From 83df7ad8167a30b57a0f81a6dfb407f2ef2f83ce Mon Sep 17 00:00:00 2001 From: xukp20 Date: Sat, 18 Apr 2026 20:10:36 +0800 Subject: [PATCH 1/3] feat(core): add builtin /steer command --- agent/claudecode/session.go | 19 ++++- agent/claudecode/session_test.go | 42 ++++++++++ agent/codex/appserver_session.go | 43 ++++++++++ agent/codex/appserver_session_test.go | 90 +++++++++++++++++++++ core/engine.go | 36 +++++++++ core/engine_test.go | 111 ++++++++++++++++++++++++++ core/i18n.go | 54 +++++++++++-- core/interfaces.go | 8 ++ docs/usage.md | 1 + docs/usage.zh-CN.md | 1 + 10 files changed, 396 insertions(+), 9 deletions(-) diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go index b0ddf9f5b..5abd933cc 100644 --- a/agent/claudecode/session.go +++ b/agent/claudecode/session.go @@ -97,7 +97,7 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs if maxContextTokens > 0 { innerArgs = append(innerArgs, "--max-context-tokens", strconv.Itoa(maxContextTokens)) } - + // outerArgs are understood by both the wrapper and Claude CLI directly. var outerArgs []string if model != "" { @@ -569,6 +569,21 @@ func (cs *claudeSession) Send(prompt string, images []core.ImageAttachment, file }) } +// Steer appends additional guidance to the current in-flight Claude task. +// We map this to a normal user message on the same live session with +// priority=next, which matches Claude's native queue semantics for +// "process after the current step/tool boundary but before the next turn". +func (cs *claudeSession) Steer(prompt string) error { + if !cs.alive.Load() { + return fmt.Errorf("session process is not running") + } + return cs.writeJSON(map[string]any{ + "type": "user", + "priority": "next", + "message": map[string]any{"role": "user", "content": prompt}, + }) +} + func extFromMime(mime string) string { switch mime { case "image/jpeg": @@ -725,7 +740,7 @@ func (cs *claudeSession) Close() error { // Uses single quotes because some splitters (e.g. my_cli) don't support // backslash escapes inside double quotes. For values containing single // quotes, we close the single-quoted segment, add an escaped single -// quote, and reopen: 'it'\''s' → it's +// quote, and reopen: 'it'\”s' → it's func shellJoinArgs(args []string) string { var b strings.Builder for i, a := range args { diff --git a/agent/claudecode/session_test.go b/agent/claudecode/session_test.go index 53f029a4f..94eca2064 100644 --- a/agent/claudecode/session_test.go +++ b/agent/claudecode/session_test.go @@ -3,6 +3,7 @@ package claudecode import ( "bytes" "context" + "encoding/json" "io" "os" "os/exec" @@ -12,6 +13,12 @@ import ( "github.com/chenhg5/cc-connect/core" ) +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { return nil } + func TestHandleResultParsesUsage(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -71,6 +78,41 @@ func TestHandleResultNoUsage(t *testing.T) { } } +func TestClaudeSessionSteer_UsesNextPriorityUserMessage(t *testing.T) { + var buf bytes.Buffer + cs := &claudeSession{ + stdin: nopWriteCloser{Writer: &buf}, + } + cs.alive.Store(true) + + if err := cs.Steer("focus on failing tests first"); err != nil { + t.Fatalf("Steer() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &payload); err != nil { + t.Fatalf("decode steer payload: %v", err) + } + + if got := payload["type"]; got != "user" { + t.Fatalf("type = %#v, want user", got) + } + if got := payload["priority"]; got != "next" { + t.Fatalf("priority = %#v, want next", got) + } + + message, ok := payload["message"].(map[string]any) + if !ok { + t.Fatalf("message = %#v, want object", payload["message"]) + } + if got := message["role"]; got != "user" { + t.Fatalf("message.role = %#v, want user", got) + } + if got := message["content"]; got != "focus on failing tests first" { + t.Fatalf("message.content = %#v, want steer text", got) + } +} + func TestReadLoop_ChildHoldsStdoutPipe(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/agent/codex/appserver_session.go b/agent/codex/appserver_session.go index 25b847f41..bd5b99c05 100644 --- a/agent/codex/appserver_session.go +++ b/agent/codex/appserver_session.go @@ -64,6 +64,10 @@ type turnStartResponse struct { } `json:"turn"` } +type turnSteerResponse struct { + TurnID string `json:"turnId"` +} + type turnNotification struct { ThreadID string `json:"threadId"` Turn struct { @@ -451,6 +455,45 @@ func (s *appServerSession) Send(prompt string, images []core.ImageAttachment, fi return nil } +// Steer appends additional guidance to the currently active regular turn. +// This uses Codex app-server's native same-turn steering API rather than +// starting a new turn. +func (s *appServerSession) Steer(prompt string) error { + if !s.alive.Load() { + return fmt.Errorf("session is closed") + } + + threadID := s.CurrentSessionID() + if threadID == "" { + return fmt.Errorf("codex app-server thread id is empty") + } + + s.stateMu.Lock() + turnID := s.currentTurn + s.stateMu.Unlock() + if turnID == "" { + return fmt.Errorf("codex app-server has no active turn to steer") + } + + params := map[string]any{ + "threadId": threadID, + "expectedTurnId": turnID, + "input": []map[string]any{{ + "type": "text", + "text": prompt, + }}, + } + + var resp turnSteerResponse + if err := s.request("turn/steer", params, &resp); err != nil { + return fmt.Errorf("codex app-server turn/steer: %w", err) + } + if resp.TurnID == "" { + return fmt.Errorf("codex app-server turn/steer returned empty turn id") + } + return nil +} + func (s *appServerSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) { if len(images) == 0 { return prompt, nil, nil diff --git a/agent/codex/appserver_session_test.go b/agent/codex/appserver_session_test.go index f865ed9a2..9debe7e46 100644 --- a/agent/codex/appserver_session_test.go +++ b/agent/codex/appserver_session_test.go @@ -1,8 +1,11 @@ package codex import ( + "bytes" "context" "encoding/json" + "io" + "sync" "testing" "github.com/chenhg5/cc-connect/core" @@ -162,6 +165,93 @@ func TestMapAppServerRateLimits_PrefersMultiBucketView(t *testing.T) { } } +func TestAppServerSessionSteer_RequiresActiveTurn(t *testing.T) { + s := &appServerSession{ + ctx: context.Background(), + pending: make(map[int64]chan rpcResponseEnvelope), + } + s.alive.Store(true) + s.threadID.Store("thread-1") + + err := s.Steer("focus on failing tests first") + if err == nil || err.Error() != "codex app-server has no active turn to steer" { + t.Fatalf("Steer() error = %v, want no active turn error", err) + } +} + +func TestAppServerSessionSteer_RequestShape(t *testing.T) { + var buf bytes.Buffer + s := &appServerSession{ + ctx: context.Background(), + stdin: nopAppServerWriteCloser{Writer: &buf}, + pending: make(map[int64]chan rpcResponseEnvelope), + } + s.alive.Store(true) + s.threadID.Store("thread-1") + s.currentTurn = "turn-1" + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + s.pendingMu.Lock() + ch := s.pending[1] + s.pendingMu.Unlock() + if ch != nil { + ch <- rpcResponseEnvelope{ID: int64(1), Result: json.RawMessage(`{"turnId":"turn-1"}`)} + return + } + } + }() + + if err := s.Steer("focus on failing tests first"); err != nil { + t.Fatalf("Steer() error = %v", err) + } + wg.Wait() + + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &payload); err != nil { + t.Fatalf("decode steer payload: %v", err) + } + + if got := payload["method"]; got != "turn/steer" { + t.Fatalf("method = %#v, want turn/steer", got) + } + + params, ok := payload["params"].(map[string]any) + if !ok { + t.Fatalf("params = %#v, want object", payload["params"]) + } + if got := params["threadId"]; got != "thread-1" { + t.Fatalf("threadId = %#v, want thread-1", got) + } + if got := params["expectedTurnId"]; got != "turn-1" { + t.Fatalf("expectedTurnId = %#v, want turn-1", got) + } + + input, ok := params["input"].([]any) + if !ok || len(input) != 1 { + t.Fatalf("input = %#v, want single-element array", params["input"]) + } + item, ok := input[0].(map[string]any) + if !ok { + t.Fatalf("input[0] = %#v, want object", input[0]) + } + if got := item["type"]; got != "text" { + t.Fatalf("input[0].type = %#v, want text", got) + } + if got := item["text"]; got != "focus on failing tests first" { + t.Fatalf("input[0].text = %#v, want steer text", got) + } +} + +type nopAppServerWriteCloser struct { + io.Writer +} + +func (nopAppServerWriteCloser) Close() error { return nil } + var _ interface { GetUsage(context.Context) (*core.UsageReport, error) } = (*appServerSession)(nil) diff --git a/core/engine.go b/core/engine.go index 45c161adf..fe16be613 100644 --- a/core/engine.go +++ b/core/engine.go @@ -3261,6 +3261,7 @@ var builtinCommands = []struct { {[]string{"heartbeat", "hb"}, "heartbeat"}, {[]string{"compress", "compact"}, "compress"}, {[]string{"stop"}, "stop"}, + {[]string{"steer"}, "steer"}, {[]string{"help"}, "help"}, {[]string{"version"}, "version"}, {[]string{"commands", "command", "cmd"}, "commands"}, @@ -3436,6 +3437,8 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { e.cmdCompress(p, msg) case "stop": e.cmdStop(p, msg) + case "steer": + e.cmdSteer(p, msg, args) case "help": e.cmdHelp(p, msg) case "version": @@ -5559,6 +5562,7 @@ func helpCardGroups() []helpCardGroup { {command: "/alias", action: "nav:/alias"}, {command: "/skills", action: "nav:/skills"}, {command: "/compress", action: "cmd:/compress"}, + {command: "/steer", action: "cmd:/steer"}, {command: "/stop", action: "act:/stop"}, }, }, @@ -6246,6 +6250,38 @@ func (e *Engine) cmdStop(p Platform, msg *Message) { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgExecutionStopped)) } +func (e *Engine) cmdSteer(p Platform, msg *Message, args []string) { + text := strings.TrimSpace(strings.Join(args, " ")) + if text == "" { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSteerEmpty)) + return + } + + iKey := e.interactiveKeyForSessionKey(msg.SessionKey) + e.interactiveMu.Lock() + state, ok := e.interactiveStates[iKey] + e.interactiveMu.Unlock() + + if !ok || state == nil || state.agentSession == nil || !state.agentSession.Alive() { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNoExecution)) + return + } + + steerer, ok := state.agentSession.(SessionSteerer) + if !ok { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSteerNotSupported)) + return + } + + if err := steerer.Steer(text); err != nil { + slog.Error("steer: send failed", "error", err) + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSteerSendFailed)) + return + } + + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSteerSent)) +} + func (e *Engine) stopInteractiveSession(sessionKey string, quietPlatform Platform, quietReplyCtx any) bool { e.interactiveMu.Lock() state, ok := e.interactiveStates[sessionKey] diff --git a/core/engine_test.go b/core/engine_test.go index 040818d6a..d0ca7d14c 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -48,6 +48,17 @@ func (s *recordingAgentSession) RespondPermission(id string, res PermissionResul return nil } +type steerSession struct { + stubAgentSession + lastPrompt string + err error +} + +func (s *steerSession) Steer(prompt string) error { + s.lastPrompt = prompt + return s.err +} + type stubPlatformEngine struct { n string sent []string @@ -7334,6 +7345,106 @@ func TestCmdStop_UsesInteractiveKeyForMultiWorkspace(t *testing.T) { } } +func TestCmdSteer_NoExecution_RepliesNoExecution(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "test:user1", Content: "/steer focus", ReplyCtx: "ctx"} + + e.cmdSteer(p, msg, []string{"focus"}) + + sent := p.getSent() + if len(sent) == 0 { + t.Fatal("expected a reply") + } + if !strings.Contains(sent[0], e.i18n.T(MsgNoExecution)) { + t.Fatalf("expected MsgNoExecution, got %q", sent[0]) + } +} + +func TestCmdSteer_Empty_RepliesUsage(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "test:user1", Content: "/steer", ReplyCtx: "ctx"} + + e.cmdSteer(p, msg, nil) + + sent := p.getSent() + if len(sent) == 0 { + t.Fatal("expected a reply") + } + if !strings.Contains(sent[0], e.i18n.T(MsgSteerEmpty)) { + t.Fatalf("expected MsgSteerEmpty, got %q", sent[0]) + } +} + +func TestCmdSteer_NotSupported_RepliesNotSupported(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + key := "test:user1" + + e.interactiveMu.Lock() + e.interactiveStates[key] = &interactiveState{agentSession: &stubAgentSession{}} + e.interactiveMu.Unlock() + + msg := &Message{SessionKey: key, Content: "/steer focus", ReplyCtx: "ctx"} + e.cmdSteer(p, msg, []string{"focus"}) + + sent := p.getSent() + if len(sent) == 0 { + t.Fatal("expected a reply") + } + if !strings.Contains(sent[0], e.i18n.T(MsgSteerNotSupported)) { + t.Fatalf("expected MsgSteerNotSupported, got %q", sent[0]) + } +} + +func TestCmdSteer_Success_SendsGuidance(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + key := "test:user1" + sess := &steerSession{} + + e.interactiveMu.Lock() + e.interactiveStates[key] = &interactiveState{agentSession: sess} + e.interactiveMu.Unlock() + + msg := &Message{SessionKey: key, Content: "/steer focus on tests", ReplyCtx: "ctx"} + e.cmdSteer(p, msg, []string{"focus", "on", "tests"}) + + if sess.lastPrompt != "focus on tests" { + t.Fatalf("steer prompt = %q, want %q", sess.lastPrompt, "focus on tests") + } + sent := p.getSent() + if len(sent) == 0 { + t.Fatal("expected a reply") + } + if !strings.Contains(sent[0], e.i18n.T(MsgSteerSent)) { + t.Fatalf("expected MsgSteerSent, got %q", sent[0]) + } +} + +func TestCmdSteer_Error_RepliesFailed(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + key := "test:user1" + sess := &steerSession{err: errors.New("boom")} + + e.interactiveMu.Lock() + e.interactiveStates[key] = &interactiveState{agentSession: sess} + e.interactiveMu.Unlock() + + msg := &Message{SessionKey: key, Content: "/steer focus", ReplyCtx: "ctx"} + e.cmdSteer(p, msg, []string{"focus"}) + + sent := p.getSent() + if len(sent) == 0 { + t.Fatal("expected a reply") + } + if !strings.Contains(sent[0], e.i18n.T(MsgSteerSendFailed)) { + t.Fatalf("expected MsgSteerSendFailed, got %q", sent[0]) + } +} + // =========================================================================== // Beta pre-release tests: inject_sender, idle_timeout, /shell, /workspace, // /switch, /memory diff --git a/core/i18n.go b/core/i18n.go index 025b046af..175354d25 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -246,8 +246,8 @@ const ( MsgCronBtnUnmute MsgKey = "cron_btn_unmute" MsgCronBtnDelete MsgKey = "cron_btn_delete" - MsgStatusTitle MsgKey = "status_title" - MsgReplyFooterRemaining MsgKey = "reply_footer_remaining" + MsgStatusTitle MsgKey = "status_title" + MsgReplyFooterRemaining MsgKey = "reply_footer_remaining" MsgModelCurrent MsgKey = "model_current" MsgModelChanged MsgKey = "model_changed" MsgModelChangeFailed MsgKey = "model_change_failed" @@ -263,6 +263,10 @@ const ( MsgCompressing MsgKey = "compressing" MsgCompressNoSession MsgKey = "compress_no_session" MsgCompressDone MsgKey = "compress_done" + MsgSteerSent MsgKey = "steer_sent" + MsgSteerSendFailed MsgKey = "steer_send_failed" + MsgSteerEmpty MsgKey = "steer_empty" + MsgSteerNotSupported MsgKey = "steer_not_supported" MsgMemoryNotSupported MsgKey = "memory_not_supported" MsgMemoryShowProject MsgKey = "memory_show_project" @@ -489,6 +493,7 @@ const ( MsgBuiltinCmdQuiet MsgKey = "quiet" MsgBuiltinCmdCompress MsgKey = "compress" MsgBuiltinCmdStop MsgKey = "stop" + MsgBuiltinCmdSteer MsgKey = "steer" MsgBuiltinCmdCron MsgKey = "cron" MsgBuiltinCmdCommands MsgKey = "commands" MsgBuiltinCmdAlias MsgKey = "alias" @@ -643,11 +648,11 @@ var messages = map[MsgKey]map[Language]string{ LangSpanish: "No hay ejecución en progreso.", }, MsgPreviousProcessing: { - LangEnglish: "⏳ Previous request still processing. Use `/btw ` to add context to the current turn.", - LangChinese: "⏳ 上一个请求仍在处理中。使用 `/btw <消息>` 可向当前轮次追加上下文。", - LangTraditionalChinese: "⏳ 上一個請求仍在處理中。使用 `/btw <訊息>` 可向當前輪次追加上下文。", - LangJapanese: "⏳ 前のリクエストを処理中です。`/btw <メッセージ>` で現在のターンにコンテキストを追加できます。", - LangSpanish: "⏳ La solicitud anterior aún se está procesando. Use `/btw ` para agregar contexto al turno actual.", + LangEnglish: "⏳ Previous request still processing. Use `/steer ` to add guidance to the current task.", + LangChinese: "⏳ 上一个请求仍在处理中。使用 `/steer <消息>` 可向当前任务追加引导。", + LangTraditionalChinese: "⏳ 上一個請求仍在處理中。使用 `/steer <訊息>` 可向當前任務追加引導。", + LangJapanese: "⏳ 前のリクエストを処理中です。`/steer <メッセージ>` で現在のタスクに追加の指示を送れます。", + LangSpanish: "⏳ La solicitud anterior aún se está procesando. Use `/steer ` para agregar instrucciones a la tarea actual.", }, MsgMessageQueued: { LangEnglish: "📬 Message received — will process after the current task finishes.", @@ -2052,6 +2057,34 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "✅ コンテキスト圧縮完了。", LangSpanish: "✅ Contexto comprimido.", }, + MsgSteerSent: { + LangEnglish: "✅ Guidance sent to the current task.", + LangChinese: "✅ 已向当前任务发送引导。", + LangTraditionalChinese: "✅ 已向當前任務送出引導。", + LangJapanese: "✅ 現在のタスクに追加の指示を送信しました。", + LangSpanish: "✅ Instrucciones enviadas a la tarea actual.", + }, + MsgSteerSendFailed: { + LangEnglish: "❌ Failed to send guidance to the current task.", + LangChinese: "❌ 向当前任务发送引导失败。", + LangTraditionalChinese: "❌ 向當前任務送出引導失敗。", + LangJapanese: "❌ 現在のタスクへの追加指示の送信に失敗しました。", + LangSpanish: "❌ Error al enviar instrucciones a la tarea actual.", + }, + MsgSteerEmpty: { + LangEnglish: "Usage: `/steer `", + LangChinese: "用法:`/steer <消息>`", + LangTraditionalChinese: "用法:`/steer <訊息>`", + LangJapanese: "使い方:`/steer <メッセージ>`", + LangSpanish: "Uso: `/steer `", + }, + MsgSteerNotSupported: { + LangEnglish: "❌ This agent does not support `/steer`.", + LangChinese: "❌ 当前 Agent 不支持 `/steer`。", + LangTraditionalChinese: "❌ 當前 Agent 不支援 `/steer`。", + LangJapanese: "❌ このエージェントは `/steer` をサポートしていません。", + LangSpanish: "❌ Este agente no admite `/steer`.", + }, // Inline strings for engine.go commands MsgStatusMode: { @@ -3317,6 +3350,13 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "現在の実行を停止", LangSpanish: "Detener ejecución actual", }, + MsgBuiltinCmdSteer: { + LangEnglish: "Add guidance to the current in-flight task", + LangChinese: "向当前执行中的任务追加引导", + LangTraditionalChinese: "向當前執行中的任務追加引導", + LangJapanese: "現在実行中のタスクに追加の指示を送る", + LangSpanish: "Agregar instrucciones a la tarea en curso", + }, MsgBuiltinCmdCron: { LangEnglish: "Manage scheduled tasks, arg: [add|list|del|enable|disable]", LangChinese: "管理定时任务,参数: [add|list|del|enable|disable]", diff --git a/core/interfaces.go b/core/interfaces.go index 885fd5b93..b8894f0a0 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -401,6 +401,14 @@ type ContextCompressor interface { CompressCommand() string } +// SessionSteerer is an optional interface for running agent sessions that can +// append additional user guidance to the current in-flight task without +// starting a new task. Backends should map this to their native same-turn +// steering semantics when available. +type SessionSteerer interface { + Steer(prompt string) error +} + // CommandProvider is an optional interface for agents that expose custom slash // commands via local files (e.g. .claude/commands/*.md). The engine scans the // returned directories for *.md files and registers them as slash commands. diff --git a/docs/usage.md b/docs/usage.md index a476d7372..df8e16c41 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,6 +43,7 @@ Each user gets an independent session with full conversation context. Manage ses | `/allow ` | Pre-allow a tool (next session) | | `/reasoning [level]` | View or switch reasoning effort (Codex) | | `/mode [name]` | View or switch permission mode | +| `/steer ` | Add guidance to the current in-flight task | | `/stop` | Stop current execution | | `/help` | Show available commands | diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md index fa2ef64cb..3e88cf86c 100644 --- a/docs/usage.zh-CN.md +++ b/docs/usage.zh-CN.md @@ -45,6 +45,7 @@ cc-connect 完整功能使用指南。 | `/allow <工具名>` | 预授权工具 | | `/reasoning [等级]` | 查看或切换推理强度(Codex)| | `/mode [名称]` | 查看或切换权限模式 | +| `/steer <消息>` | 向当前执行中的任务追加引导 | | `/stop` | 停止当前执行 | | `/help` | 显示可用命令 | From dba1efece2128d850c61e4fbbd4a68c333f23052 Mon Sep 17 00:00:00 2001 From: xukp20 Date: Sun, 19 Apr 2026 22:41:56 +0800 Subject: [PATCH 2/3] fix(claudecode): restore shellJoinArgs comment --- agent/claudecode/session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go index 5abd933cc..810576765 100644 --- a/agent/claudecode/session.go +++ b/agent/claudecode/session.go @@ -740,7 +740,7 @@ func (cs *claudeSession) Close() error { // Uses single quotes because some splitters (e.g. my_cli) don't support // backslash escapes inside double quotes. For values containing single // quotes, we close the single-quoted segment, add an escaped single -// quote, and reopen: 'it'\”s' → it's +// quote, and reopen: 'it'\''s' -> it's func shellJoinArgs(args []string) string { var b strings.Builder for i, a := range args { From a503798c2967d73371f349f9db2418fcc6804f8a Mon Sep 17 00:00:00 2001 From: xukp20 Date: Wed, 6 May 2026 10:11:08 +0800 Subject: [PATCH 3/3] test(core): set bridge token in capabilities snapshot test --- core/bridge_capabilities_snapshot_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/bridge_capabilities_snapshot_test.go b/core/bridge_capabilities_snapshot_test.go index 9f393a599..bf5070f51 100644 --- a/core/bridge_capabilities_snapshot_test.go +++ b/core/bridge_capabilities_snapshot_test.go @@ -13,7 +13,7 @@ func TestBridgeBuildCapabilitiesSnapshotIncludesProjectCatalog(t *testing.T) { CurrentBuildTime = prevBuildTime }() - bs := NewBridgeServer(0, "", "/bridge/ws", nil) + bs := NewBridgeServer(0, "test-token", "/bridge/ws", nil) bp := bs.NewPlatform("test-proj") e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish) e.AddCommand("deploy", "Deploy app", "ship it", "", "", "config")