Merge branch 'main' into feat/outbound-bot-mention-resolution

This commit is contained in:
Yuanhao Luo
2026-06-23 20:08:30 +08:00
committed by GitHub
63 changed files with 2665 additions and 502 deletions

View File

@@ -9,6 +9,7 @@
- **Skill discovery depth-1 only**: skill scanning no longer recurses into subdirectories. Only `<skill_dir>/<name>/SKILL.md` is registered; nested SKILL.md files (e.g. inside `<name>/references/...`) are treated as skill assets and ignored, matching the Claude Code CLI convention. Previously, nested SKILL.md files leaked into platform command menus as phantom slash commands (101 leaked commands from `frontend-design` skill alone) (#1304).
- **Feishu: tighter `@` mention detection in `SendWithStatusFooter` / `buildReplyContent`** — a bare `@` inside an email address, URL, or escaped character no longer false-positives as a mention. Mention detection now checks for the resolved `<at user_id="...">` tag instead of a substring match, so card rendering (and the notation-style status footer) is preserved for content that merely contains `@`. Real `@mentions` still force `MsgTypeText` so Feishu fires the mention event (#1341).
- **feishu**: coalesce consecutive image messages from the same session into a single multi-image dispatch to fix first-image drop on batch sends (#1395). When the Feishu mobile client sends N images in quick succession, each image arrives as a separate `image` event with very close `create_time` values. Dispatching each immediately caused core/engine's `create_time` watermark (PR #1168) to drop the oldest image, so the agent only saw N-1 images. A per-session image buffer with a 150ms quiet window now merges the burst into one `core.Message` carrying all images, in send order. Single-image sends and quoted-image replies are unaffected.
- **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free.
## v1.3.3 (2026-06-15)
@@ -46,6 +47,8 @@ the full themed summary; per-beta details remain in the beta sections below.
test framework (#1348)
- **core**: queued FIFO drains no longer drop earlier queued messages as stale just
because a later queued message has a higher `create_time` (#1286)
- **core**: make `/history` entry truncation configurable via
`[display].history_max_len`, defaulting to 1000; `0` disables truncation (#1291)
- **tts/minimax**: drop `status=2` trailer chunk to stop audio playing twice (#1364)
- **tests**: add provider-resume regression tests for codex / opencode / kimi (#1366)

View File

@@ -19,14 +19,15 @@ func init() {
// Agent runs an ACP (Agent Client Protocol) agent subprocess over stdio JSON-RPC.
type Agent struct {
workDir string
command string
args []string
staticEnv map[string]string
extraEnv []string
sessionEnv []string
authMethod string // optional, e.g. "cursor_login" for Cursor CLI (see authenticate RPC)
displayName string // optional, for doctor (default "ACP")
workDir string
cmd string
cliExtraArgs []string // extra args from cmd, prepended before args
args []string
staticEnv map[string]string
extraEnv []string
sessionEnv []string
authMethod string // optional, e.g. "cursor_login" for Cursor CLI (see authenticate RPC)
displayName string // optional, for doctor (default "ACP")
// mode is the pending permission mode to apply to new sessions.
// When set, StartSession applies it via session/set_mode right after
@@ -72,10 +73,9 @@ func New(opts map[string]any) (core.Agent, error) {
if workDir == "" {
workDir = "."
}
cmdStr, _ := opts["command"].(string)
cmdStr = strings.TrimSpace(cmdStr)
cmdStr, cliExtraArgs := core.ParseCmdOpts(opts, "")
if cmdStr == "" {
return nil, fmt.Errorf("acp: agent option \"command\" is required (path or name of the ACP agent binary)")
return nil, fmt.Errorf("acp: agent option \"cmd\" or \"command\" is required (path or name of the ACP agent binary)")
}
if _, err := exec.LookPath(cmdStr); err != nil {
return nil, fmt.Errorf("acp: command %q not found in PATH: %w", cmdStr, err)
@@ -96,7 +96,8 @@ func New(opts map[string]any) (core.Agent, error) {
return &Agent{
workDir: workDir,
command: cmdStr,
cmd: cmdStr,
cliExtraArgs: cliExtraArgs,
args: args,
staticEnv: staticEnv,
extraEnv: extra,
@@ -194,7 +195,7 @@ func (a *Agent) WorkspaceAgentOptions() map[string]any {
defer a.mu.RUnlock()
opts := map[string]any{
"command": a.command,
"cmd": a.cmd,
}
if len(a.args) > 0 {
opts["args"] = append([]string(nil), a.args...)
@@ -223,8 +224,8 @@ func (a *Agent) SetSessionEnv(env []string) {
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.RLock()
command := a.command
args := a.args
command := a.cmd
allArgs := append(append([]string{}, a.cliExtraArgs...), a.args...)
workDir := a.workDir
authMethod := a.authMethod
pendingMode := a.mode
@@ -234,7 +235,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
return newACPSession(ctx, acpSessionConfig{
command: command,
args: args,
args: allArgs,
extraEnv: extra,
workDir: workDir,
resumeSessionID: sessionID,
@@ -250,9 +251,8 @@ func (a *Agent) Stop() error { return nil }
func (a *Agent) CLIBinaryName() string {
a.mu.RLock()
cmd := a.command
a.mu.RUnlock()
return filepath.Base(cmd)
defer a.mu.RUnlock()
return filepath.Base(a.cmd)
}
func (a *Agent) CLIDisplayName() string {

View File

@@ -52,8 +52,8 @@ func TestWorkspaceAgentOptions(t *testing.T) {
}
opts := snapshotter.WorkspaceAgentOptions()
if got, _ := opts["command"].(string); got != "true" {
t.Fatalf("command = %q, want true", got)
if got, _ := opts["cmd"].(string); got != "true" {
t.Fatalf("cmd = %q, want true", got)
}
gotArgs, _ := opts["args"].([]string)
if len(gotArgs) != 2 || gotArgs[0] != "--acp" || gotArgs[1] != "--stdio" {

View File

@@ -67,7 +67,8 @@ type acpSessionListEntry struct {
// and starts its readLoop. The caller owns the returned `teardown`
// func and must invoke it to reap the child process.
func (a *Agent) probeSpawn(ctx context.Context, cwd string) (*transport, *bytes.Buffer, func(), error) {
cmd := exec.CommandContext(ctx, a.command, a.args...)
allArgs := append(append([]string{}, a.cliExtraArgs...), a.args...)
cmd := exec.CommandContext(ctx, a.cmd, allArgs...)
cmd.Dir = cwd
cmd.Env = core.MergeEnv(os.Environ(), a.extraEnv)
@@ -83,7 +84,7 @@ func (a *Agent) probeSpawn(ctx context.Context, cwd string) (*transport, *bytes.
cmd.Stderr = io.MultiWriter(&stderrBuf)
if err := cmd.Start(); err != nil {
return nil, nil, nil, fmt.Errorf("acp: probe start %s: %w", a.command, err)
return nil, nil, nil, fmt.Errorf("acp: probe start %s: %w", a.cmd, err)
}
// The server-request handler needs to reference `tr` itself in order
@@ -210,7 +211,7 @@ func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, erro
return nil, err
}
if len(init.AgentCapabilities.SessionCapabilities.List) == 0 {
slog.Info("acp: session/list unsupported by agent, caching for fast-path", "command", a.command)
slog.Info("acp: session/list unsupported by agent, caching for fast-path", "command", a.cmd)
a.listUnsupported.Store(true)
return nil, nil
}

View File

@@ -30,15 +30,17 @@ func init() {
// - "yolo": auto-approve all tools (--dangerously-skip-permissions)
// - "plan": read-only plan mode with terminal sandbox constraints (--sandbox)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "agy"
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
workDir string
model string
mode string
cmd string // CLI binary name, default "agy"
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -49,10 +51,7 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "agy"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "agy")
var timeoutMins int64
switch v := opts["timeout_mins"].(type) {
@@ -77,12 +76,14 @@ func New(opts map[string]any) (core.Agent, error) {
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
timeout: timeout,
activeIdx: -1,
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
timeout: timeout,
activeIdx: -1,
}, nil
}
@@ -206,9 +207,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
timeout := a.timeout
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -217,7 +220,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.Unlock()
return newAntigravitySession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
return newAntigravitySession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv, timeout)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {

View File

@@ -24,6 +24,7 @@ import (
// antigravitySession manages multi-turn conversations with the Antigravity CLI (agy).
type antigravitySession struct {
cmd string
extraArgs []string // extra args from cmd, prepended before agy args
workDir string
model string
mode string
@@ -43,19 +44,20 @@ type antigravitySession struct {
var permissionPromptPattern = regexp.MustCompile(`(?is)(allow|approve|permission).{0,400}(\(y/n\)|\(y\/n\)|\(y\/N\)|\(Y\/n\)|\[y\/n\]|\[y\/N\]|\[Y\/n\]|yes\/no)`)
func newAntigravitySession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*antigravitySession, error) {
func newAntigravitySession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*antigravitySession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
as := &antigravitySession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
as.alive.Store(true)
@@ -139,7 +141,7 @@ func (as *antigravitySession) Send(prompt string, images []core.ImageAttachment,
}
fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]"
}
args := buildAntigravityArgs(chatID, isResume, as.mode, fullPrompt)
args := as.buildAntigravityArgs(chatID, isResume, as.mode, fullPrompt)
if strings.TrimSpace(as.model) != "" {
slog.Warn("antigravitySession: model is configured but ignored because agy does not support --model yet", "model", as.model)
}
@@ -201,9 +203,10 @@ func (as *antigravitySession) Send(prompt string, images []core.ImageAttachment,
return nil
}
func buildAntigravityArgs(chatID string, isResume bool, mode, fullPrompt string) []string {
func (as *antigravitySession) buildAntigravityArgs(chatID string, isResume bool, mode, fullPrompt string) []string {
// Prepend extra args from cmd so wrappers like "timeout 3600 agy" work.
// Keep "-p <prompt>" at the very end because agy consumes the immediate next arg.
args := make([]string, 0, 10)
args := append([]string{}, as.extraArgs...)
if isResume {
args = append(args, "--conversation", chatID)
}

View File

@@ -57,7 +57,7 @@ func TestNormalizeMode(t *testing.T) {
}
func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", core.ContinueSession, nil, 0)
if err != nil {
t.Fatalf("newAntigravitySession: %v", err)
}
@@ -69,7 +69,8 @@ func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) {
}
func TestBuildAntigravityArgs_PromptAtEnd(t *testing.T) {
args := buildAntigravityArgs("sid-1", true, "plan", "What is 1+1?")
s, _ := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", "", nil, 0)
args := s.buildAntigravityArgs("sid-1", true, "plan", "What is 1+1?")
if len(args) < 2 {
t.Fatalf("args too short: %v", args)
}
@@ -97,7 +98,7 @@ func TestUsesInteractivePermission(t *testing.T) {
}
func TestRespondPermission_WritesTerminalAnswer(t *testing.T) {
s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", "", nil, 0)
s, err := newAntigravitySession(context.Background(), "echo", nil, "/tmp", "", "default", "", nil, 0)
if err != nil {
t.Fatalf("newAntigravitySession: %v", err)
}

View File

@@ -35,10 +35,10 @@ func init() {
// - "bypassPermissions": auto-approve everything (alias: yolo)
type Agent struct {
workDir string
cliBin string // CLI binary name or path (default: "claude")
cliExtraArgs []string // extra args parsed from cli_path (e.g. ["code", "-t", "foo"])
cmd string // CLI binary name (default: "claude")
cliExtraArgs []string // extra args parsed from cmd (e.g. ["code", "-t", "foo"])
configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
cliArgsFlag string // if set, claude args are passed as a single string via this flag (e.g. "-a")
cmdArgsFlag string // if set, claude args are passed as a single string via this flag (e.g. "-a")
model string
reasoningEffort string // "low" | "medium" | "high" | "max"
mode string // "default" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | "dontAsk"
@@ -129,18 +129,17 @@ func New(opts map[string]any) (core.Agent, error) {
if workDir == "" {
workDir = "."
}
cliBin := "claude"
var cliExtraArgs []string
if cliPath, _ := opts["cli_path"].(string); cliPath != "" {
// NOTE: paths containing spaces are not supported because Fields
// splits on whitespace. Use a symlink or wrapper script instead.
parts := strings.Fields(cliPath)
cliBin = parts[0]
if len(parts) > 1 {
cliExtraArgs = parts[1:]
cmd, cliExtraArgs := core.ParseCmdOpts(opts, "claude")
cmdArgsFlag, _ := opts["cmd_args_flag"].(string)
if cmdArgsFlag == "" {
if v, ok := opts["cli_args_flag"].(string); ok && v != "" {
slog.Warn("DEPRECATED: 'cli_args_flag' is deprecated, use 'cmd_args_flag' instead",
"deprecated_key", "cli_args_flag",
"new_key", "cmd_args_flag",
"value", v)
cmdArgsFlag = v
}
}
cliArgsFlag, _ := opts["cli_args_flag"].(string)
model, _ := opts["model"].(string)
reasoningEffort, _ := opts["reasoning_effort"].(string)
mode, _ := opts["mode"].(string)
@@ -216,8 +215,8 @@ func New(opts map[string]any) (core.Agent, error) {
// skip the supervisor-side LookPath check and let spawn fail loudly
// at runtime if the target doesn't have claude installed.
if !spawnOpts.IsolationMode() {
if _, err := exec.LookPath(cliBin); err != nil {
return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cliBin)
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cmd)
}
}
@@ -248,9 +247,9 @@ func New(opts map[string]any) (core.Agent, error) {
return &Agent{
workDir: workDir,
cliBin: cliBin,
cmd: cmd,
cliExtraArgs: cliExtraArgs,
cliArgsFlag: cliArgsFlag,
cmdArgsFlag: cmdArgsFlag,
model: model,
reasoningEffort: normalizeEffort(reasoningEffort),
mode: mode,
@@ -308,7 +307,7 @@ func normalizePermissionMode(raw string) string {
}
func (a *Agent) Name() string { return "claudecode" }
func (a *Agent) CLIBinaryName() string { return a.cliBin }
func (a *Agent) CLIBinaryName() string { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "Claude" }
func (a *Agent) SetWorkDir(dir string) {
@@ -527,7 +526,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
disableVerbose := a.routerURL != ""
a.mu.Unlock()
return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt, tools, disTools, pluginDirs, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok, a.ccDataDir)
return newClaudeSession(ctx, workDir, a.cmd, a.cliExtraArgs, a.cmdArgsFlag, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt, tools, disTools, pluginDirs, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok, a.ccDataDir)
}
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
@@ -815,7 +814,7 @@ func (a *Agent) GetRunAsEnv() []string {
// must propagate to per-workspace agent instances created lazily by
// core.Engine.getOrCreateWorkspaceAgent. Without this snapshot, the engine
// constructs workspace agents from a fresh opts map and silently drops
// every claudecode field except mode/model — so cli_path, allowed_tools,
// every claudecode field except mode/model — so cmd, allowed_tools,
// and friends would only take effect on the project-level agent.
//
// Runtime-only state (providers, sessionEnv, providerProxy, platformPrompt)
@@ -842,11 +841,11 @@ func (a *Agent) WorkspaceAgentOptions() map[string]any {
}
opts["env"] = envMap
}
if cliPath := snapshotCLIPath(a.cliBin, a.cliExtraArgs); cliPath != "" {
opts["cli_path"] = cliPath
if cliPath := snapshotCmdPath(a.cmd, a.cliExtraArgs); cliPath != "" {
opts["cmd"] = cliPath
}
if a.cliArgsFlag != "" {
opts["cli_args_flag"] = a.cliArgsFlag
if a.cmdArgsFlag != "" {
opts["cmd_args_flag"] = a.cmdArgsFlag
}
if a.model != "" {
opts["model"] = a.model
@@ -875,22 +874,22 @@ func (a *Agent) WorkspaceAgentOptions() map[string]any {
return opts
}
// snapshotCLIPath rebuilds the cli_path opts string from cliBin and the
// snapshotCmdPath rebuilds the cmd opts string from cmd and the
// extra-args tail captured at construction. Returns "" when only the
// default "claude" binary is in use, so we don't pollute the workspace
// opts with a redundant default.
func snapshotCLIPath(cliBin string, cliExtraArgs []string) string {
func snapshotCmdPath(cmd string, cliExtraArgs []string) string {
// Normalise empty to the default binary so we can reason about extra args.
if cliBin == "" {
cliBin = "claude"
if cmd == "" {
cmd = "claude"
}
if cliBin == "claude" && len(cliExtraArgs) == 0 {
if cmd == "claude" && len(cliExtraArgs) == 0 {
return "" // default binary, no extra args — no need to persist
}
if len(cliExtraArgs) == 0 {
return cliBin
return cmd
}
return cliBin + " " + strings.Join(cliExtraArgs, " ")
return cmd + " " + strings.Join(cliExtraArgs, " ")
}
// stringsToAny copies a []string into a fresh []any so it round-trips

View File

@@ -262,12 +262,12 @@ func TestAgent_Name(t *testing.T) {
}
func TestAgent_CLIBinaryName(t *testing.T) {
a := &Agent{cliBin: "claude"}
a := &Agent{cmd: "claude"}
if got := a.CLIBinaryName(); got != "claude" {
t.Errorf("CLIBinaryName() = %q, want %q", got, "claude")
}
a2 := &Agent{cliBin: "my-cli"}
a2 := &Agent{cmd: "my-cli"}
if got := a2.CLIBinaryName(); got != "my-cli" {
t.Errorf("CLIBinaryName() = %q, want %q", got, "my-cli")
}
@@ -518,7 +518,7 @@ func TestFindProjectDir_ICloudPath(t *testing.T) {
func TestSnapshotCLIPath(t *testing.T) {
cases := []struct {
name string
cliBin string
cmd string
extraArgs []string
want string
}{
@@ -530,9 +530,9 @@ func TestSnapshotCLIPath(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := snapshotCLIPath(tc.cliBin, tc.extraArgs)
got := snapshotCmdPath(tc.cmd, tc.extraArgs)
if got != tc.want {
t.Errorf("snapshotCLIPath(%q, %v) = %q, want %q", tc.cliBin, tc.extraArgs, got, tc.want)
t.Errorf("snapshotCmdPath(%q, %v) = %q, want %q", tc.cmd, tc.extraArgs, got, tc.want)
}
})
}
@@ -543,9 +543,9 @@ func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) {
// PATH. WorkspaceAgentOptions only reads fields that the production
// New() also writes; this just verifies the snapshot shape.
a := &Agent{
cliBin: "my-cli",
cmd: "my-cli",
cliExtraArgs: []string{"--add-dir", "/parent"},
cliArgsFlag: "-a",
cmdArgsFlag: "-a",
model: "claude-opus-4-7",
reasoningEffort: "high",
mode: "acceptEdits",
@@ -559,8 +559,8 @@ func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) {
want := map[string]any{
"mode": "acceptEdits",
"cli_path": "my-cli --add-dir /parent",
"cli_args_flag": "-a",
"cmd": "my-cli --add-dir /parent",
"cmd_args_flag": "-a",
"model": "claude-opus-4-7",
"reasoning_effort": "high",
"allowed_tools": []any{"Edit", "Read"},
@@ -585,9 +585,9 @@ func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) {
}
func TestWorkspaceAgentOptions_OmitsZeroValues(t *testing.T) {
// Default agent (only mode is always emitted, plus default cliBin
// "claude" should be skipped by snapshotCLIPath).
a := &Agent{cliBin: "claude", mode: "default"}
// Default agent (only mode is always emitted, plus default cmd
// "claude" should be skipped by snapshotCmdPath).
a := &Agent{cmd: "claude", mode: "default"}
got := a.WorkspaceAgentOptions()
if len(got) != 1 {
@@ -597,7 +597,7 @@ func TestWorkspaceAgentOptions_OmitsZeroValues(t *testing.T) {
t.Errorf("snapshot[mode] = %v, want %q", got["mode"], "default")
}
for _, k := range []string{
"cli_path", "cli_args_flag", "model", "reasoning_effort",
"cmd", "cmd_args_flag", "model", "reasoning_effort",
"allowed_tools", "disallowed_tools", "max_context_tokens",
"router_url", "router_api_key",
} {
@@ -621,9 +621,9 @@ func TestWorkspaceAgentOptions_RoundTripsThroughNew(t *testing.T) {
t.Skip("run_as_user-based LookPath bypass is Unix-only")
}
parent := &Agent{
cliBin: "my-cli",
cmd: "my-cli",
cliExtraArgs: []string{"code", "--add-dir", "/parent"},
cliArgsFlag: "-a",
cmdArgsFlag: "-a",
model: "claude-opus-4-7",
reasoningEffort: "high",
mode: "acceptEdits",
@@ -643,14 +643,14 @@ func TestWorkspaceAgentOptions_RoundTripsThroughNew(t *testing.T) {
}
child := a.(*Agent)
if child.cliBin != "my-cli" {
t.Errorf("cliBin = %q, want %q", child.cliBin, "my-cli")
if child.cmd != "my-cli" {
t.Errorf("cmd = %q, want %q", child.cmd, "my-cli")
}
if !reflect.DeepEqual(child.cliExtraArgs, []string{"code", "--add-dir", "/parent"}) {
t.Errorf("cliExtraArgs = %v, want [code --add-dir /parent]", child.cliExtraArgs)
}
if child.cliArgsFlag != "-a" {
t.Errorf("cliArgsFlag = %q, want -a", child.cliArgsFlag)
if child.cmdArgsFlag != "-a" {
t.Errorf("cmdArgsFlag = %q, want -a", child.cmdArgsFlag)
}
if child.model != "claude-opus-4-7" {
t.Errorf("model = %q, want claude-opus-4-7", child.model)

View File

@@ -160,7 +160,7 @@ func TestIntegration_ProviderSwitch_SessionStartModel(t *testing.T) {
providers: providers,
activeIdx: -1,
workDir: workDir,
cliBin: cliBin,
cmd: cliBin,
}
a.SetActiveProvider(prov.Name)

View File

@@ -204,7 +204,7 @@ func buildAppendSystemPrompt(agentPrompt, platformPrompt, userAppend string) str
return strings.Join(parts, "\n")
}
func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt string, allowedTools, disallowedTools []string, pluginDirs []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int, ccDataDir string) (*claudeSession, error) {
func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cmdArgsFlag string, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt string, allowedTools, disallowedTools []string, pluginDirs []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int, ccDataDir string) (*claudeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
// Claude Code rejects bypassPermissions when running as root.
@@ -217,7 +217,7 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
}
// innerArgs are Claude Code CLI flags — when a wrapper is used with
// cliArgsFlag these get bundled into a single passthrough string.
// cmdArgsFlag these get bundled into a single passthrough string.
// outerArgs are flags the wrapper itself understands (e.g. --model).
innerArgs := []string{
"--output-format", "stream-json",
@@ -326,16 +326,16 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
}
// Build final argument list.
// When cliArgsFlag is set (e.g. "-a"), inner args are bundled into a
// When cmdArgsFlag is set (e.g. "-a"), inner args are bundled into a
// single passthrough string via that flag, while outer args (--model etc.)
// are appended directly so the wrapper can also interpret them.
// Args containing spaces/newlines are quoted so the wrapper's command-line
// parser (e.g. splitCommandLine) keeps them as single tokens.
// Result: my-cli code -t foo -a "--verbose --append-system-prompt 'long text'" --model x
var allArgs []string
if cliArgsFlag != "" {
if cmdArgsFlag != "" {
allArgs = append(allArgs, cliExtraArgs...)
allArgs = append(allArgs, cliArgsFlag, shellJoinArgs(innerArgs))
allArgs = append(allArgs, cmdArgsFlag, shellJoinArgs(innerArgs))
allArgs = append(allArgs, outerArgs...)
} else {
allArgs = append(allArgs, cliExtraArgs...)
@@ -740,10 +740,40 @@ func (cs *claudeSession) handleUser(raw map[string]any) {
contentType, _ := item["type"].(string)
if contentType == "tool_result" {
isError, _ := item["is_error"].(bool)
var result string
switch c := item["content"].(type) {
case string:
result = c
case []any:
var parts []string
for _, elem := range c {
if m, ok := elem.(map[string]any); ok {
if t, _ := m["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
result = strings.Join(parts, "\n")
}
if isError {
result, _ := item["content"].(string)
slog.Debug("claudeSession: tool error", "content", result)
}
success := !isError
code := 0
if isError {
code = 1
}
evt := core.Event{
Type: core.EventToolResult,
ToolResult: truncateStr(strings.TrimSpace(result), 500),
ToolExitCode: &code,
ToolSuccess: &success,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}

View File

@@ -582,6 +582,129 @@ func makeFiller(n int) string {
return string(b)
}
// TestHandleUserEmitsToolResult is a regression test for the bug where
// claudeSession.handleUser silently dropped tool_result content blocks
// (only logging when is_error=true) instead of emitting EventToolResult.
// Without this event, engine never sees tool output and the Feishu/Slack/
// Discord progress card never renders tool results — only the final
// assistant text reaches the user.
//
// Cases covered:
// - string content (plain text result)
// - array content (Anthropic SDK multi-block: [{type:"text", text:"..."}])
// - is_error=true (exit code 1, success=false)
func TestHandleUserEmitsToolResult(t *testing.T) {
cases := []struct {
name string
raw map[string]any
wantResult string
wantCode int
wantSuccess bool
}{
{
name: "string content",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_abc",
"is_error": false,
"content": "command output here",
},
},
},
},
wantResult: "command output here",
wantCode: 0,
wantSuccess: true,
},
{
name: "array content",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_def",
"is_error": false,
"content": []any{
map[string]any{"type": "text", "text": "line one"},
map[string]any{"type": "text", "text": "line two"},
},
},
},
},
},
wantResult: "line one\nline two",
wantCode: 0,
wantSuccess: true,
},
{
name: "error result",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_err",
"is_error": true,
"content": "boom",
},
},
},
},
wantResult: "boom",
wantCode: 1,
wantSuccess: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &claudeSession{
events: make(chan core.Event, 4),
ctx: ctx,
}
cs.alive.Store(true)
cs.handleUser(tc.raw)
select {
case evt := <-cs.events:
if evt.Type != core.EventToolResult {
t.Fatalf("event type = %q, want %q", evt.Type, core.EventToolResult)
}
if evt.ToolResult != tc.wantResult {
t.Errorf("ToolResult = %q, want %q", evt.ToolResult, tc.wantResult)
}
if evt.ToolExitCode == nil || *evt.ToolExitCode != tc.wantCode {
got := -1
if evt.ToolExitCode != nil {
got = *evt.ToolExitCode
}
t.Errorf("ToolExitCode = %d, want %d", got, tc.wantCode)
}
if evt.ToolSuccess == nil || *evt.ToolSuccess != tc.wantSuccess {
got := false
if evt.ToolSuccess != nil {
got = *evt.ToolSuccess
}
t.Errorf("ToolSuccess = %v, want %v", got, tc.wantSuccess)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for EventToolResult — handleUser dropped the tool_result")
}
})
}
}
func helperCommand(ctx context.Context, mode string) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")

View File

@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"github.com/BurntSushi/toml"
"github.com/chenhg5/cc-connect/core"
)
@@ -40,8 +42,8 @@ type Agent struct {
codexHome string
systemPrompt string
appendPrompt string
cliBin string // CLI binary name, default "codex"
cliExtraArgs []string // extra args parsed from cli_path after the binary
cmd string // CLI binary name, default "codex"
cliExtraArgs []string // extra args parsed from cmd after the binary
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
@@ -66,19 +68,10 @@ func New(opts map[string]any) (core.Agent, error) {
backend = normalizeBackend(backend)
appServerURL = normalizeAppServerURL(appServerURL)
// cli_path allows overriding the binary, e.g. "omx" or "omx --flag val"
cliBin := "codex"
var cliExtraArgs []string
if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" {
parts := strings.Fields(cliPath)
cliBin = parts[0]
if len(parts) > 1 {
cliExtraArgs = parts[1:]
}
}
cmd, cliExtraArgs := core.ParseCmdOpts(opts, "codex")
if _, err := exec.LookPath(cliBin); err != nil {
return nil, fmt.Errorf("codex: %q CLI not found in PATH, install with: npm install -g @openai/codex", cliBin)
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("codex: %q CLI not found in PATH, install with: npm install -g @openai/codex", cmd)
}
// Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml).
@@ -108,7 +101,7 @@ func New(opts map[string]any) (core.Agent, error) {
codexHome: strings.TrimSpace(codexHome),
systemPrompt: strings.TrimSpace(systemPrompt),
appendPrompt: strings.TrimSpace(appendPrompt),
cliBin: cliBin,
cmd: cmd,
cliExtraArgs: cliExtraArgs,
configEnv: configEnv,
activeIdx: -1,
@@ -217,6 +210,9 @@ func (a *Agent) configuredModels() []core.ModelOption {
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
if models := readCodexModelCatalog(); len(models) > 0 {
return models
}
if models := a.configuredModels(); len(models) > 0 {
return models
}
@@ -303,19 +299,23 @@ func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
}
func readCodexCachedModels() []core.ModelOption {
codexHome := os.Getenv("CODEX_HOME")
codexHome, _ := resolveCodexHome(nil)
if codexHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
codexHome = filepath.Join(home, ".codex")
return nil
}
path := filepath.Join(codexHome, "models_cache.json")
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return parseCodexModelsJSON(b)
}
// parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json
// or models_cache.json) into a deduplicated, filtered slice of ModelOption.
// It is shared by readCodexCachedModels and readCodexModelCatalog.
func parseCodexModelsJSON(data []byte) []core.ModelOption {
var payload struct {
Models []struct {
Slug string `json:"slug"`
@@ -325,7 +325,7 @@ func readCodexCachedModels() []core.ModelOption {
SupportedInAPI bool `json:"supported_in_api"`
} `json:"models"`
}
if err := json.Unmarshal(b, &payload); err != nil {
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
@@ -357,6 +357,62 @@ func readCodexCachedModels() []core.ModelOption {
return models
}
// readCodexModelCatalog reads $CODEX_HOME/config.toml to find the
// model_catalog_json setting, then reads and parses that JSON file.
// This is the authoritative source of model metadata for Codex CLI,
// maintained by the Codex distribution itself.
func readCodexModelCatalog() []core.ModelOption {
codexHome, _ := resolveCodexHome(nil)
if codexHome == "" {
return nil
}
// Parse CODEX_HOME/config.toml to find model_catalog_json path
cfgPath := filepath.Join(codexHome, "config.toml")
var cfg struct {
ModelCatalogJSON string `toml:"model_catalog_json"`
}
if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil {
slog.Debug("codex: failed to read config.toml for model_catalog_json", "error", err)
return nil
}
if cfg.ModelCatalogJSON == "" {
return nil
}
// Expand ~ and resolve relative paths against CODEX_HOME
catalogPath := cfg.ModelCatalogJSON
switch {
case strings.HasPrefix(catalogPath, "~/"):
home, err := os.UserHomeDir()
if err != nil {
slog.Debug("codex: cannot resolve home dir for model_catalog_json", "error", err)
return nil
}
catalogPath = filepath.Join(home, catalogPath[2:])
case catalogPath == "~":
home, err := os.UserHomeDir()
if err != nil {
return nil
}
catalogPath = home
case !filepath.IsAbs(catalogPath):
catalogPath = filepath.Join(codexHome, catalogPath)
}
b, err := os.ReadFile(catalogPath)
if err != nil {
slog.Debug("codex: failed to read model_catalog_json", "path", catalogPath, "error", err)
return nil
}
models := parseCodexModelsJSON(b)
if models == nil {
slog.Debug("codex: failed to parse model_catalog_json", "path", catalogPath)
}
return models
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -373,7 +429,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
codexHome := a.codexHome
systemPrompt := a.systemPrompt
appendPrompt := a.appendPrompt
cliBin := a.cliBin
cliBin := a.cmd
cliExtraArgs := a.cliExtraArgs
workDir := a.workDir
// Order matters for MergeEnv override semantics (later wins):

View File

@@ -40,3 +40,73 @@ func TestAvailableModels_FallbackToModelsCache(t *testing.T) {
t.Fatalf("models = %v, want [gpt-5.4 gpt-5.3-codex]", models)
}
}
// TestAvailableModels_UsesModelCatalog tests that when CODEX_HOME/config.toml
// contains model_catalog_json, readCodexModelCatalog reads and parses that file
// as the highest-priority model source.
func TestAvailableModels_UsesModelCatalog(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
tmp := t.TempDir()
config := `model_catalog_json = "model_catalog.json"
`
if err := os.WriteFile(tmp+"/config.toml", []byte(config), 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
// Mix of models: list-visible API-supported, hidden, non-API, and
// one with an empty slug that falls back to display_name.
catalog := `{
"models": [
{"slug":"gpt-5.4","display_name":"GPT-5.4","description":"Latest frontier agentic coding model.","visibility":"list","supported_in_api":true},
{"slug":"gpt-5.3-codex","display_name":"GPT-5.3 Codex","description":"Great for coding","visibility":"list","supported_in_api":true},
{"slug":"hidden-internal","visibility":"hidden","supported_in_api":true},
{"slug":"tool-only","visibility":"list","supported_in_api":false},
{"slug":"","display_name":"MissingSlug","visibility":"list","supported_in_api":true}
]
}`
if err := os.WriteFile(tmp+"/model_catalog.json", []byte(catalog), 0o644); err != nil {
t.Fatalf("write model_catalog.json: %v", err)
}
t.Setenv("CODEX_HOME", tmp)
t.Setenv("OPENAI_API_KEY", "test-key")
t.Setenv("OPENAI_BASE_URL", srv.URL)
a := &Agent{activeIdx: -1}
models := a.AvailableModels(context.Background())
// 3 visible+supported: gpt-5.4, gpt-5.3-codex, MissingSlug (slug empty → display_name fallback)
if len(models) != 3 {
t.Fatalf("models length = %d, want 3, models=%v", len(models), models)
}
if models[0].Name != "gpt-5.4" || models[0].Desc != "Latest frontier agentic coding model." {
t.Errorf("models[0] = %+v, want gpt-5.4 with description", models[0])
}
if models[1].Name != "gpt-5.3-codex" || models[1].Desc != "Great for coding" {
t.Errorf("models[1] = %+v, want gpt-5.3-codex with description", models[1])
}
if models[2].Name != "MissingSlug" || models[2].Desc != "" {
t.Errorf("models[2] = %+v, want MissingSlug (display_name fallback)", models[2])
}
}
// TestReadCodexModelCatalog_NoConfigFile tests graceful fallback when
// CODEX_HOME/config.toml does not exist.
func TestReadCodexModelCatalog_NoConfigFile(t *testing.T) {
tmp := t.TempDir()
t.Setenv("CODEX_HOME", tmp)
a := &Agent{activeIdx: -1}
models := a.AvailableModels(context.Background())
// No config.toml → no model_catalog.json → no models_cache.json
// → no OPENAI_API_KEY → all the way to hardcoded fallback (6 models)
if len(models) != 6 {
t.Fatalf("expected 6 hardcoded fallback models, got %d: %v", len(models), models)
}
}

View File

@@ -16,7 +16,7 @@ func TestNew_ParsesProjectEnvFromOpts(t *testing.T) {
// to be installed on the test runner.
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
"cmd": "go",
"env": map[string]string{
"HTTPS_PROXY": "http://127.0.0.1:10808",
"HTTP_PROXY": "http://127.0.0.1:10808",
@@ -50,7 +50,7 @@ func TestNew_ParsesProjectEnvFromOpts(t *testing.T) {
func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) {
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
"cmd": "go",
"env": map[string]any{
"OPENAI_BASE_URL": "https://api.example.com/v1",
"CUSTOM_FLAG": "yes",
@@ -80,7 +80,7 @@ func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) {
func TestNew_NoEnvOpts(t *testing.T) {
opts := map[string]any{
"work_dir": t.TempDir(),
"cli_path": "go",
"cmd": "go",
}
a, err := New(opts)

View File

@@ -30,8 +30,8 @@ type codexSession struct {
mode string
baseURL string // provider base URL; passed as -c openai_base_url=<url>
modelProvider string // Codex model_provider name; passed as -c model_provider=<name>
cliBin string // CLI binary, default "codex"
cliExtraArgs []string // extra args from cli_path, prepended before exec args
cmd string // CLI binary, default "codex"
cliExtraArgs []string // extra args from cmd, prepended before exec args
extraEnv []string
promptPreamble string
events chan core.Event
@@ -97,7 +97,7 @@ func newCodexSession(ctx context.Context, cliBin string, cliExtraArgs []string,
mode: mode,
baseURL: baseURL,
modelProvider: modelProvider,
cliBin: cliBin,
cmd: cliBin,
cliExtraArgs: cliExtraArgs,
extraEnv: extraEnv,
promptPreamble: buildCodexPromptPreamble(systemPrompt, appendPrompt),
@@ -141,7 +141,7 @@ func (cs *codexSession) Send(prompt string, images []core.ImageAttachment, files
args = append(append([]string{}, cs.cliExtraArgs...), args...)
}
bin := cs.cliBin
bin := cs.cmd
if bin == "" {
bin = "codex"
}

View File

@@ -26,13 +26,15 @@ func init() {
// - "default": every tool call requires user approval
// - "bypassPermissions": auto-approve everything (alias: yolo)
type Agent struct {
workDir string
cliBin string // CLI binary name or path (default: "copilot")
model string
mode string // "default" | "bypassPermissions"
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
sessionEnv []string
workDir string
cmd string // CLI binary name (default: "copilot")
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
model string
mode string // "default" | "bypassPermissions"
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
sessionEnv []string
mu sync.RWMutex
}
@@ -42,24 +44,23 @@ func New(opts map[string]any) (core.Agent, error) {
if workDir == "" {
workDir = "."
}
cliBin := "copilot"
if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" {
cliBin = strings.TrimSpace(cliPath)
}
cmd, extraArgs := core.ParseCmdOpts(opts, "copilot")
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
if _, err := exec.LookPath(cliBin); err != nil {
return nil, fmt.Errorf("copilot: %q CLI not found in PATH, please install it first", cliBin)
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("copilot: %q CLI not found in PATH, please install it first", cmd)
}
return &Agent{
workDir: workDir,
cliBin: cliBin,
model: model,
mode: mode,
activeIdx: -1,
workDir: workDir,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
model: model,
mode: mode,
activeIdx: -1,
}, nil
}
@@ -73,7 +74,7 @@ func normalizeMode(raw string) string {
}
func (a *Agent) Name() string { return "copilot" }
func (a *Agent) CLIBinaryName() string { return a.cliBin }
func (a *Agent) CLIBinaryName() string { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "GitHub Copilot" }
func (a *Agent) SetWorkDir(dir string) {
@@ -151,8 +152,10 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
workDir := a.workDir
cliBin := a.cliBin
extraEnv := a.providerEnvLocked()
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
provider := a.providerConfigLocked()
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
@@ -162,7 +165,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.RUnlock()
return newCopilotSession(ctx, workDir, cliBin, model, mode, sessionID, extraEnv, provider)
return newCopilotSession(ctx, workDir, cmd, extraArgs, model, mode, sessionID, extraEnv, provider)
}
// listSessionsProbeTimeout bounds how long we wait for a session.list probe.
@@ -195,7 +198,7 @@ type probeSession struct {
}
type probeSnapshot struct {
cliBin string
cmd string
workDir string
env []string
}
@@ -205,7 +208,7 @@ type probeSnapshot struct {
func newProbeSession(ctx context.Context, snapshot probeSnapshot) (*probeSession, error) {
probeCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(probeCtx, snapshot.cliBin, "--headless", "--stdio", "--no-auto-update")
cmd := exec.CommandContext(probeCtx, snapshot.cmd, "--headless", "--stdio", "--no-auto-update")
cmd.Dir = snapshot.workDir
cmd.Env = snapshot.env
@@ -277,7 +280,7 @@ func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, erro
snapshot := a.probeSnapshotLocked()
a.mu.RUnlock()
if _, err := exec.LookPath(snapshot.cliBin); err != nil {
if _, err := exec.LookPath(snapshot.cmd); err != nil {
return nil, nil
}
@@ -343,8 +346,8 @@ func (a *Agent) WorkspaceAgentOptions() map[string]any {
if a.model != "" {
opts["model"] = a.model
}
if a.cliBin != "copilot" && a.cliBin != "" {
opts["cli_path"] = a.cliBin
if a.cmd != "copilot" && a.cmd != "" {
opts["cmd"] = a.cmd
}
return opts
}
@@ -363,7 +366,7 @@ func (a *Agent) DeleteSession(ctx context.Context, sessionID string) error {
snapshot := a.probeSnapshotLocked()
a.mu.RUnlock()
if _, err := exec.LookPath(snapshot.cliBin); err != nil {
if _, err := exec.LookPath(snapshot.cmd); err != nil {
return nil
}
@@ -497,7 +500,7 @@ func (a *Agent) probeSnapshotLocked() probeSnapshot {
if len(a.sessionEnv) > 0 {
env = core.MergeEnv(env, a.sessionEnv)
}
return probeSnapshot{cliBin: a.cliBin, workDir: a.workDir, env: env}
return probeSnapshot{cmd: a.cmd, workDir: a.workDir, env: env}
}
func (a *Agent) providerConfigLocked() *copilotWireProviderConfig {

View File

@@ -229,7 +229,7 @@ func TestAgent_ListSessions(t *testing.T) {
}
func TestAgent_CLIBinaryName(t *testing.T) {
a := &Agent{cliBin: "copilot"}
a := &Agent{cmd: "copilot"}
if got := a.CLIBinaryName(); got != "copilot" {
t.Fatalf("CLIBinaryName() = %q, want copilot", got)
}
@@ -243,7 +243,7 @@ func TestAgent_CLIDisplayName(t *testing.T) {
}
func TestAgent_WorkspaceAgentOptions(t *testing.T) {
a := &Agent{mode: "bypassPermissions", model: "gpt-4o", cliBin: "copilot"}
a := &Agent{mode: "bypassPermissions", model: "gpt-4o", cmd: "copilot"}
opts := a.WorkspaceAgentOptions()
if opts["mode"] != "bypassPermissions" {
t.Fatalf("mode = %v, want bypassPermissions", opts["mode"])
@@ -251,16 +251,16 @@ func TestAgent_WorkspaceAgentOptions(t *testing.T) {
if opts["model"] != "gpt-4o" {
t.Fatalf("model = %v, want gpt-4o", opts["model"])
}
// cliBin is "copilot" (default) so cli_path should not be set
if _, ok := opts["cli_path"]; ok {
t.Fatal("cli_path should not be set for default binary name")
// cmd is "copilot" (default) so cmd should not be set
if _, ok := opts["cmd"]; ok {
t.Fatal("cmd should not be set for default binary name")
}
// Custom CLI path should be included
a2 := &Agent{mode: "default", cliBin: "/custom/copilot"}
a2 := &Agent{mode: "default", cmd: "/custom/copilot"}
opts2 := a2.WorkspaceAgentOptions()
if opts2["cli_path"] != "/custom/copilot" {
t.Fatalf("cli_path = %v, want /custom/copilot", opts2["cli_path"])
if opts2["cmd"] != "/custom/copilot" {
t.Fatalf("cmd = %v, want /custom/copilot", opts2["cmd"])
}
}
@@ -270,7 +270,7 @@ func TestAgent_WorkspaceAgentOptions(t *testing.T) {
// TestAgent_ListSessions_NoBinary verifies graceful nil return when binary is missing.
func TestAgent_ListSessions_NoBinary(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
a := &Agent{cmd: "copilot-nonexistent-xyz", workDir: "."}
sessions, err := a.ListSessions(context.Background())
if err != nil {
t.Fatalf("ListSessions with missing binary returned error: %v", err)
@@ -282,7 +282,7 @@ func TestAgent_ListSessions_NoBinary(t *testing.T) {
// TestAgent_DeleteSession_NoBinary verifies graceful nil return when binary is missing.
func TestAgent_DeleteSession_NoBinary(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
a := &Agent{cmd: "copilot-nonexistent-xyz", workDir: "."}
if err := a.DeleteSession(context.Background(), "some-session-id"); err != nil {
t.Fatalf("DeleteSession with missing binary returned error: %v", err)
}
@@ -290,7 +290,7 @@ func TestAgent_DeleteSession_NoBinary(t *testing.T) {
// TestAgent_DeleteSession_EmptyID verifies empty session ID returns nil immediately.
func TestAgent_DeleteSession_EmptyID(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
a := &Agent{cmd: "copilot-nonexistent-xyz", workDir: "."}
if err := a.DeleteSession(context.Background(), ""); err != nil {
t.Fatalf("DeleteSession with empty ID returned error: %v", err)
}
@@ -309,7 +309,7 @@ func TestAgent_ListSessions_RPC(t *testing.T) {
// Point agent at the test binary itself acting as a mock copilot
a := &Agent{
cliBin: bin,
cmd: bin,
workDir: ".",
}
@@ -341,7 +341,7 @@ func TestAgent_DeleteSession_RPC(t *testing.T) {
}
a := &Agent{
cliBin: bin,
cmd: bin,
workDir: ".",
}

View File

@@ -80,45 +80,45 @@ type copilotPermissionResult struct {
Rules []any `json:"rules,omitempty"`
}
func newCopilotSession(ctx context.Context, workDir, cliBin, model, mode, resumeSessionID string, extraEnv []string, provider *copilotWireProviderConfig) (*copilotSession, error) {
func newCopilotSession(ctx context.Context, workDir, cliBin string, extraArgs []string, model, mode, resumeSessionID string, extraEnv []string, provider *copilotWireProviderConfig) (*copilotSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
args := []string{"--headless", "--stdio", "--no-auto-update"}
args := append(append([]string{}, extraArgs...), "--headless", "--stdio", "--no-auto-update")
slog.Debug("copilotSession: starting", "bin", cliBin, "args", args, "dir", workDir)
cmd := exec.CommandContext(sessionCtx, cliBin, args...)
cmd.Dir = workDir
prepareCmdForKill(cmd)
child := exec.CommandContext(sessionCtx, cliBin, args...)
child.Dir = workDir
prepareCmdForKill(child)
env := os.Environ()
if len(extraEnv) > 0 {
env = core.MergeEnv(env, extraEnv)
}
cmd.Env = env
child.Env = env
stdin, err := cmd.StdinPipe()
stdin, err := child.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
stdout, err := child.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
child.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
if err := child.Start(); err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: start: %w", err)
}
cs := &copilotSession{
cmd: cmd,
cmd: child,
rpc: newRPCClient(stdin),
reader: newLSPReader(stdout),
events: make(chan core.Event, 64),

View File

@@ -31,14 +31,16 @@ func init() {
// - "plan": --mode plan (read-only analysis)
// - "ask": --mode ask (Q&A style, read-only)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "agent"
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
workDir string
model string
mode string
cmd string // CLI binary name, default "agent"
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -49,20 +51,19 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "agent"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "agent")
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("cursor: %q CLI not found in PATH, install with: npm i -g @anthropic-ai/cursor-agent (or from Cursor IDE settings)", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
activeIdx: -1,
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
activeIdx: -1,
}, nil
}
@@ -193,8 +194,10 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -203,7 +206,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.RUnlock()
return newCursorSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv)
return newCursorSession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv)
}
// ListSessions reads sessions from Cursor Agent CLI chat storage.

View File

@@ -22,17 +22,18 @@ import (
// cursorSession manages multi-turn conversations with the Cursor Agent CLI.
// Each Send() launches a new `agent --print` process with --resume for continuity.
type cursorSession struct {
cmd string // CLI binary name
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Cursor chat/session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
cmd string // CLI binary name
extraArgs []string // extra args from cmd, prepended before agent args
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Cursor chat/session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
thinkingBuf strings.Builder // accumulate thinking deltas
@@ -51,18 +52,19 @@ type pendingInteractionQuery struct {
queryType string // e.g. "webFetchRequestQuery", "shellRequestQuery"
}
func newCursorSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*cursorSession, error) {
func newCursorSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string) (*cursorSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
cs := &cursorSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
cs.alive.Store(true)
@@ -88,10 +90,10 @@ func (cs *cursorSession) Send(prompt string, images []core.ImageAttachment, file
chatID := cs.CurrentSessionID()
isResume := chatID != ""
args := []string{
args := append(append([]string{}, cs.extraArgs...),
"--print",
"--output-format", "stream-json",
}
)
switch cs.mode {
case "force":

View File

@@ -30,15 +30,17 @@ func init() {
// - "yolo": auto-approve all tools (-y / --approval-mode yolo)
// - "plan": read-only plan mode (--approval-mode plan)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "gemini"
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
workDir string
model string
mode string
cmd string // CLI binary name, default "gemini"
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -49,10 +51,7 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "gemini"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "gemini")
var timeoutMins int64
switch v := opts["timeout_mins"].(type) {
@@ -77,12 +76,14 @@ func New(opts map[string]any) (core.Agent, error) {
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
timeout: timeout,
activeIdx: -1,
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
timeout: timeout,
activeIdx: -1,
}, nil
}
@@ -209,9 +210,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
timeout := a.timeout
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -220,7 +223,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.Unlock()
return newGeminiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
return newGeminiSession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv, timeout)
}
// ListSessions reads sessions from ~/.gemini/tmp/<project_hash>/chats/.

View File

@@ -25,34 +25,36 @@ import (
// with --resume for conversation continuity. The prompt is passed via stdin
// (using -p - flag) to preserve newlines in multi-line messages.
type geminiSession struct {
cmd string
workDir string
model string
mode string
timeout time.Duration
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Gemini session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
cmd string
extraArgs []string // extra args from cmd, prepended before gemini args
workDir string
model string
mode string
timeout time.Duration
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Gemini session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
pendingMsgs []string // buffered assistant messages awaiting classification
}
func newGeminiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*geminiSession, error) {
func newGeminiSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*geminiSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
gs := &geminiSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
gs.alive.Store(true)
@@ -112,9 +114,9 @@ func (gs *geminiSession) Send(prompt string, images []core.ImageAttachment, file
chatID := gs.CurrentSessionID()
isResume := chatID != ""
args := []string{
args := append(append([]string{}, gs.extraArgs...),
"--output-format", "stream-json",
}
)
switch gs.mode {
case "yolo":

View File

@@ -497,7 +497,7 @@ func TestComputeLineDiff(t *testing.T) {
}
func TestGeminiSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newGeminiSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
s, err := newGeminiSession(context.Background(), "echo", nil, "/tmp", "", "default", core.ContinueSession, nil, 0)
if err != nil {
t.Fatalf("newGeminiSession: %v", err)
}

View File

@@ -35,6 +35,8 @@ type Agent struct {
model string
mode string
cmd string
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
toolTimeoutSec int
providers []core.ProviderConfig
activeIdx int
@@ -50,10 +52,7 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "iflow"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "iflow")
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("iflow: %q CLI not found in PATH, install with: npm i -g @iflow-ai/iflow-cli", cmd)
@@ -74,6 +73,8 @@ func New(opts map[string]any) (core.Agent, error) {
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
toolTimeoutSec: toolTimeoutSec,
activeIdx: -1,
}, nil
@@ -148,9 +149,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
toolTimeoutSec := a.toolTimeoutSec
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -159,7 +162,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.Unlock()
return newIFlowSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, toolTimeoutSec)
return newIFlowSession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv, toolTimeoutSec)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {

View File

@@ -41,6 +41,7 @@ var iflowPendingToolTimeoutDefaultMode = 6 * time.Second
// then tails the transcript JSONL to recover structured assistant/tool events.
type iflowSession struct {
cmd string
extraArgs []string // extra args from cmd, prepended before iflow args
workDir string
model string
mode string
@@ -92,11 +93,12 @@ type iflowToolResult struct {
Output string
}
func newIFlowSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, toolTimeoutSec int) (*iflowSession, error) {
func newIFlowSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string, toolTimeoutSec int) (*iflowSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &iflowSession{
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
@@ -157,7 +159,7 @@ func (s *iflowSession) Send(prompt string, images []core.ImageAttachment, files
}
turn.sessionDir = sessionDir
args := make([]string, 0, 16)
args := append([]string{}, s.extraArgs...)
if s.model != "" {
args = append(args, "-m", s.model)
}

View File

@@ -337,7 +337,7 @@ func TestIFlowTurnTimerResetsOnPartialToolCompletion(t *testing.T) {
}
func TestIFlowSessionCustomToolTimeout(t *testing.T) {
sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 300)
sess, err := newIFlowSession(context.Background(), "echo", nil, "/tmp", "", "yolo", "", nil, 300)
if err != nil {
t.Fatalf("newIFlowSession: %v", err)
}
@@ -348,7 +348,7 @@ func TestIFlowSessionCustomToolTimeout(t *testing.T) {
}
func TestIFlowSessionDefaultToolTimeout(t *testing.T) {
sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 0)
sess, err := newIFlowSession(context.Background(), "echo", nil, "/tmp", "", "yolo", "", nil, 0)
if err != nil {
t.Fatalf("newIFlowSession: %v", err)
}
@@ -394,7 +394,7 @@ while :; do sleep 1; done
t.Fatalf("WriteFile fake iflow: %v", err)
}
sess, err := newIFlowSession(context.Background(), cmdPath, workDir, "", "default", "", nil, 0)
sess, err := newIFlowSession(context.Background(), cmdPath, nil, workDir, "", "default", "", nil, 0)
if err != nil {
t.Fatalf("newIFlowSession: %v", err)
}
@@ -430,7 +430,7 @@ while :; do sleep 1; done
}
func TestIFlowSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0)
s, err := newIFlowSession(context.Background(), "echo", nil, "/tmp", "", "default", core.ContinueSession, nil, 0)
if err != nil {
t.Fatalf("newIFlowSession: %v", err)
}

View File

@@ -30,15 +30,17 @@ func init() {
// - "plan": read-only plan mode
// - "quiet": alias for --quiet (print + text + final-message-only)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "kimi"
timeout time.Duration
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
sessionEnv []string
mu sync.RWMutex
workDir string
model string
mode string
cmd string // CLI binary name, default "kimi"
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
timeout time.Duration
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -49,10 +51,7 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "kimi"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "kimi")
var timeoutMins int64
switch v := opts["timeout_mins"].(type) {
@@ -77,12 +76,14 @@ func New(opts map[string]any) (core.Agent, error) {
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
timeout: timeout,
activeIdx: -1,
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
timeout: timeout,
activeIdx: -1,
}, nil
}
@@ -100,7 +101,7 @@ func normalizeMode(raw string) string {
}
func (a *Agent) Name() string { return "kimi" }
func (a *Agent) CLIBinaryName() string { return "kimi" }
func (a *Agent) CLIBinaryName() string { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "Kimi" }
func (a *Agent) SetWorkDir(dir string) {
@@ -158,9 +159,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
timeout := a.timeout
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -169,7 +172,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.Unlock()
return newKimiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
return newKimiSession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv, timeout)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {

View File

@@ -25,6 +25,7 @@ import (
// with --resume for conversation continuity.
type kimiSession struct {
cmd string
extraArgs []string // extra args from cmd, prepended before kimi args
workDir string
model string
mode string
@@ -40,19 +41,20 @@ type kimiSession struct {
pendingMsgs []string // buffered assistant text messages
}
func newKimiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*kimiSession, error) {
func newKimiSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*kimiSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
ks := &kimiSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
timeout: timeout,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
ks.alive.Store(true)
@@ -122,10 +124,10 @@ func (ks *kimiSession) Send(prompt string, images []core.ImageAttachment, files
fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]"
}
args := []string{
args := append(append([]string{}, ks.extraArgs...),
"--print",
"--output-format", "stream-json",
}
)
switch ks.mode {
case "plan":

View File

@@ -12,7 +12,7 @@ import (
func TestNewKimiSession(t *testing.T) {
ctx := context.Background()
ks, err := newKimiSession(ctx, "kimi", "/tmp", "kimi-k2", "default", "resume-123", nil, 0)
ks, err := newKimiSession(ctx, "kimi", nil, "/tmp", "kimi-k2", "default", "resume-123", nil, 0)
require.NoError(t, err)
require.NotNil(t, ks)
assert.True(t, ks.Alive())
@@ -41,7 +41,7 @@ func TestExtractResumeSessionID(t *testing.T) {
func TestHandleAssistantWithText(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.handleEvent(map[string]any{
@@ -58,7 +58,7 @@ func TestHandleAssistantWithText(t *testing.T) {
func TestHandleAssistantWithThink(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.handleEvent(map[string]any{
@@ -78,7 +78,7 @@ func TestHandleAssistantWithThink(t *testing.T) {
func TestHandleAssistantWithToolCalls(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.handleEvent(map[string]any{
@@ -109,7 +109,7 @@ func TestHandleAssistantWithToolCalls(t *testing.T) {
func TestHandleTool(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.handleEvent(map[string]any{
@@ -129,7 +129,7 @@ func TestHandleTool(t *testing.T) {
func TestFlushPendingAsText(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.pendingMsgs = []string{"Hello", " ", "world"}
@@ -144,7 +144,7 @@ func TestFlushPendingAsText(t *testing.T) {
func TestFlushPendingAsThinking(t *testing.T) {
ctx := context.Background()
ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0)
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0)
defer ks.Close()
ks.pendingMsgs = []string{"Thinking..."}

View File

@@ -32,7 +32,9 @@ type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "opencode"
cmd string // CLI binary name, default "opencode"
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
agentName string // passed as --agent to opencode (for plugin-defined agents)
providers []core.ProviderConfig
activeIdx int
@@ -67,10 +69,7 @@ func New(opts map[string]any) (core.Agent, error) {
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "opencode"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "opencode")
agentName, _ := opts["agent"].(string) // --agent flag for plugin-defined agents (#1210)
ccDataDir, _ := opts["cc_data_dir"].(string)
ccProject, _ := opts["cc_project"].(string)
@@ -89,6 +88,8 @@ func New(opts map[string]any) (core.Agent, error) {
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
agentName: agentName,
activeIdx: -1,
modelCachePath: modelCachePath,
@@ -464,9 +465,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
model := a.model
mode := a.mode
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
agentName := a.agentName
extraEnv := a.providerEnvLocked()
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.providerEnvLocked()...)
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
@@ -475,7 +478,7 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
}
a.mu.Unlock()
return newOpencodeSession(ctx, cmd, workDir, model, mode, agentName, sessionID, extraEnv)
return newOpencodeSession(ctx, cmd, extraArgs, workDir, model, mode, agentName, sessionID, extraEnv)
}
// ListSessions runs `opencode session list` and parses the JSON output.

View File

@@ -25,6 +25,7 @@ import (
// with --session for conversation continuity.
type opencodeSession struct {
cmd string
extraArgs []string // extra args from cmd, prepended before opencode args
workDir string
model string
mode string
@@ -40,11 +41,12 @@ type opencodeSession struct {
resultSent atomic.Bool // true when EventResult has been sent for this turn
}
func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, agentName, resumeID string, extraEnv []string) (*opencodeSession, error) {
func newOpencodeSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, agentName, resumeID string, extraEnv []string) (*opencodeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &opencodeSession{
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
@@ -155,7 +157,7 @@ func opencodeImageExt(mimeType string) string {
}
func (s *opencodeSession) buildRunArgs(prompt string, imagePaths []string, chatID string) []string {
args := []string{"run", "--format", "json"}
args := append(append([]string{}, s.extraArgs...), "run", "--format", "json")
if chatID != "" {
args = append(args, "--session", chatID)

View File

@@ -61,7 +61,7 @@ func TestOpencodeSessionEntry_Unmarshal(t *testing.T) {
// the ContinueSession sentinel (__continue__) is not passed as a literal
// session ID to the CLI. This was fixed in PR #249.
func TestNewOpencodeSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newOpencodeSession(context.Background(), "echo", "/tmp", "", "default", "", core.ContinueSession, nil)
s, err := newOpencodeSession(context.Background(), "echo", nil, "/tmp", "", "default", "", core.ContinueSession, nil)
if err != nil {
t.Fatalf("newOpencodeSession: %v", err)
}

View File

@@ -22,13 +22,15 @@ func init() {
// Agent drives the pi coding agent CLI (`pi --mode json --no-input`).
type Agent struct {
cmd string // path to pi binary
workDir string
model string
mode string // "default" | "yolo"
thinking string // reasoning effort: off, minimal, low, medium, high, xhigh
sessionEnv []string
mu sync.Mutex
cmd string // path to pi binary
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
workDir string
model string
mode string // "default" | "yolo"
thinking string // reasoning effort: off, minimal, low, medium, high, xhigh
sessionEnv []string
mu sync.Mutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -40,10 +42,7 @@ func New(opts map[string]any) (core.Agent, error) {
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "pi"
}
cmd, extraArgs := core.ParseCmdOpts(opts, "pi")
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("pi: '%s' not found in PATH, install with: npm install -g @mariozechner/pi-coding-agent", cmd)
@@ -57,10 +56,12 @@ func New(opts map[string]any) (core.Agent, error) {
}
return &Agent{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
workDir: workDir,
model: model,
mode: mode,
}, nil
}
@@ -74,7 +75,7 @@ func normalizeMode(raw string) string {
}
func (a *Agent) Name() string { return "pi" }
func (a *Agent) CLIBinaryName() string { return "pi" }
func (a *Agent) CLIBinaryName() string { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "Pi" }
func (a *Agent) SetModel(model string) {
@@ -110,9 +111,11 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
mode := a.mode
model := a.model
thinking := a.thinking
extraEnv := append([]string{}, a.sessionEnv...)
extraArgs := append([]string{}, a.cliExtraArgs...)
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.sessionEnv...)
a.mu.Unlock()
return newPiSession(ctx, a.cmd, a.workDir, model, mode, thinking, sessionID, extraEnv)
return newPiSession(ctx, a.cmd, extraArgs, a.workDir, model, mode, thinking, sessionID, extraEnv)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {

View File

@@ -121,7 +121,7 @@ func TestNew_DefaultCmd(t *testing.T) {
// ── Agent interface methods ──────────────────────────────────
func TestAgent_NameAndDisplay(t *testing.T) {
a := &Agent{}
a := &Agent{cmd: "pi"}
if a.Name() != "pi" {
t.Errorf("Name() = %q", a.Name())
}
@@ -645,11 +645,11 @@ func TestCleanAttachments_NonexistentDir(t *testing.T) {
func TestPiSessionAttachmentDirsAreIsolated(t *testing.T) {
workDir := t.TempDir()
s1, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil)
s1, err := newPiSession(context.Background(), "pi", nil, workDir, "", "", "", "", nil)
if err != nil {
t.Fatal(err)
}
s2, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil)
s2, err := newPiSession(context.Background(), "pi", nil, workDir, "", "", "", "", nil)
if err != nil {
t.Fatal(err)
}
@@ -1186,7 +1186,7 @@ func TestHandleMessageEnd_UserRole(t *testing.T) {
// ── piSession lifecycle ──────────────────────────────────────
func TestPiSession_NewWithResumeID(t *testing.T) {
s, err := newPiSession(context.Background(), "echo", "/tmp", "model", "default", "", "resume-id", nil)
s, err := newPiSession(context.Background(), "echo", nil, "/tmp", "model", "default", "", "resume-id", nil)
if err != nil {
t.Fatalf("newPiSession: %v", err)
}
@@ -1202,7 +1202,7 @@ func TestPiSession_ContinueSessionTreatedAsFresh(t *testing.T) {
// Claude Code to pick up the latest CLI session via --continue. Agents that
// don't support --continue must treat it as "" (fresh session), otherwise
// they pass the literal "__continue__" as a session ID which always fails.
s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", core.ContinueSession, nil)
s, err := newPiSession(context.Background(), "echo", nil, "/tmp", "", "default", "", core.ContinueSession, nil)
if err != nil {
t.Fatalf("newPiSession: %v", err)
}
@@ -1214,7 +1214,7 @@ func TestPiSession_ContinueSessionTreatedAsFresh(t *testing.T) {
}
func TestPiSession_NewWithoutResumeID(t *testing.T) {
s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil)
s, err := newPiSession(context.Background(), "echo", nil, "/tmp", "", "default", "", "", nil)
if err != nil {
t.Fatalf("newPiSession: %v", err)
}
@@ -1226,7 +1226,7 @@ func TestPiSession_NewWithoutResumeID(t *testing.T) {
}
func TestPiSession_SendWhenClosed(t *testing.T) {
s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil)
s, _ := newPiSession(context.Background(), "echo", nil, "/tmp", "", "default", "", "", nil)
s.Close()
err := s.Send("hello", nil, nil)
@@ -1255,7 +1255,7 @@ func TestPiSession_Events(t *testing.T) {
}
func TestPiSession_Close(t *testing.T) {
s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil)
s, _ := newPiSession(context.Background(), "echo", nil, "/tmp", "", "default", "", "", nil)
if err := s.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
@@ -1363,7 +1363,7 @@ func TestPiSession_ReadLoopWithEcho(t *testing.T) {
line1, _ := json.Marshal(sessionEvent)
line2, _ := json.Marshal(textEvent)
s, err := newPiSession(context.Background(), "sh", "/tmp", "", "default", "", "", nil)
s, err := newPiSession(context.Background(), "sh", nil, "/tmp", "", "default", "", "", nil)
if err != nil {
t.Fatalf("newPiSession: %v", err)
}

View File

@@ -25,6 +25,7 @@ import (
// Subsequent turns use `--session <sessionID>` to resume.
type piSession struct {
cmd string
extraArgs []string // extra args from cmd, prepended before pi args
workDir string
model string
mode string
@@ -52,16 +53,17 @@ type piSession struct {
lastUsage *core.ContextUsage
}
func newPiSession(ctx context.Context, cmd, workDir, model, mode, thinking, resumeID string, extraEnv []string) (*piSession, error) {
func newPiSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, thinking, resumeID string, extraEnv []string) (*piSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &piSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
thinking: thinking,
extraEnv: extraEnv,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
thinking: thinking,
extraEnv: extraEnv,
attachDir: filepath.Join(workDir, ".cc-connect", "attachments",
fmt.Sprintf("pi_%d", time.Now().UnixNano())),
events: make(chan core.Event, 64),
@@ -95,7 +97,7 @@ func (s *piSession) Send(prompt string, images []core.ImageAttachment, files []c
return fmt.Errorf("session is closed")
}
args := []string{"--mode", "json", "-p"}
args := append(append([]string{}, s.extraArgs...), "--mode", "json", "-p")
sid := s.CurrentSessionID()
if sid != "" {

View File

@@ -19,11 +19,14 @@ func init() {
// Agent drives Qoder CLI using `qodercli -p <prompt> -f stream-json`.
type Agent struct {
workDir string
model string
mode string // "default" | "yolo"
sessionEnv []string
mu sync.Mutex
workDir string
cmd string // CLI binary name (default: "qodercli")
cliExtraArgs []string // extra args from cmd after the binary name
configEnv []string // env vars from [projects.agent.options.env]
model string
mode string // "default" | "yolo"
sessionEnv []string
mu sync.Mutex
}
func New(opts map[string]any) (core.Agent, error) {
@@ -35,14 +38,18 @@ func New(opts map[string]any) (core.Agent, error) {
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
if _, err := exec.LookPath("qodercli"); err != nil {
return nil, fmt.Errorf("qoder: 'qodercli' not found in PATH, install with: curl -fsSL https://qoder.com/install | bash")
cmd, extraArgs := core.ParseCmdOpts(opts, "qodercli")
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("qoder: %q not found in PATH, install with: curl -fsSL https://qoder.com/install | bash", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
workDir: workDir,
cmd: cmd,
cliExtraArgs: extraArgs,
configEnv: core.ParseConfigEnv(opts),
model: model,
mode: mode,
}, nil
}
@@ -56,7 +63,7 @@ func normalizeMode(raw string) string {
}
func (a *Agent) Name() string { return "qoder" }
func (a *Agent) CLIBinaryName() string { return "qodercli" }
func (a *Agent) CLIBinaryName() string { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "Qoder" }
func (a *Agent) SetWorkDir(dir string) {
@@ -105,11 +112,14 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
a.mu.Lock()
mode := a.mode
model := a.model
cmd := a.cmd
extraArgs := append([]string{}, a.cliExtraArgs...)
workDir := a.workDir
extraEnv := append([]string{}, a.sessionEnv...)
extraEnv := append([]string(nil), a.configEnv...)
extraEnv = append(extraEnv, a.sessionEnv...)
a.mu.Unlock()
return newQoderSession(ctx, workDir, model, mode, sessionID, extraEnv)
return newQoderSession(ctx, cmd, extraArgs, workDir, model, mode, sessionID, extraEnv)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {

View File

@@ -133,7 +133,7 @@ func TestAgent_Name(t *testing.T) {
}
func TestAgent_CLIBinaryName(t *testing.T) {
a := &Agent{}
a := &Agent{cmd: "qodercli"}
if got := a.CLIBinaryName(); got != "qodercli" {
t.Errorf("CLIBinaryName() = %q, want %q", got, "qodercli")
}

View File

@@ -23,16 +23,18 @@ import (
// Each Send() spawns `qodercli -p <prompt> -f stream-json -q`.
// Subsequent turns use `-r <sessionID>` to resume the conversation.
type qoderSession struct {
workDir string
model string
mode string
extraEnv []string
events chan core.Event
sessionID atomic.Value // stores string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
cmd string
extraArgs []string // extra args from cmd, prepended before qoder args
workDir string
model string
mode string
extraEnv []string
events chan core.Event
sessionID atomic.Value // stores string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
startupWarning string
textMu sync.Mutex
@@ -47,17 +49,19 @@ const maxAssistantTextCacheEntries = 1024
// silently skipped under root).
func (qs *qoderSession) StartupWarning() string { return qs.startupWarning }
func newQoderSession(ctx context.Context, workDir, model, mode, resumeID string, extraEnv []string) (*qoderSession, error) {
func newQoderSession(ctx context.Context, cmd string, extraArgs []string, workDir, model, mode, resumeID string, extraEnv []string) (*qoderSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
qs := &qoderSession{
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
cmd: cmd,
extraArgs: extraArgs,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
assistantTextByID: make(map[string]string),
}
@@ -88,7 +92,7 @@ func (qs *qoderSession) Send(prompt string, images []core.ImageAttachment, files
return fmt.Errorf("session is closed")
}
args := []string{"-p", prompt, "-f", "stream-json", "-q", "-w", qs.workDir}
args := append(append([]string{}, qs.extraArgs...), "-p", prompt, "-f", "stream-json", "-q", "-w", qs.workDir)
sid := qs.CurrentSessionID()
if sid != "" {
@@ -109,7 +113,7 @@ func (qs *qoderSession) Send(prompt string, images []core.ImageAttachment, files
slog.Debug("qoderSession: launching", "resume", sid != "", "args_len", len(args))
cmd := exec.CommandContext(qs.ctx, "qodercli", args...)
cmd := exec.CommandContext(qs.ctx, qs.cmd, args...)
cmd.Dir = qs.workDir
if len(qs.extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), qs.extraEnv)

View File

@@ -565,6 +565,7 @@ func main() {
// Wire display truncation settings (includes legacy quiet → display mapping)
{
mode, tm, tool, tmlen, toollen, _, _ := config.EffectiveDisplay(cfg, &proj)
historyMaxLen := config.EffectiveHistoryMaxLen(cfg, &proj)
engine.SetDisplayConfig(core.DisplayCfg{
Mode: mode,
CardMode: config.EffectiveCardMode(cfg, &proj),
@@ -572,6 +573,7 @@ func main() {
ThinkingMaxLen: tmlen,
ToolMaxLen: toollen,
ToolMessages: tool,
HistoryMaxLen: &historyMaxLen,
})
}
@@ -1303,11 +1305,15 @@ func main() {
slog.Info("cc-connect is running", "projects", len(engines))
// After startup, check if we were restarted and send success notification
// After startup, check if we were restarted and queue the success
// notification. The engine dispatches it on the first OnPlatformReady
// for the target platform (or with a 10s safety timeout), so async
// platforms that need 2-3s to actually connect (e.g. Telegram) do not
// silently drop the notify. See issue #1383.
if notify := core.ConsumeRestartNotify(cfg.DataDir); notify != nil {
slog.Info("post-restart: sending success notification", "platform", notify.Platform, "session", notify.SessionKey)
slog.Info("post-restart: queuing success notification", "platform", notify.Platform, "session", notify.SessionKey)
for _, e := range engines {
e.SendRestartNotification(notify.Platform, notify.SessionKey)
e.SetPendingRestartNotify(notify)
}
}
@@ -1682,6 +1688,7 @@ func reloadConfig(configPath, projName string, engine *core.Engine) (*core.Confi
// Reload display config (includes legacy quiet → display mapping)
mode, tm, tool, tmlen, toollen, showCtx, showFooter := config.EffectiveDisplay(cfg, proj)
historyMaxLen := config.EffectiveHistoryMaxLen(cfg, proj)
engine.SetDisplayConfig(core.DisplayCfg{
Mode: mode,
CardMode: config.EffectiveCardMode(cfg, proj),
@@ -1689,6 +1696,7 @@ func reloadConfig(configPath, projName string, engine *core.Engine) (*core.Confi
ThinkingMaxLen: tmlen,
ToolMaxLen: toollen,
ToolMessages: tool,
HistoryMaxLen: &historyMaxLen,
})
result.DisplayUpdated = true

View File

@@ -98,6 +98,12 @@ func parseSendArgs(args []string) (core.SendRequest, string, error) {
}
i++
req.Message = args[i]
case "--cwd", "--work-dir":
if i+1 >= len(args) {
return req, "", fmt.Errorf("%s requires a value", args[i])
}
i++
req.WorkDir = args[i]
case "--tts":
if i+1 >= len(args) {
return req, "", fmt.Errorf("%s requires a value", args[i])
@@ -366,6 +372,8 @@ Send a message, attachment, or synthesized voice message to an active cc-connect
Options:
-m, --message <text> Message to send (preferred over positional args)
--cwd <path> Start a new session in this working directory
--work-dir <path> Alias for --cwd
--tts <text> Synthesize text and send it as a voice/audio message
--image <path> Send an image attachment (repeatable)
--file <path> Send a file attachment (repeatable)

View File

@@ -77,6 +77,26 @@ func TestParseSendArgs_UsesSessionEnvFallback(t *testing.T) {
}
}
func TestParseSendArgs_WorkDirOption(t *testing.T) {
workDir := t.TempDir()
req, _, err := parseSendArgs([]string{"--cwd", workDir, "--message", "please check"})
if err != nil {
t.Fatalf("parseSendArgs returned error: %v", err)
}
if got := req.WorkDir; got != workDir {
t.Fatalf("WorkDir = %q, want %q", got, workDir)
}
req, _, err = parseSendArgs([]string{"--work-dir", workDir, "please check"})
if err != nil {
t.Fatalf("parseSendArgs returned error for --work-dir: %v", err)
}
if got := req.WorkDir; got != workDir {
t.Fatalf("WorkDir from --work-dir = %q, want %q", got, workDir)
}
}
// TestParseSendArgs_AudioPopulatesAudios is the regression for
// t-20260615-cqjbk1: --audio and --video must NOT land in req.Files
// (which would route them through SendFile and skip

View File

@@ -119,6 +119,7 @@ level = "info" # debug, info, warn, error
# thinking_messages = true # Show/hide thinking messages (default: true) / 是否显示思考消息(默认 true
# thinking_max_len = 300 # Max chars for thinking messages (default: 300) / 思考消息最大字符数(默认 300
# tool_max_len = 500 # Max chars for tool use messages (default: 500) / 工具调用消息最大字符数(默认 500
# history_max_len = 1000 # Max chars per /history entry (default: 1000; 0 = no truncation) / 每条历史记录最大字符数(默认 10000 表示不截断)
# tool_messages = true # Show/hide tool progress messages (default: true) / 是否显示工具进度消息(默认 true
# show_context_indicator = true # Show "[ctx: ~N%]" suffix on assistant replies (default: true)
# # 在助手回复末尾显示「[ctx: ~N%]」上下文占用提示(默认 true 显示)
@@ -1017,6 +1018,16 @@ app_secret = "your-feishu-app-secret"
# # 机器人间 @通知映射表friendly_name -> open_id。
# # 当 @name 不是群成员(例如另一个机器人 / Agent只能用 open_id 标识)时使用。
# # 优先级高于 resolve_mentions 的群成员匹配;需同时开启 resolve_mentions = true。
# image_batch_window_ms = 500 # Coalesce window for consecutive images from the same session (default: 500ms).
# # When the Feishu mobile client sends multiple images, each arrives as a separate
# # event; cc-connect buffers them and dispatches a single multi-image message once
# # no new image arrives within this window. Raise it (e.g. 8001200ms) if your
# # network or device routinely sends images more than 500ms apart and they still
# # split into multiple agent turns; lower it for snappier single-image responses.
# # 来自同一会话的连续图片消息的合批窗口(默认 500ms。飞书手机端连续发图时每张
# # 都是独立事件cc-connect 会在窗口内将它们合并成一条多图消息再分发;如果你的
# # 网络或设备发送间隔超过 500ms 且仍被拆成多轮,可调高(如 8001200ms如果
# # 只是单图发送,希望响应更快,也可以适当调低。
# Lark (International Version) / Lark 国际版
# Lark now supports WebSocket long-connection too; webhook mode is only needed
@@ -1732,7 +1743,7 @@ app_secret = "your-feishu-app-secret"
# # Optional: override only if the `devin` binary is at a non-standard path.
# # Always use an absolute path when running under launchd / systemd where
# # $PATH is minimal (e.g. ~/.local/bin may not be included).
# # command = "/Users/me/.local/bin/devin"
# # cmd = "/Users/me/.local/bin/devin"
# #
# # Optional: initial permission mode applied to new sessions via
# # session/set_mode. Valid ids: normal, ask, plan, accept-edits, bypass.
@@ -1772,7 +1783,7 @@ app_secret = "your-feishu-app-secret"
#
# [projects.agent.options]
# work_dir = "/path/to/project"
# command = "agent"
# cmd = "agent"
# args = ["acp"]
# auth_method = "cursor_login"
# display_name = "Cursor ACP"
@@ -1800,7 +1811,7 @@ app_secret = "your-feishu-app-secret"
#
# [projects.agent.options]
# work_dir = "/path/to/project"
# command = "openclaw"
# cmd = "openclaw"
# args = ["acp"]
# # Remote gateway (use token-file on shared hosts — avoid --token in argv):
# # args = ["acp", "--url", "wss://gateway-host:18789", "--token-file", "/path/to/gateway.token"]
@@ -1818,7 +1829,7 @@ app_secret = "your-feishu-app-secret"
#
# [projects.agent.options]
# work_dir = "/path/to/project"
# command = "traecli"
# cmd = "traecli"
# args = ["acp", "serve"]
# display_name = "Trae CLI ACP"
@@ -1831,7 +1842,7 @@ app_secret = "your-feishu-app-secret"
#
# [projects.agent.options]
# work_dir = "/path/to/project"
# command = "coco"
# cmd = "coco"
# args = ["acp", "serve"]
# display_name = "COCO ACP"
@@ -1846,10 +1857,10 @@ app_secret = "your-feishu-app-secret"
#
# [projects.agent.options]
# work_dir = "/path/to/project"
# command = "copilot"
# cmd = "copilot"
# args = ["--acp", "--stdio"]
# display_name = "Copilot ACP"
# # command = "/absolute/path/to/copilot" # if not on PATH (see COPILOT_CLI_PATH in GitHub docs)
# # cmd = "/absolute/path/to/copilot" # if not on PATH (see COPILOT_CLI_PATH in GitHub docs)
# [[projects.platforms]]
# type = "telegram"
@@ -1963,7 +1974,7 @@ app_secret = "your-feishu-app-secret"
# work_dir = "/path/to/code"
# model = "gpt-4.1" # or claude-sonnet-4.6, o3-mini, etc.
# mode = "default" # "default" or "bypassPermissions" (yolo)
# cli_path = "copilot" # CLI binary name or path (default: "copilot")
# cmd = "copilot" # CLI binary name or path (default: "copilot")
#
# [[projects.platforms]]
# type = "telegram"

View File

@@ -198,6 +198,7 @@ type DisplayConfig struct {
ThinkingMaxLen *int `toml:"thinking_max_len"` // max chars for thinking messages; 0 = no truncation; default 300
ToolMaxLen *int `toml:"tool_max_len"` // max chars for tool use messages; 0 = no truncation; default 500
ToolMessages *bool `toml:"tool_messages"` // whether tool progress messages are shown; default true
HistoryMaxLen *int `toml:"history_max_len"` // max chars per /history entry; 0 = no truncation; default 1000
ShowContextIndicator *bool `toml:"show_context_indicator"` // whether [ctx: ~N%] suffix is shown; default true
ReplyFooter *bool `toml:"reply_footer"` // whether Codex-like footer is shown; default true
}
@@ -915,6 +916,19 @@ func EffectiveDisplay(cfg *Config, proj *ProjectConfig) (mode string, thinkingMe
return
}
// EffectiveHistoryMaxLen returns the per-entry /history truncation length.
// Resolution: project [display] > global [display] > default 1000. A value
// of 0 disables truncation.
func EffectiveHistoryMaxLen(cfg *Config, proj *ProjectConfig) int {
if proj != nil && proj.Display != nil && proj.Display.HistoryMaxLen != nil {
return *proj.Display.HistoryMaxLen
}
if cfg != nil && cfg.Display.HistoryMaxLen != nil {
return *cfg.Display.HistoryMaxLen
}
return 1000
}
// EffectiveShell returns the shell binary, flag, and init command for the project.
// Resolution: per-project > global > platform default.
// The flag is auto-detected: "/C" for cmd, "-Command" for powershell/pwsh, "-c" for everything else.
@@ -1063,6 +1077,9 @@ func validateDisplayConfig(prefix string, display *DisplayConfig) error {
return fmt.Errorf("config: %s.card_mode must be \"legacy\" or \"rich\"", prefix)
}
}
if display.HistoryMaxLen != nil && *display.HistoryMaxLen < 0 {
return fmt.Errorf("config: %s.history_max_len must be >= 0", prefix)
}
return nil
}
@@ -3456,6 +3473,11 @@ func GetGlobalSettings() map[string]any {
} else {
result["tool_max_len"] = 500
}
if cfg.Display.HistoryMaxLen != nil {
result["history_max_len"] = *cfg.Display.HistoryMaxLen
} else {
result["history_max_len"] = 1000
}
// Stream preview
spEnabled := true
if cfg.StreamPreview.Enabled != nil {

View File

@@ -429,9 +429,62 @@ func TestEffectiveDisplay_ProjectOverride(t *testing.T) {
}
}
func TestEffectiveHistoryMaxLen(t *testing.T) {
globalLen, projectLen, unlimited := 800, 1200, 0
tests := []struct {
name string
cfg Config
proj ProjectConfig
want int
}{
{
name: "default",
cfg: Config{},
proj: ProjectConfig{},
want: 1000,
},
{
name: "global display",
cfg: Config{
Display: DisplayConfig{HistoryMaxLen: &globalLen},
},
proj: ProjectConfig{},
want: 800,
},
{
name: "project display overrides global",
cfg: Config{
Display: DisplayConfig{HistoryMaxLen: &globalLen},
},
proj: ProjectConfig{
Display: &DisplayConfig{HistoryMaxLen: &projectLen},
},
want: 1200,
},
{
name: "zero disables truncation",
cfg: Config{
Display: DisplayConfig{HistoryMaxLen: &unlimited},
},
proj: ProjectConfig{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := EffectiveHistoryMaxLen(&tt.cfg, &tt.proj); got != tt.want {
t.Fatalf("EffectiveHistoryMaxLen() = %d, want %d", got, tt.want)
}
})
}
}
func TestValidateProjectDisplayConfig(t *testing.T) {
mode := "verbose"
cardMode := "modern"
negativeHistoryMaxLen := -1
tests := []struct {
name string
@@ -448,6 +501,11 @@ func TestValidateProjectDisplayConfig(t *testing.T) {
display: &DisplayConfig{CardMode: &cardMode},
wantErr: `projects[0].display.card_mode must be "legacy" or "rich"`,
},
{
name: "invalid project history max len",
display: &DisplayConfig{HistoryMaxLen: &negativeHistoryMaxLen},
wantErr: `projects[0].display.history_max_len must be >= 0`,
},
}
for _, tt := range tests {

View File

@@ -51,6 +51,8 @@ type SendRequest struct {
Project string `json:"project"`
SessionKey string `json:"session_key"`
Message string `json:"message"`
WorkDir string `json:"work_dir,omitempty"`
CWD string `json:"cwd,omitempty"`
TTSText string `json:"tts_text,omitempty"`
Images []ImageAttachment `json:"images,omitempty"`
Files []FileAttachment `json:"files,omitempty"`
@@ -246,8 +248,12 @@ func (s *APIServer) handleSend(w http.ResponseWriter, r *http.Request) {
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = req.CWD
}
if req.Message != "" || len(req.Images) > 0 || len(req.Files) > 0 {
if err := engine.SendToSessionWithAttachments(req.SessionKey, req.Message, req.Images, req.Files, req.AtUsers, req.AtAll); err != nil {
if err := engine.SendToSessionWithOptions(req.SessionKey, req.Message, req.Images, req.Files, SendOptions{WorkDir: workDir, AtUsers: req.AtUsers, AtAll: req.AtAll}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -2,9 +2,11 @@ package core
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -176,6 +178,179 @@ func TestHandleSend_EmptyProjectMultipleEnginesRequiresName(t *testing.T) {
}
}
type sendWorkDirAgent struct {
name string
workDir string
session AgentSession
}
func (a *sendWorkDirAgent) Name() string { return a.name }
func (a *sendWorkDirAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
return a.session, nil
}
func (a *sendWorkDirAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
return nil, nil
}
func (a *sendWorkDirAgent) Stop() error { return nil }
func (a *sendWorkDirAgent) GetWorkDir() string {
return a.workDir
}
func TestHandleSend_WorkDirStartsSideSession(t *testing.T) {
agentName := "test-send-workdir-agent"
baseDir := t.TempDir()
targetDir := t.TempDir()
sessionKey := "test:user1"
var workspaceSession *resultAgentSession
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
workDir, _ := opts["work_dir"].(string)
workspaceSession = newResultAgentSession("agent result from " + workDir)
return &sendWorkDirAgent{
name: agentName,
workDir: workDir,
session: workspaceSession,
}, nil
})
platform := &stubCronReplyTargetPlatform{
stubPlatformEngine: stubPlatformEngine{n: "test"},
}
engine := NewEngine(
"test",
&sendWorkDirAgent{
name: agentName,
workDir: baseDir,
session: newResultAgentSession("base result"),
},
[]Platform{platform},
filepath.Join(t.TempDir(), "sessions.json"),
LangEnglish,
)
api := &APIServer{engines: map[string]*Engine{"test": engine}}
body, err := json.Marshal(map[string]any{
"project": "test",
"session_key": sessionKey,
"message": "please ask this person",
"work_dir": targetDir,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.handleSend(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
sent := platform.getSent()
if len(sent) != 1 || sent[0] != "please ask this person" {
t.Fatalf("platform sent = %#v, want direct send of request content", sent)
}
_, sessions, err := engine.getOrCreateWorkspaceAgent(targetDir)
if err != nil {
t.Fatalf("get workspace agent: %v", err)
}
list := sessions.ListSessions(sessionKey)
if len(list) != 1 {
t.Fatalf("workspace sessions len = %d, want 1", len(list))
}
if got := list[0].GetName(); got != "send" {
t.Fatalf("side session name = %q, want send", got)
}
platform.clearSent()
engine.handleMessage(platform, &Message{
SessionKey: sessionKey,
Platform: "test",
UserID: "user1",
UserName: "Target",
Content: "human answer",
ReplyCtx: "reply-ctx",
})
sent = waitForPlatformSend(&platform.stubPlatformEngine, 1, 3*time.Second)
if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "agent result from "+targetDir) {
t.Fatalf("platform sent after reply = %#v, want agent result from target work dir", sent)
}
if workspaceSession == nil || len(workspaceSession.sentPrompts) != 1 || !strings.Contains(workspaceSession.sentPrompts[0], "human answer") {
t.Fatalf("workspace session prompts = %#v, want human reply prompt", workspaceSession)
}
}
func TestHandleSend_WorkDirFollowsDirectParticipantOnInboundSession(t *testing.T) {
agentName := "test-send-workdir-direct-agent"
baseDir := t.TempDir()
targetDir := t.TempDir()
syntheticKey := "test:d:proactive:user1"
realInboundKey := "test:d:real-conversation:user1"
var workspaceSession *resultAgentSession
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
workDir, _ := opts["work_dir"].(string)
workspaceSession = newResultAgentSession("agent result from " + workDir)
return &sendWorkDirAgent{
name: agentName,
workDir: workDir,
session: workspaceSession,
}, nil
})
platform := &stubCronReplyTargetPlatform{
stubPlatformEngine: stubPlatformEngine{n: "test"},
}
engine := NewEngine(
"test",
&sendWorkDirAgent{
name: agentName,
workDir: baseDir,
session: newResultAgentSession("base result"),
},
[]Platform{platform},
filepath.Join(t.TempDir(), "sessions.json"),
LangEnglish,
)
api := &APIServer{engines: map[string]*Engine{"test": engine}}
body, err := json.Marshal(map[string]any{
"project": "test",
"session_key": syntheticKey,
"message": "please ask this person",
"work_dir": targetDir,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.handleSend(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
platform.clearSent()
engine.handleMessage(platform, &Message{
SessionKey: realInboundKey,
Platform: "test",
UserID: "user1",
UserName: "Target",
Content: "human answer",
ReplyCtx: "reply-ctx",
})
sent := waitForPlatformSend(&platform.stubPlatformEngine, 1, 3*time.Second)
if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "agent result from "+targetDir) {
t.Fatalf("platform sent after reply = %#v, want agent result from target work dir", sent)
}
if workspaceSession == nil || len(workspaceSession.sentPrompts) != 1 || !strings.Contains(workspaceSession.sentPrompts[0], "human answer") {
t.Fatalf("workspace session prompts = %#v, want human reply prompt", workspaceSession)
}
}
func TestHandleCronExec_TriggersJob(t *testing.T) {
store, err := NewCronStore(t.TempDir())
if err != nil {

80
core/cmdopts.go Normal file
View File

@@ -0,0 +1,80 @@
package core
import (
"log/slog"
"strings"
)
// ParseCmdOpts extracts the command and extra args from agent options.
//
// Priority (first non-empty wins):
// 1. "cmd" (canonical field, no warning)
// 2. "cli_path" (deprecated — logs a warning)
// 3. "command" (deprecated — logs a warning)
// 4. defaultBin (fallback when nothing is configured)
//
// Examples:
//
// "my-cli code -t foo" → cmd="my-cli", extraArgs=["code", "-t", "foo"]
// "claude" → cmd="claude", extraArgs=nil
// "" (default "pi") → cmd="pi", extraArgs=nil
func ParseCmdOpts(opts map[string]any, defaultBin string) (cmd string, extraArgs []string) {
if v, ok := opts["cmd"].(string); ok && strings.TrimSpace(v) != "" {
parts := strings.Fields(v)
return parts[0], parts[1:]
}
if v, ok := opts["cli_path"].(string); ok && strings.TrimSpace(v) != "" {
slog.Warn("DEPRECATED: 'cli_path' is deprecated, use 'cmd' instead",
"deprecated_key", "cli_path",
"new_key", "cmd",
"value", v)
parts := strings.Fields(v)
return parts[0], parts[1:]
}
if v, ok := opts["command"].(string); ok && strings.TrimSpace(v) != "" {
slog.Warn("DEPRECATED: 'command' is deprecated, use 'cmd' instead",
"deprecated_key", "command",
"new_key", "cmd",
"value", v)
parts := strings.Fields(v)
return parts[0], parts[1:]
}
return defaultBin, nil
}
// ParseConfigEnv parses opts["env"] (set via [projects.agent.options.env]
// in config.toml) into a []string of KEY=VALUE pairs.
//
// Supports both map[string]string and map[string]any (from TOML parser).
// Returns nil when no env is configured.
//
// The returned slice should be stored separately from sessionEnv so that
// SetSessionEnv calls cannot overwrite static config env.
func ParseConfigEnv(opts map[string]any) []string {
raw, ok := opts["env"]
if !ok || raw == nil {
return nil
}
switch m := raw.(type) {
case map[string]string:
env := make([]string, 0, len(m))
for k, v := range m {
env = append(env, k+"="+v)
}
return env
case map[string]any:
env := make([]string, 0, len(m))
for k, v := range m {
if s, ok := v.(string); ok {
env = append(env, k+"="+s)
}
}
return env
default:
return nil
}
}

319
core/cmdopts_test.go Normal file
View File

@@ -0,0 +1,319 @@
package core
import (
"bytes"
"encoding/json"
"log/slog"
"sort"
"testing"
)
// captureSlog redirects the default slog logger to a buffer for the duration
// of a test, and returns the buffer plus a restore func.
func captureSlog(t *testing.T) (*bytes.Buffer, func()) {
t.Helper()
prev := slog.Default()
buf := &bytes.Buffer{}
handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
return buf, func() { slog.SetDefault(prev) }
}
func TestParseCmdOpts_CmdField(t *testing.T) {
tests := []struct {
name string
opts map[string]any
defaultBin string
wantCmd string
wantArgs []string
}{
{
name: "cmd field with no args",
opts: map[string]any{"cmd": "claude"},
defaultBin: "fallback",
wantCmd: "claude",
wantArgs: nil,
},
{
name: "cmd field with extra args",
opts: map[string]any{"cmd": "my-cli code -t foo"},
defaultBin: "fallback",
wantCmd: "my-cli",
wantArgs: []string{"code", "-t", "foo"},
},
{
name: "cmd field with leading/trailing whitespace",
opts: map[string]any{"cmd": " claude "},
defaultBin: "fallback",
wantCmd: "claude",
wantArgs: nil,
},
{
name: "cmd field empty falls through to default",
opts: map[string]any{"cmd": ""},
defaultBin: "fallback",
wantCmd: "fallback",
wantArgs: nil,
},
{
name: "cmd field whitespace only falls through to default",
opts: map[string]any{"cmd": " \t "},
defaultBin: "fallback",
wantCmd: "fallback",
wantArgs: nil,
},
{
name: "cmd field non-string falls through to default",
opts: map[string]any{"cmd": 12345},
defaultBin: "fallback",
wantCmd: "fallback",
wantArgs: nil,
},
{
name: "no fields set returns default",
opts: map[string]any{},
defaultBin: "pi",
wantCmd: "pi",
wantArgs: nil,
},
{
name: "cmd with single extra arg",
opts: map[string]any{"cmd": "gemini --model pro"},
defaultBin: "fallback",
wantCmd: "gemini",
wantArgs: []string{"--model", "pro"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf, restore := captureSlog(t)
defer restore()
gotCmd, gotArgs := ParseCmdOpts(tt.opts, tt.defaultBin)
if gotCmd != tt.wantCmd {
t.Errorf("cmd: got %q, want %q", gotCmd, tt.wantCmd)
}
if !equalStrings(gotArgs, tt.wantArgs) {
t.Errorf("extraArgs: got %v, want %v", gotArgs, tt.wantArgs)
}
if buf.Len() != 0 {
t.Errorf("expected no log output, got: %s", buf.String())
}
})
}
}
func TestParseCmdOpts_DeprecatedCliPath(t *testing.T) {
buf, restore := captureSlog(t)
defer restore()
cmd, extraArgs := ParseCmdOpts(map[string]any{"cli_path": "old-cli --flag"}, "fallback")
if cmd != "old-cli" {
t.Errorf("cmd: got %q, want %q", cmd, "old-cli")
}
if !equalStrings(extraArgs, []string{"--flag"}) {
t.Errorf("extraArgs: got %v, want %v", extraArgs, []string{"--flag"})
}
if buf.Len() == 0 {
t.Fatal("expected deprecation warning, got none")
}
var rec map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
t.Fatalf("unmarshal log record: %v\nlog: %s", err, buf.String())
}
if rec["deprecated_key"] != "cli_path" {
t.Errorf("deprecated_key: got %v, want %q", rec["deprecated_key"], "cli_path")
}
if rec["new_key"] != "cmd" {
t.Errorf("new_key: got %v, want %q", rec["new_key"], "cmd")
}
if rec["value"] != "old-cli --flag" {
t.Errorf("value: got %v, want %q", rec["value"], "old-cli --flag")
}
}
func TestParseCmdOpts_DeprecatedCommand(t *testing.T) {
buf, restore := captureSlog(t)
defer restore()
cmd, extraArgs := ParseCmdOpts(map[string]any{"command": "legacy-cli sub"}, "fallback")
if cmd != "legacy-cli" {
t.Errorf("cmd: got %q, want %q", cmd, "legacy-cli")
}
if !equalStrings(extraArgs, []string{"sub"}) {
t.Errorf("extraArgs: got %v, want %v", extraArgs, []string{"sub"})
}
var rec map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
t.Fatalf("unmarshal log record: %v\nlog: %s", err, buf.String())
}
if rec["deprecated_key"] != "command" {
t.Errorf("deprecated_key: got %v, want %q", rec["deprecated_key"], "command")
}
if rec["new_key"] != "cmd" {
t.Errorf("new_key: got %v, want %q", rec["new_key"], "cmd")
}
}
func TestParseCmdOpts_Priority(t *testing.T) {
tests := []struct {
name string
opts map[string]any
wantCmd string
}{
{
name: "cmd wins over cli_path",
opts: map[string]any{"cmd": "new-cli", "cli_path": "old-cli"},
wantCmd: "new-cli",
},
{
name: "cmd wins over command",
opts: map[string]any{"cmd": "new-cli", "command": "old-cli"},
wantCmd: "new-cli",
},
{
name: "cli_path wins over command",
opts: map[string]any{"cli_path": "mid-cli", "command": "old-cli"},
wantCmd: "mid-cli",
},
{
name: "cli_path wins over default",
opts: map[string]any{"cli_path": "mid-cli"},
wantCmd: "mid-cli",
},
{
name: "command wins over default",
opts: map[string]any{"command": "old-cli"},
wantCmd: "old-cli",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, restore := captureSlog(t)
defer restore()
cmd, _ := ParseCmdOpts(tt.opts, "fallback")
if cmd != tt.wantCmd {
t.Errorf("cmd: got %q, want %q", cmd, tt.wantCmd)
}
})
}
}
func TestParseCmdOpts_NoWarningForCanonical(t *testing.T) {
buf, restore := captureSlog(t)
defer restore()
ParseCmdOpts(map[string]any{"cmd": "claude"}, "fallback")
if buf.Len() != 0 {
t.Errorf("expected no log output for 'cmd' field, got: %s", buf.String())
}
}
func TestParseCmdOpts_SlogRestoreNoLeak(t *testing.T) {
// Sanity check: the restore func actually reverts the default logger.
// We use a unique sentinel handler and verify it's gone after restore.
sentinel := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
prev := slog.Default()
slog.SetDefault(sentinel)
{
_, restore := captureSlog(t)
restore()
}
if slog.Default() != sentinel {
t.Errorf("expected default logger to be restored to sentinel")
}
slog.SetDefault(prev) // cleanup
}
func TestParseConfigEnv(t *testing.T) {
tests := []struct {
name string
opts map[string]any
want []string
}{
{
name: "nil opts",
opts: nil,
want: nil,
},
{
name: "no env key",
opts: map[string]any{"cmd": "x"},
want: nil,
},
{
name: "env nil value",
opts: map[string]any{"env": nil},
want: nil,
},
{
name: "env map string string",
opts: map[string]any{"env": map[string]string{"FOO": "bar", "BAZ": "qux"}},
want: []string{"FOO=bar", "BAZ=qux"},
},
{
name: "env map any string values (TOML output)",
opts: map[string]any{"env": map[string]any{"FOO": "bar", "BAZ": "qux"}},
want: []string{"FOO=bar", "BAZ=qux"},
},
{
name: "env map any with non-string value is skipped",
opts: map[string]any{"env": map[string]any{"FOO": "bar", "NUM": 42}},
want: []string{"FOO=bar"},
},
{
name: "env empty map",
opts: map[string]any{"env": map[string]string{}},
want: []string{},
},
{
name: "env unsupported type returns nil",
opts: map[string]any{"env": []string{"FOO=bar"}},
want: nil,
},
{
name: "env value containing equals",
opts: map[string]any{"env": map[string]string{"URL": "https://x?a=1&b=2"}},
want: []string{"URL=https://x?a=1&b=2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseConfigEnv(tt.opts)
if !equalStrings(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestParseConfigEnv_DoesNotMutateInput(t *testing.T) {
in := map[string]any{
"env": map[string]any{
"FOO": "bar",
},
}
_ = ParseConfigEnv(in)
// Mutate the input — the next call should still return correct data,
// proving we don't keep a reference to the inner map.
in["env"].(map[string]any)["FOO"] = "mutated"
in["env"].(map[string]any)["NEW"] = "added"
got := ParseConfigEnv(in)
if !equalStrings(got, []string{"FOO=mutated", "NEW=added"}) {
t.Errorf("expected fresh read on each call, got %v", got)
}
}
// equalStrings compares two string slices for unordered-set equality.
// Order is not guaranteed for map-based inputs.
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
ac := append([]string(nil), a...)
bc := append([]string(nil), b...)
sort.Strings(ac)
sort.Strings(bc)
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}

View File

@@ -30,6 +30,13 @@ const maxPlatformMessageLen = 4000
const telegramBotCommandLimit = 100
const defaultMaxQueuedMessages = 5 // default cap for queued messages per session
// defaultPendingRestartTimeout is how long the post-restart notify
// dispatcher waits for the target platform to reach ready before
// dropping the notify with a warning. 10s covers the typical 2-3s
// Telegram connect window with margin and is short enough that a stuck
// platform does not block startup logging indefinitely.
const defaultPendingRestartTimeout = 10 * time.Second
// previewText truncates s to maxRunes runes for safe inclusion in debug logs.
// Truncation uses runes (not bytes) so multi-byte characters render cleanly.
// Newlines are replaced with literal "\n" to keep each log entry on one line.
@@ -53,6 +60,7 @@ func previewText(s string, maxRunes int) string {
const (
defaultThinkingMaxLen = 300
defaultToolMaxLen = 500
defaultHistoryMaxLen = 1000
)
// Slow-operation thresholds. Operations exceeding these durations produce a
@@ -124,35 +132,167 @@ func ConsumeRestartNotify(dataDir string) *RestartRequest {
}
// SendRestartNotification sends a "restart successful" message to the
// platform/session that initiated the restart.
// platform/session that initiated the restart. For async-recoverable
// platforms that may not be ready yet at startup, the call is queued
// and dispatched on the first OnPlatformReady for the matching platform
// (see issue #1383).
func (e *Engine) SendRestartNotification(platformName, sessionKey string) {
for _, p := range e.platforms {
if p.Name() != platformName {
continue
}
rc, ok := p.(ReplyContextReconstructor)
if !ok {
slog.Debug("restart notify: platform does not support ReconstructReplyCtx", "platform", platformName)
return
}
rctx, err := rc.ReconstructReplyCtx(sessionKey)
if err != nil {
slog.Debug("restart notify: reconstruct failed", "error", err)
return
}
text := e.i18n.T(MsgRestartSuccess)
if CurrentVersion != "" {
text += fmt.Sprintf(" (%s)", CurrentVersion)
}
if err := e.waitOutgoing(p); err != nil {
slog.Debug("restart notify: outgoing wait cancelled or limited", "platform", platformName, "error", err)
return
}
if err := p.Send(e.ctx, rctx, text); err != nil {
slog.Debug("restart notify: send failed", "error", err)
}
req := &RestartRequest{Platform: platformName, SessionKey: sessionKey}
e.SetPendingRestartNotify(req)
}
// SetPendingRestartNotify queues a restart notification for dispatch
// when the target platform becomes ready. Replaces any previously queued
// notify. If the target platform is already ready, the notify is
// dispatched on a background goroutine with retry on send failure.
func (e *Engine) SetPendingRestartNotify(req *RestartRequest) {
if req == nil {
return
}
e.pendingRestartMu.Lock()
e.pendingRestartNotify = req
e.pendingRestartFiredCh = make(chan struct{})
firedCh := e.pendingRestartFiredCh
e.pendingRestartMu.Unlock()
// If the target platform is already ready, fire the dispatch on a
// goroutine so the caller (main startup) is not blocked. If not yet
// ready, OnPlatformReady will pick it up. A safety goroutine drops
// the notify after a timeout if the platform never reaches ready.
go e.runPendingRestartNotify(req, firedCh)
}
// ConsumePendingRestartNotify returns the currently queued notify (if any)
// and clears it. Used by tests.
func (e *Engine) ConsumePendingRestartNotify() *RestartRequest {
e.pendingRestartMu.Lock()
defer e.pendingRestartMu.Unlock()
req := e.pendingRestartNotify
e.pendingRestartNotify = nil
return req
}
// SetPendingRestartTimeout overrides the safety timeout used by
// runPendingRestartNotify. Production code uses defaultPendingRestartTimeout
// (10s); tests use this to exercise the timeout path quickly.
func (e *Engine) SetPendingRestartTimeout(d time.Duration) {
e.pendingRestartTimeout = d
}
// runPendingRestartNotify dispatches the notify for a platform that is
// already ready, with bounded retry on transient send failure. The fired
// channel is closed when the notify is fully resolved (success, exhausted
// retries, or platform dropped from engine). This is called from
// SetPendingRestartNotify on a background goroutine and from
// onPlatformReady (also on a goroutine).
func (e *Engine) runPendingRestartNotify(req *RestartRequest, firedCh chan struct{}) {
defer close(firedCh)
// Wait briefly for the platform to reach ready if it's not already.
// Upper bound: matches the typical Telegram 2-3 s connect window
// with margin (see defaultPendingRestartTimeout), and short enough
// that a stuck platform does not block startup logging forever.
timeout := e.pendingRestartTimeout
if timeout <= 0 {
timeout = defaultPendingRestartTimeout
}
deadline := time.Now().Add(timeout)
for {
e.pendingRestartMu.Lock()
stillQueued := e.pendingRestartNotify == req
e.pendingRestartMu.Unlock()
if !stillQueued {
return
}
if e.lookupReadyPlatform(req.Platform) != nil {
break
}
if time.Now().After(deadline) {
slog.Warn("restart notify: target platform did not reach ready in time, dropping",
"platform", req.Platform, "session", req.SessionKey, "timeout", timeout)
e.clearPendingRestart(req)
return
}
time.Sleep(200 * time.Millisecond)
}
if err := e.dispatchRestartNotify(req); err != nil {
slog.Warn("restart notify: dispatch failed after retries",
"platform", req.Platform, "session", req.SessionKey, "error", err)
}
e.clearPendingRestart(req)
}
// lookupReadyPlatform returns the platform with the given name if it has
// reached ready state, otherwise nil.
func (e *Engine) lookupReadyPlatform(platformName string) Platform {
e.platformLifecycleMu.Lock()
defer e.platformLifecycleMu.Unlock()
for p, ready := range e.platformReady {
if ready && p.Name() == platformName {
return p
}
}
return nil
}
// clearPendingRestart removes the queued notify if it is still the same
// request (avoids clearing a newer notify that replaced this one).
func (e *Engine) clearPendingRestart(req *RestartRequest) {
e.pendingRestartMu.Lock()
if e.pendingRestartNotify == req {
e.pendingRestartNotify = nil
}
e.pendingRestartMu.Unlock()
}
// dispatchRestartNotify sends the notify to the target platform with up
// to 3 attempts (initial + 2 retries) on transient failure. The
// platform must already be ready when this is called.
func (e *Engine) dispatchRestartNotify(req *RestartRequest) error {
p := e.lookupReadyPlatform(req.Platform)
if p == nil {
return fmt.Errorf("platform %q not ready", req.Platform)
}
rc, ok := p.(ReplyContextReconstructor)
if !ok {
return fmt.Errorf("platform %q does not support ReconstructReplyCtx", req.Platform)
}
rctx, err := rc.ReconstructReplyCtx(req.SessionKey)
if err != nil {
return fmt.Errorf("reconstruct reply ctx: %w", err)
}
text := e.i18n.T(MsgRestartSuccess)
if CurrentVersion != "" {
text += fmt.Sprintf(" (%s)", CurrentVersion)
}
backoffs := []time.Duration{0, 500 * time.Millisecond, 1500 * time.Millisecond}
var lastErr error
for attempt, wait := range backoffs {
if wait > 0 {
time.Sleep(wait)
}
if err := e.waitOutgoing(p); err != nil {
lastErr = fmt.Errorf("wait outgoing: %w", err)
slog.Warn("restart notify: wait outgoing failed",
"platform", req.Platform, "attempt", attempt+1, "error", err)
continue
}
if err := p.Send(e.ctx, rctx, text); err != nil {
lastErr = err
slog.Warn("restart notify: send failed, will retry",
"platform", req.Platform, "session", req.SessionKey,
"attempt", attempt+1, "max_attempts", len(backoffs), "error", err)
continue
}
if attempt > 0 {
slog.Info("restart notify: send succeeded after retry",
"platform", req.Platform, "session", req.SessionKey, "attempt", attempt+1)
}
return nil
}
return lastErr
}
// RestartCh is signaled when /restart is invoked. main listens on it
@@ -168,6 +308,7 @@ type DisplayCfg struct {
ThinkingMaxLen int // max runes for thinking preview; 0 = no truncation
ToolMaxLen int // max runes for tool use preview; 0 = no truncation
ToolMessages bool
HistoryMaxLen *int // max runes for /history entries; nil = default, 0 = no truncation
}
// InstantReplyCfg controls the immediate confirmation reply sent when a message
@@ -270,8 +411,8 @@ type Engine struct {
filterExternalSessions bool
// Shell configuration for /shell, cron exec, hooks, webhook exec
shell string // shell binary path (e.g. "sh", "/bin/zsh")
shellFlag string // shell flag (e.g. "-c", "-Command", "/C")
shell string // shell binary path (e.g. "sh", "/bin/zsh")
shellFlag string // shell flag (e.g. "-c", "-Command", "/C")
shellProfile string // prepended to every command (e.g. "source ~/.zshrc;")
// Multi-workspace mode
@@ -283,6 +424,8 @@ type Engine struct {
workspacePool *workspacePool
initFlows map[string]*workspaceInitFlow // workspace channel key → init state
initFlowsMu sync.Mutex
sendWorkDirMu sync.RWMutex
sendWorkDirs map[string]string // sessionKey → work_dir assigned by send --cwd
// Terminal observation (--observe)
observeEnabled bool
@@ -300,6 +443,20 @@ type Engine struct {
replyFooterMu sync.Mutex
replyFooterUsage replyFooterUsageCache
// pendingRestartNotify is queued at startup if a /restart was consumed
// from the run/restart_notify file. It is dispatched on the first
// OnPlatformReady for the matching platform name, so async platforms
// (Telegram, Weixin, Matrix, Discord) have a chance to actually connect
// before the post-restart message is sent. See issue #1383.
pendingRestartMu sync.Mutex
pendingRestartNotify *RestartRequest
pendingRestartFiredCh chan struct{} // closed when the notify is dispatched (success or exhausted)
// pendingRestartTimeout is how long the dispatcher waits for the
// target platform to reach ready before dropping the notify with a
// warning. Default 10s; tests may override.
pendingRestartTimeout time.Duration
// /web command callbacks
webSetupFunc func() (port int, token string, needRestart bool, err error)
webStatusFunc func() (url string)
@@ -556,6 +713,7 @@ func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath str
skills: NewSkillRegistry(),
aliases: make(map[string]string),
interactiveStates: make(map[string]*interactiveState),
sendWorkDirs: make(map[string]string),
platformReady: make(map[Platform]bool),
startedAt: time.Now(),
streamPreview: DefaultStreamPreviewCfg(),
@@ -566,6 +724,7 @@ func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath str
showWorkdirIndicator: true,
shell: defaultShell(),
shellFlag: defaultShellFlag(),
pendingRestartTimeout: defaultPendingRestartTimeout,
}
if ag != nil {
@@ -2608,7 +2767,17 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
var wsAgent Agent
var wsSessions *SessionManager
var resolvedWorkspace string
if e.multiWorkspace {
if forcedWorkDir := e.sendWorkDirForSession(msg.SessionKey); forcedWorkDir != "" {
e.bindSendWorkDir(msg.SessionKey, forcedWorkDir)
var err error
wsAgent, wsSessions, err = e.getOrCreateWorkspaceAgent(forcedWorkDir)
if err != nil {
slog.Error("failed to create send work_dir agent", "work_dir", forcedWorkDir, "err", err)
e.reply(p, msg.ReplyCtx, fmt.Sprintf("Failed to initialize workspace: %v", err))
return
}
resolvedWorkspace = forcedWorkDir
} else if e.multiWorkspace {
channelID := effectiveChannelID(msg)
channelKey := effectiveWorkspaceChannelKey(msg)
workspace, channelName, err := e.resolveWorkspace(p, channelID)
@@ -2655,7 +2824,7 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
sessions := e.sessions
agent := e.agent
interactiveKey := msg.SessionKey
if e.multiWorkspace && wsSessions != nil {
if resolvedWorkspace != "" && wsSessions != nil {
sessions = wsSessions
agent = wsAgent
interactiveKey = resolvedWorkspace + ":" + msg.SessionKey
@@ -3526,10 +3695,14 @@ func (e *Engine) processInteractiveMessageWith(p Platform, msg *Message, session
// getOrCreateWorkspaceAgent returns (or creates) a per-workspace agent and session manager.
// workspace must be a normalized path (from resolveWorkspace or normalizeWorkspacePath).
func (e *Engine) getOrCreateWorkspaceAgent(workspace string) (Agent, *SessionManager, error) {
e.interactiveMu.Lock()
if e.workspacePool == nil {
return nil, nil, fmt.Errorf("workspace pool not initialized (multi-workspace mode not enabled)")
e.workspacePool = newWorkspacePool(DefaultWorkspaceIdleTimeout)
}
ws := e.workspacePool.GetOrCreate(workspace)
pool := e.workspacePool
e.interactiveMu.Unlock()
ws := pool.GetOrCreate(workspace)
ws.mu.Lock()
defer ws.mu.Unlock()
@@ -8593,20 +8766,33 @@ func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📜 History (last %d):\n\n", len(entries)))
maxLen := e.historyEntryMaxLen()
for _, h := range entries {
icon := "👤"
if h.Role == "assistant" {
icon = "🤖"
}
content := h.Content
if len([]rune(content)) > 200 {
content = string([]rune(content)[:200]) + "..."
}
content := truncateHistoryEntry(h.Content, maxLen)
sb.WriteString(fmt.Sprintf("%s [%s]\n%s\n\n", icon, h.Timestamp.Format("15:04:05"), content))
}
e.reply(p, msg.ReplyCtx, sb.String())
}
func (e *Engine) historyEntryMaxLen() int {
if e.display.HistoryMaxLen != nil {
return *e.display.HistoryMaxLen
}
return defaultHistoryMaxLen
}
func truncateHistoryEntry(content string, maxLen int) string {
if maxLen <= 0 || utf8.RuneCountInString(content) <= maxLen {
return content
}
runes := []rune(content)
return string(runes[:maxLen]) + "..."
}
func (e *Engine) cmdLang(p Platform, msg *Message, args []string) {
if len(args) == 0 {
cur := e.i18n.CurrentLang()
@@ -10385,7 +10571,28 @@ func (e *Engine) SendToSession(sessionKey, message string) error {
return e.SendToSessionWithAttachments(sessionKey, message, nil, nil, nil, false)
}
// SendOptions controls optional behavior for external send callers.
type SendOptions struct {
WorkDir string
AtUsers []string
AtAll bool
}
func (e *Engine) SendToSessionWithAttachments(sessionKey, message string, images []ImageAttachment, files []FileAttachment, atUsers []string, atAll bool) error {
return e.SendToSessionWithOptions(sessionKey, message, images, files, SendOptions{AtUsers: atUsers, AtAll: atAll})
}
func (e *Engine) SendToSessionWithOptions(sessionKey, message string, images []ImageAttachment, files []FileAttachment, opts SendOptions) error {
if strings.TrimSpace(opts.WorkDir) != "" {
workDir, useWorkDirSession, err := e.resolveSendWorkDir(opts.WorkDir)
if err != nil {
return err
}
if useWorkDirSession {
return e.SendToSessionInWorkDir(sessionKey, message, images, files, workDir, opts.AtUsers, opts.AtAll)
}
}
state, p, replyCtx, err := e.resolveOutboundSessionTarget(sessionKey, len(images) > 0 || len(files) > 0)
if err != nil {
return err
@@ -10421,9 +10628,9 @@ func (e *Engine) SendToSessionWithAttachments(sessionKey, message string, images
return err
}
// Use AtMentionSender when @users specified and platform supports it
if len(atUsers) > 0 || atAll {
if len(opts.AtUsers) > 0 || opts.AtAll {
if atSender, ok := p.(AtMentionSender); ok {
if err := atSender.ReplyWithAt(e.ctx, replyCtx, message, atUsers, atAll); err != nil {
if err := atSender.ReplyWithAt(e.ctx, replyCtx, message, opts.AtUsers, opts.AtAll); err != nil {
return err
}
} else {
@@ -10461,6 +10668,257 @@ func (e *Engine) SendToSessionWithAttachments(sessionKey, message string, images
return nil
}
type sendTarget struct {
state *interactiveState
platform Platform
replyCtx any
sessionKey string
}
func (e *Engine) SendToSessionInWorkDir(sessionKey, message string, images []ImageAttachment, files []FileAttachment, workDir string, atUsers []string, atAll bool) error {
if message == "" && len(images) == 0 && len(files) == 0 {
return fmt.Errorf("message or attachment is required")
}
if (len(images) > 0 || len(files) > 0) && !e.attachmentSendEnabled {
return ErrAttachmentSendDisabled
}
target, err := e.resolveSendTarget(sessionKey, len(images) > 0 || len(files) > 0)
if err != nil {
return err
}
if target.platform == nil {
return fmt.Errorf("no active session found (key=%q)", sessionKey)
}
_, sessions, err := e.getOrCreateWorkspaceAgent(workDir)
if err != nil {
return err
}
session := sessions.NewSession(target.sessionKey, "send")
if message != "" {
session.AddHistory("assistant", message)
}
sessions.Save()
e.bindSendWorkDir(target.sessionKey, workDir)
var imageSender ImageSender
if len(images) > 0 {
var ok bool
imageSender, ok = target.platform.(ImageSender)
if !ok {
return fmt.Errorf("platform %s: %w", target.platform.Name(), ErrNotSupported)
}
}
var fileSender FileSender
if len(files) > 0 {
var ok bool
fileSender, ok = target.platform.(FileSender)
if !ok {
return fmt.Errorf("platform %s: %w", target.platform.Name(), ErrNotSupported)
}
}
if message != "" {
if err := e.waitOutgoing(target.platform); err != nil {
return err
}
if len(atUsers) > 0 || atAll {
if atSender, ok := target.platform.(AtMentionSender); ok {
if err := atSender.ReplyWithAt(e.ctx, target.replyCtx, message, atUsers, atAll); err != nil {
return err
}
} else if err := target.platform.Send(e.ctx, target.replyCtx, message); err != nil {
return err
}
} else {
if err := target.platform.Send(e.ctx, target.replyCtx, message); err != nil {
return err
}
}
if target.state != nil {
target.state.mu.Lock()
target.state.sideText = strings.TrimSpace(message)
target.state.mu.Unlock()
}
}
for _, img := range images {
if err := e.waitOutgoing(target.platform); err != nil {
return err
}
if err := imageSender.SendImage(e.ctx, target.replyCtx, img); err != nil {
return err
}
}
for _, file := range files {
if err := e.waitOutgoing(target.platform); err != nil {
return err
}
if err := fileSender.SendFile(e.ctx, target.replyCtx, file); err != nil {
return err
}
}
return nil
}
func (e *Engine) resolveSendTarget(sessionKey string, attachments bool) (sendTarget, error) {
e.interactiveMu.Lock()
resolvedSessionKey := sessionKey
var state *interactiveState
if sessionKey != "" {
state = e.interactiveStates[sessionKey]
if state == nil && e.multiWorkspace {
if iKey := e.interactiveKeyForSessionKeyLocked(sessionKey); iKey != sessionKey {
state = e.interactiveStates[iKey]
}
}
} else if len(e.interactiveStates) == 1 {
for key, s := range e.interactiveStates {
resolvedSessionKey = key
state = s
break
}
} else if len(e.interactiveStates) > 1 && attachments {
e.interactiveMu.Unlock()
return sendTarget{}, fmt.Errorf("multiple active sessions; must specify --session to send attachments")
} else {
for key, s := range e.interactiveStates {
resolvedSessionKey = key
state = s
break
}
}
e.interactiveMu.Unlock()
var p Platform
var replyCtx any
if state != nil {
state.mu.Lock()
p = state.platform
replyCtx = state.replyCtx
state.mu.Unlock()
}
if p == nil && resolvedSessionKey != "" {
strippedKey := resolvedSessionKey
platformName := ""
if idx := strings.Index(strippedKey, ":"); idx > 0 {
platformName = strippedKey[:idx]
}
var targetPlatform Platform
for _, candidate := range e.platforms {
if candidate.Name() == platformName {
targetPlatform = candidate
break
}
}
if targetPlatform == nil {
for _, candidate := range e.platforms {
needle := ":" + candidate.Name() + ":"
if idx := strings.Index(strippedKey, needle); idx >= 0 {
targetPlatform = candidate
strippedKey = strippedKey[idx+1:]
break
}
}
}
if targetPlatform != nil {
rc, ok := targetPlatform.(ReplyContextReconstructor)
if !ok {
return sendTarget{}, fmt.Errorf("platform %q does not support proactive messaging", targetPlatform.Name())
}
reconstructed, err := rc.ReconstructReplyCtx(strippedKey)
if err != nil {
return sendTarget{}, fmt.Errorf("reconstruct reply context: %w", err)
}
p = targetPlatform
replyCtx = reconstructed
resolvedSessionKey = strippedKey
}
}
return sendTarget{
state: state,
platform: p,
replyCtx: replyCtx,
sessionKey: resolvedSessionKey,
}, nil
}
func (e *Engine) resolveSendWorkDir(workDir string) (string, bool, error) {
current := e.currentSendWorkDir()
target, err := normalizeSendWorkDir(workDir, current)
if err != nil {
return "", false, err
}
info, err := os.Stat(target)
if err != nil {
return "", false, fmt.Errorf("work_dir %q: %w", target, err)
}
if !info.IsDir() {
return "", false, fmt.Errorf("work_dir %q is not a directory", target)
}
if current != "" {
if normalizedCurrent, err := normalizeSendWorkDir(current, ""); err == nil && normalizedCurrent == target {
return target, false, nil
}
}
return target, true, nil
}
func (e *Engine) currentSendWorkDir() string {
if e.agent != nil {
if wd, ok := e.agent.(interface{ GetWorkDir() string }); ok {
if dir := strings.TrimSpace(wd.GetWorkDir()); dir != "" {
return dir
}
}
}
if strings.TrimSpace(e.baseWorkDir) != "" {
return strings.TrimSpace(e.baseWorkDir)
}
if cwd, err := os.Getwd(); err == nil {
return cwd
}
return ""
}
func normalizeSendWorkDir(workDir, base string) (string, error) {
dir := strings.TrimSpace(workDir)
if dir == "" {
return "", fmt.Errorf("work_dir is required")
}
if dir == "~" || strings.HasPrefix(dir, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
if dir == "~" {
dir = home
} else {
dir = filepath.Join(home, strings.TrimPrefix(dir, "~/"))
}
}
if !filepath.IsAbs(dir) {
if strings.TrimSpace(base) == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve current directory: %w", err)
}
}
dir = filepath.Join(base, dir)
}
abs, err := filepath.Abs(filepath.Clean(dir))
if err != nil {
return "", err
}
return abs, nil
}
// SendTTSToSession synthesizes and sends a voice message to an active session.
// It is used by the local API/CLI so agents can call `cc-connect send --tts`.
func (e *Engine) SendTTSToSession(sessionKey, text string) error {
@@ -12431,15 +12889,13 @@ func (e *Engine) renderHistoryCard(sessionKey string) *Card {
}
var sb strings.Builder
maxLen := e.historyEntryMaxLen()
for _, h := range entries {
icon := "👤"
if h.Role == "assistant" {
icon = "🤖"
}
content := h.Content
if len([]rune(content)) > 200 {
content = string([]rune(content)[:200]) + "..."
}
content := truncateHistoryEntry(h.Content, maxLen)
sb.WriteString(fmt.Sprintf("%s [%s]\n%s\n\n", icon, h.Timestamp.Format("15:04:05"), content))
}
@@ -15447,6 +15903,11 @@ func (e *Engine) commandContextWithWorkspace(p Platform, msg *Message) (Agent, *
// sessionContextForKey resolves the agent and session manager for a sessionKey.
// It uses existing workspace bindings and falls back to global context if unresolved.
func (e *Engine) sessionContextForKey(sessionKey string) (Agent, *SessionManager) {
if workspace := e.sendWorkDirForSession(sessionKey); workspace != "" {
if wsAgent, wsSessions, err := e.getOrCreateWorkspaceAgent(workspace); err == nil {
return wsAgent, wsSessions
}
}
if !e.multiWorkspace || e.workspaceBindings == nil {
return e.agent, e.sessions
}
@@ -15472,6 +15933,54 @@ func (e *Engine) sessionContextForKey(sessionKey string) (Agent, *SessionManager
return e.agent, e.sessions
}
func (e *Engine) bindSendWorkDir(sessionKey, workDir string) {
if sessionKey == "" || workDir == "" {
return
}
keys := []string{sessionKey}
if participantKey := directParticipantKeyForSession(sessionKey); participantKey != "" {
keys = append(keys, participantKey)
}
e.sendWorkDirMu.Lock()
defer e.sendWorkDirMu.Unlock()
if e.sendWorkDirs == nil {
e.sendWorkDirs = make(map[string]string)
}
for _, key := range keys {
if key != "" {
e.sendWorkDirs[key] = workDir
}
}
}
func (e *Engine) sendWorkDirForSession(sessionKey string) string {
if sessionKey == "" {
return ""
}
participantKey := directParticipantKeyForSession(sessionKey)
e.sendWorkDirMu.RLock()
defer e.sendWorkDirMu.RUnlock()
if workDir := e.sendWorkDirs[sessionKey]; workDir != "" {
return workDir
}
if participantKey != "" {
return e.sendWorkDirs[participantKey]
}
return ""
}
func directParticipantKeyForSession(sessionKey string) string {
platformName := extractPlatformName(sessionKey)
if platformName == "" {
return ""
}
parts := strings.SplitN(sessionKey, ":", 5)
if len(parts) < 4 || parts[1] != "d" || strings.TrimSpace(parts[3]) == "" {
return ""
}
return platformName + ":direct-user:" + strings.TrimSpace(parts[3])
}
// workspaceFromLiveState extracts the workspace path embedded in a live
// interactive state key for sessionKey, or "" if no live state references
// this sessionKey. Used as a recovery path when channel-binding-derived
@@ -15494,6 +16003,9 @@ func (e *Engine) workspaceFromLiveState(sessionKey string) string {
// interactiveKeyForSessionKey returns the interactive state key for a sessionKey.
// In multi-workspace mode, it prefixes with the bound workspace path when available.
func (e *Engine) interactiveKeyForSessionKey(sessionKey string) string {
if workspace := e.sendWorkDirForSession(sessionKey); workspace != "" {
return workspace + ":" + sessionKey
}
// Single-workspace fast path: no scan, no binding lookup, no lock.
if !e.multiWorkspace || e.workspaceBindings == nil {
return sessionKey
@@ -15527,6 +16039,9 @@ func (e *Engine) interactiveKeyForSessionKey(sessionKey string) string {
// ID, so step 2 misses. The state map was keyed correctly at processing
// time, so we recover the workspace prefix from there.
func (e *Engine) interactiveKeyForSessionKeyLocked(sessionKey string) string {
if workspace := e.sendWorkDirForSession(sessionKey); workspace != "" {
return workspace + ":" + sessionKey
}
if !e.multiWorkspace || e.workspaceBindings == nil {
return sessionKey
}

View File

@@ -12528,6 +12528,34 @@ func TestEstimateTokensWithPendingAssistant(t *testing.T) {
}
}
func TestTruncateHistoryEntry(t *testing.T) {
if got := truncateHistoryEntry("abcdef", 3); got != "abc..." {
t.Fatalf("truncateHistoryEntry ascii = %q, want %q", got, "abc...")
}
if got := truncateHistoryEntry("你好世界", 2); got != "你好..." {
t.Fatalf("truncateHistoryEntry unicode = %q, want %q", got, "你好...")
}
if got := truncateHistoryEntry("👨‍👩‍👧 中文", 2); got != "👨‍..." || !utf8.ValidString(got) {
t.Fatalf("truncateHistoryEntry emoji = %q, want valid UTF-8 %q", got, "👨‍...")
}
if got := truncateHistoryEntry("abcdef", 0); got != "abcdef" {
t.Fatalf("truncateHistoryEntry disabled = %q, want original", got)
}
}
func TestEngineHistoryEntryMaxLen(t *testing.T) {
e := NewEngine("test", &stubAgent{}, []Platform{&stubPlatformEngine{n: "test"}}, filepath.Join(t.TempDir(), "sessions.json"), LangEnglish)
if got := e.historyEntryMaxLen(); got != defaultHistoryMaxLen {
t.Fatalf("default historyEntryMaxLen = %d, want %d", got, defaultHistoryMaxLen)
}
limit := 0
e.SetDisplayConfig(DisplayCfg{HistoryMaxLen: &limit})
if got := e.historyEntryMaxLen(); got != 0 {
t.Fatalf("configured historyEntryMaxLen = %d, want 0", got)
}
}
type recordingTTS struct {
mu sync.Mutex
text string

262
core/restart_notify_test.go Normal file
View File

@@ -0,0 +1,262 @@
package core
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// restartNotifyStub is a Platform that records Send calls and can be made
// to fail the first N sends. It also implements ReplyContextReconstructor
// so the post-restart notify path can find a reply context for the
// session key.
type restartNotifyStub struct {
mu sync.Mutex
name string
sent []string
failFirstN int32
failNextError error
ready atomic.Bool
reconstructRCT string
}
func (p *restartNotifyStub) Name() string { return p.name }
func (p *restartNotifyStub) Start(MessageHandler) error {
return nil
}
func (p *restartNotifyStub) Stop() error { return nil }
func (p *restartNotifyStub) Reply(_ context.Context, _ any, content string) error {
return p.recordSend(content)
}
func (p *restartNotifyStub) Send(_ context.Context, _ any, content string) error {
return p.recordSend(content)
}
func (p *restartNotifyStub) recordSend(content string) error {
p.mu.Lock()
failCount := p.failFirstN
failErr := p.failNextError
p.mu.Unlock()
if atomic.LoadInt32(&failCount) > 0 {
atomic.AddInt32(&p.failFirstN, -1)
if failErr != nil {
return failErr
}
return errors.New("simulated send failure")
}
p.mu.Lock()
p.sent = append(p.sent, content)
p.mu.Unlock()
return nil
}
func (p *restartNotifyStub) sentTexts() []string {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]string, len(p.sent))
copy(out, p.sent)
return out
}
func (p *restartNotifyStub) ReconstructReplyCtx(sessionKey string) (any, error) {
if p.reconstructRCT != "" {
return p.reconstructRCT, nil
}
return "rctx-" + sessionKey, nil
}
// markReady simulates the engine's onPlatformReady transition. The
// engine's lookupReadyPlatform checks the platformReady map, which is
// normally mutated by the real engine; tests below use the public
// OnPlatformReady path to do the same.
func (p *restartNotifyStub) markReady(t *testing.T, e *Engine) {
t.Helper()
if !p.ready.CompareAndSwap(false, true) {
return
}
e.OnPlatformReady(p)
}
// waitForSent polls until the stub has recorded at least n sent
// messages, or fails the test after timeout. Used so tests don't
// have to know the exact dispatch timing.
func (p *restartNotifyStub) waitForSent(t *testing.T, n int, timeout time.Duration) []string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if got := p.sentTexts(); len(got) >= n {
return got
}
time.Sleep(20 * time.Millisecond)
}
return p.sentTexts()
}
// TestRestartNotify_DispatchesAfterPlatformReady covers the core
// issue #1383 fix: the post-restart notify must wait for the
// platform to be ready, not fire immediately at startup.
func TestRestartNotify_DispatchesAfterPlatformReady(t *testing.T) {
plat := &restartNotifyStub{name: "telegram"}
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
// Queue the notify BEFORE marking ready — this mirrors the real
// startup order in cmd/cc-connect/main.go where SetPendingRestartNotify
// is called right after e.Start() returns.
engine.SetPendingRestartNotify(&RestartRequest{
Platform: "telegram",
SessionKey: "session-1",
})
// At this point the platform is not ready; nothing should have been
// sent yet.
if got := plat.sentTexts(); len(got) != 0 {
t.Fatalf("notify fired before platform ready: %v", got)
}
// Simulate the 2.6s Telegram connect window described in the issue.
time.Sleep(300 * time.Millisecond) // simulate async startup delay
plat.markReady(t, engine)
// Notify should now arrive.
got := plat.waitForSent(t, 1, 2*time.Second)
if len(got) != 1 {
t.Fatalf("expected 1 sent message after ready, got %d (%v)", len(got), got)
}
if !strings.Contains(got[0], "restarted successfully") {
t.Errorf("expected restart success text, got %q", got[0])
}
// Pending slot should be cleared after dispatch.
if engine.ConsumePendingRestartNotify() != nil {
t.Error("pending restart notify was not cleared after dispatch")
}
}
// TestRestartNotify_AlreadyReadySucceedsImmediately covers the
// non-async platform case: if the platform is marked ready before
// the notify is queued (e.g. synchronous platforms), the dispatch
// still happens without a 10s wait.
func TestRestartNotify_AlreadyReadySucceedsImmediately(t *testing.T) {
plat := &restartNotifyStub{name: "feishu"}
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
// Mark ready first.
plat.markReady(t, engine)
engine.SetPendingRestartNotify(&RestartRequest{
Platform: "feishu",
SessionKey: "session-1",
})
got := plat.waitForSent(t, 1, 1*time.Second)
if len(got) != 1 {
t.Fatalf("expected 1 sent message, got %d (%v)", len(got), got)
}
}
// TestRestartNotify_RetriesOnSendFailure verifies the retry path:
// first attempt fails, second attempt succeeds, message lands.
func TestRestartNotify_RetriesOnSendFailure(t *testing.T) {
plat := &restartNotifyStub{
name: "telegram",
failFirstN: 1, // fail first attempt, succeed on second
}
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
plat.markReady(t, engine)
engine.SetPendingRestartNotify(&RestartRequest{
Platform: "telegram",
SessionKey: "session-1",
})
// With backoff (0 + 500ms), the second send should land within 2s.
got := plat.waitForSent(t, 1, 3*time.Second)
if len(got) != 1 {
t.Fatalf("expected 1 sent message after retry, got %d (%v)", len(got), got)
}
}
// TestRestartNotify_ExhaustsRetriesNoHang verifies that when every
// attempt fails, the dispatcher gives up (does not loop forever)
// and clears the pending slot.
func TestRestartNotify_ExhaustsRetriesNoHang(t *testing.T) {
plat := &restartNotifyStub{
name: "telegram",
failFirstN: 100, // effectively never succeed within 3 attempts
}
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
plat.markReady(t, engine)
engine.SetPendingRestartNotify(&RestartRequest{
Platform: "telegram",
SessionKey: "session-1",
})
// backoffs = 0, 500ms, 1500ms → total ~2s. Give a generous 4s
// window for the dispatch to give up and clear the pending slot.
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
if engine.ConsumePendingRestartNotify() == nil {
// pending cleared → dispatcher exited cleanly
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("pending restart notify was not cleared after retries exhausted")
}
// TestRestartNotify_TimesOutIfPlatformNeverReady verifies the
// safety timeout: if the target platform never reaches ready,
// the notify is dropped with a warning (we don't hang forever).
// Uses a short timeout via SetPendingRestartTimeout to keep the
// test fast.
func TestRestartNotify_TimesOutIfPlatformNeverReady(t *testing.T) {
plat := &restartNotifyStub{name: "telegram"} // never marked ready
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
engine.SetPendingRestartTimeout(300 * time.Millisecond)
engine.SetPendingRestartNotify(&RestartRequest{
Platform: "telegram",
SessionKey: "session-1",
})
// Wait for the timeout path to fire. The dispatcher logs a warn
// and clears the slot. We poll until the slot is empty, with
// generous margin beyond the 300ms timeout.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if engine.ConsumePendingRestartNotify() == nil {
// slot cleared → timeout fired, dispatcher exited cleanly
if got := plat.sentTexts(); len(got) != 0 {
t.Fatalf("nothing should be sent when platform never ready, got %v", got)
}
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("pending restart notify was not cleared by safety timeout")
}
// TestRestartNotify_NilNotifyIgnored is a defensive check: passing
// a nil notify to SetPendingRestartNotify should be a no-op (does
// not panic, does not crash the engine).
func TestRestartNotify_NilNotifyIgnored(t *testing.T) {
plat := &restartNotifyStub{name: "telegram"}
engine := NewEngine("test", &stubAgent{}, []Platform{plat}, "", LangEnglish)
plat.markReady(t, engine)
engine.SetPendingRestartNotify(nil)
// Wait briefly to ensure no dispatch fires.
time.Sleep(200 * time.Millisecond)
if got := plat.sentTexts(); len(got) != 0 {
t.Fatalf("nil notify should not send anything, got %v", got)
}
if engine.ConsumePendingRestartNotify() != nil {
t.Error("nil notify should not occupy the pending slot")
}
}

View File

@@ -112,6 +112,7 @@ app_secret = "QhkMpxxxxxxxxxxxxxxxxxxxx"
# thread_isolation = true # 可选:按飞书 thread/root 隔离群聊会话
# progress_style = "legacy" # 可选legacy | compact | card
# done_emoji = "none" # 可选agent 完成回复后添加的表情回复(如 "Done");设为 "none" 可禁用
# image_batch_window_ms = 500 # 可选:连续多图合批窗口(默认 500ms详见下文
```
> 如果应用没有交互卡片权限,或后台未配置卡片回调,可将 `enable_feishu_card = false`,让所有命令统一走纯文本回复,避免卡片发送失败后用户看不到内容。
@@ -119,6 +120,7 @@ app_secret = "QhkMpxxxxxxxxxxxxxxxxxxxx"
> `progress_style = "compact"` 会把思考/工具进度合并到一条可更新消息里,减少刷屏;`legacy` 保持原有逐条发送;`card` 会使用结构化卡片(标题 + 进度块)持续更新同一条消息,观感比纯文本更清晰。
> `domain` 只影响运行时 API / WebSocket 请求地址CLI `setup/new/bind` 的引导域名仍然使用内置默认值。
> `done_emoji` 设置后agent 每次完成回复时会在用户消息上添加指定表情(如 `"Done"` → ✅)。先移除 "OnIt" 表情(如果有),再添加 done 表情。在 quiet 模式下特别有用因为飞书卡片原地更新不触发推送done 表情可以通知用户 agent 已完成。设为 `"none"` 或不配置则禁用。
> `image_batch_window_ms` 控制连续多张图片合并成一条 agent 消息的等待窗口(默认 500ms。飞书手机端一次连发多张图时每张图是独立事件cc-connect 会在窗口内将它们合并成一条多图消息再分发给 agent。如果你的网络/设备发送间隔超过 500ms 且仍被拆成多轮回复(每张图独立处理),可调高到 8001200ms如果以单图为主、希望响应更快可适当调低。设为 `0` 时回退到默认 500ms。
---

View File

@@ -37,7 +37,7 @@ Each user gets an independent session with full conversation context. Manage ses
| `/list` | List all agent sessions for this project |
| `/switch <id>` | Switch to a different session |
| `/current` | Show current session info |
| `/history [n]` | Show last n messages (default 10) |
| `/history [n]` | Show last n messages (default 10; each entry follows `[display].history_max_len`, default 1000) |
| `/usage` | Show account/model quota usage (if supported) |
| `/provider [...]` | Manage API providers |
| `/model [switch <alias>]` | List available models or switch by alias |

View File

@@ -38,7 +38,7 @@ cc-connect 完整功能使用指南。
| `/list` | 列出当前项目的会话 |
| `/switch <id>` | 切换到指定会话 |
| `/current` | 查看当前会话 |
| `/history [n]` | 查看最近 n 条消息 |
| `/history [n]` | 查看最近 n 条消息;单条长度受 `[display].history_max_len` 控制,默认 1000 |
| `/usage` | 查看账号/模型限额使用情况 |
| `/provider [...]` | 管理 API Provider |
| `/model [switch <alias>]` | 列出可用模型或按别名切换 |

View File

@@ -200,9 +200,17 @@ func (p *Platform) Start(handler core.MessageHandler) error {
default:
}
if err := p.streamClient.Start(ctx); err != nil {
slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err)
}
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("dingtalk: stream panic recovered, will reconnect",
"panic", r)
}
}()
if err := p.streamClient.Start(ctx); err != nil {
slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err)
}
}()
// Brief pause before reconnecting to avoid tight loop on persistent failures.
select {

View File

@@ -181,16 +181,55 @@ type Platform struct {
// message) caused the first (oldest create_time) image to be dropped by
// core/engine's create_time watermark (PR #1168), so only N-1 images were
// ever delivered to the agent (issue #1395).
imageBatchMu sync.Mutex
imageBatch map[string]*imageBatchEntry
imageBatchMu sync.Mutex
imageBatch map[string]*imageBatchEntry
imageBatchWindow time.Duration // quiet period before flushing a batch; 0 means use defaultImageBatchWindow
}
// imageBatchWindow is the quiet period after the last image in a session
// before the buffered batch is dispatched as a single multi-image message.
// 150ms comfortably covers Feishu mobile's batch-send timing (typically
// <100ms between images) while staying responsive for sequential single
// image sends.
const imageBatchWindow = 150 * time.Millisecond
// defaultImageBatchWindow is the quiet period after the last image in a
// session before the buffered batch is dispatched as a single multi-image
// message. 500ms covers real-world mobile sending intervals (we've observed
// ~330ms between consecutive sends from the Feishu mobile client when a user
// taps "send" repeatedly) while remaining responsive for sequential single
// image sends. Operators that need a longer or shorter window can override
// it via the platform option `image_batch_window_ms`.
const defaultImageBatchWindow = 500 * time.Millisecond
// batchWindow returns the effective image-batch coalesce window for this
// Platform. Tests and zero-initialised Platforms fall back to the default so
// they never schedule a zero-duration timer (which would fire immediately and
// defeat batching).
func (p *Platform) batchWindow() time.Duration {
if p.imageBatchWindow > 0 {
return p.imageBatchWindow
}
return defaultImageBatchWindow
}
// coerceMilliseconds normalises numeric TOML values (which decode as int64 or
// float64) into an integer millisecond count.
func coerceMilliseconds(v any) (int64, error) {
switch x := v.(type) {
case int:
return int64(x), nil
case int32:
return int64(x), nil
case int64:
return x, nil
case uint:
return int64(x), nil
case uint32:
return int64(x), nil
case uint64:
return int64(x), nil
case float32:
return int64(x), nil
case float64:
return int64(x), nil
default:
return 0, fmt.Errorf("expected number, got %T", v)
}
}
// imageBatchEntry holds image data accumulated for one session while we wait
// to see if more images are coming. timer is stopped and replaced on every
@@ -315,6 +354,18 @@ func newPlatform(name, domain string, opts map[string]any) (core.Platform, error
useInteractiveCard = v
}
imageBatchWindow := defaultImageBatchWindow
if raw, ok := opts["image_batch_window_ms"]; ok {
ms, err := coerceMilliseconds(raw)
if err != nil {
return nil, fmt.Errorf("%s: invalid image_batch_window_ms %v: %w", name, raw, err)
}
if ms < 0 {
return nil, fmt.Errorf("%s: image_batch_window_ms must be >= 0, got %d", name, ms)
}
imageBatchWindow = time.Duration(ms) * time.Millisecond
}
// Webhook mode configuration (for Lark international version)
port, _ := opts["port"].(string)
if port == "" {
@@ -358,6 +409,7 @@ func newPlatform(name, domain string, opts map[string]any) (core.Platform, error
peerBots: peerBots,
mentionMap: mentionMap,
imageBatch: make(map[string]*imageBatchEntry),
imageBatchWindow: imageBatchWindow,
}
if !useInteractiveCard {
base.self = base
@@ -1085,13 +1137,13 @@ func (p *Platform) bufferImage(sessionKey string, entry *imageBatchEntry) {
existing.createTimeMs = entry.createTimeMs
}
ref := existing
existing.timer = time.AfterFunc(imageBatchWindow, func() {
existing.timer = time.AfterFunc(p.batchWindow(), func() {
p.flushImageBatchByRef(sessionKey, ref)
})
} else {
// Start a fresh batch with its own timer.
ref := entry
entry.timer = time.AfterFunc(imageBatchWindow, func() {
entry.timer = time.AfterFunc(p.batchWindow(), func() {
p.flushImageBatchByRef(sessionKey, ref)
})
p.imageBatch[sessionKey] = entry

View File

@@ -1447,6 +1447,72 @@ func TestSendWithStatusFooter_NoFallbackOnNonMentionAt(t *testing.T) {
t.Errorf("mention: content must contain resolved mention + inline footer; got %s", gotContent)
}
}
func TestNewPlatform_ImageBatchWindow(t *testing.T) {
// Default: 500ms when option omitted.
p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
"app_id": "cli_test",
"app_secret": "secret",
})
if err != nil {
t.Fatalf("newPlatform error: %v", err)
}
fp := extractBasePlatform(p)
if fp == nil {
t.Fatal("expected *Platform or *interactivePlatform")
}
if fp.imageBatchWindow != defaultImageBatchWindow {
t.Errorf("default imageBatchWindow = %v, want %v", fp.imageBatchWindow, defaultImageBatchWindow)
}
// Custom value (int64, mirrors how TOML decodes integers).
p, err = newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
"app_id": "cli_test",
"app_secret": "secret",
"image_batch_window_ms": int64(1200),
})
if err != nil {
t.Fatalf("newPlatform with custom window error: %v", err)
}
fp = extractBasePlatform(p)
if want := 1200 * time.Millisecond; fp.imageBatchWindow != want {
t.Errorf("custom imageBatchWindow = %v, want %v", fp.imageBatchWindow, want)
}
// Zero is allowed (effectively disables coalescing) but still passes
// validation; batchWindow() will substitute the default at call time so
// timers never fire instantly.
p, err = newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
"app_id": "cli_test",
"app_secret": "secret",
"image_batch_window_ms": 0,
})
if err != nil {
t.Fatalf("newPlatform with zero window error: %v", err)
}
fp = extractBasePlatform(p)
if fp.imageBatchWindow != 0 {
t.Errorf("imageBatchWindow = %v, want 0", fp.imageBatchWindow)
}
if fp.batchWindow() != defaultImageBatchWindow {
t.Errorf("batchWindow() with zero field = %v, want %v (fallback)", fp.batchWindow(), defaultImageBatchWindow)
}
// Negative is rejected.
if _, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
"app_id": "cli_test",
"app_secret": "secret",
"image_batch_window_ms": -1,
}); err == nil {
t.Error("expected error for negative image_batch_window_ms, got nil")
}
// Non-numeric is rejected.
if _, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
"app_id": "cli_test",
"app_secret": "secret",
"image_batch_window_ms": "200",
}); err == nil {
t.Error("expected error for string image_batch_window_ms, got nil")
}
}
@@ -1455,7 +1521,7 @@ func TestSendWithStatusFooter_NoFallbackOnNonMentionAt(t *testing.T) {
// separate message event with very close create_time values. Dispatching each
// immediately caused core/engine's create_time watermark (PR #1168) to drop
// the oldest image, so the agent only saw N-1 images. After the fix, all N
// images within imageBatchWindow should be coalesced into ONE dispatched
// images within the image batch window should be coalesced into ONE dispatched
// core.Message with N image attachments.
func TestDispatchMessageCoalescesImageBatch(t *testing.T) {
const appID = "cli_batch_img"
@@ -1546,7 +1612,7 @@ func TestDispatchMessageCoalescesImageBatch(t *testing.T) {
}
// Collect dispatched messages with a generous timeout that comfortably
// exceeds imageBatchWindow (150ms) but still finishes the test quickly.
// exceeds the configured image-batch window but still finishes quickly.
var dispatched []*core.Message
deadline := time.After(2 * time.Second)
for len(dispatched) < tc.wantMsgs {
@@ -1563,7 +1629,7 @@ func TestDispatchMessageCoalescesImageBatch(t *testing.T) {
select {
case extra := <-received:
t.Fatalf("unexpected extra dispatched message %q with %d images", extra.MessageID, len(extra.Images))
case <-time.After(imageBatchWindow + 100*time.Millisecond):
case <-time.After(p.batchWindow() + 100*time.Millisecond):
}
if len(dispatched) != 1 {
@@ -1842,7 +1908,7 @@ func TestFlushImageBatchesStopsPendingTimers(t *testing.T) {
select {
case extra := <-received:
t.Fatalf("unexpected extra message after flush: %+v", extra)
case <-time.After(imageBatchWindow + 100*time.Millisecond):
case <-time.After(p.batchWindow() + 100*time.Millisecond):
}
}

View File

@@ -3,6 +3,7 @@ package slack
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
@@ -16,6 +17,16 @@ import (
// risks chat.update rate limits.
const cardUpdateMinInterval = 3 * time.Second
// slackUpdateMaxText is the conservative payload-size cap (in bytes of the
// post-conversion mrkdwn text) for `chat.update`. Slack documents the text
// parameter as ~4000 chars but enforces it server-side as a byte count and
// occasionally tightens it; once exceeded the API returns `msg_too_long`.
// We stop attempting in-place edits past this threshold and let Finalize
// deliver the full reply as a fresh message instead. 3500 leaves headroom
// for multi-byte CJK content where Go's `len()` (bytes) overshoots Slack's
// effective limit.
const slackUpdateMaxText = 3500
// slackStreamingCard aggregates one agent turn (thinking + tool steps + answer)
// into a single Slack message that updates in place — the cc-connect equivalent
// of DingTalk's AI Card. The message is posted LAZILY on the first non-empty
@@ -46,15 +57,23 @@ func (p *Platform) CreateStreamingCard(ctx context.Context, rctx any) (core.Stre
return &slackStreamingCard{client: p.client, channel: rc.channel, threadTS: rc.timestamp}, nil
}
// postFresh posts a brand-new message — the lazy first post for an unseen
// card, or the "too long for chat.update" overflow path used by Finalize.
// Caller must hold c.mu.
func (c *slackStreamingCard) postFresh(ctx context.Context, rendered string) (string, error) {
opts := []slack.MsgOption{slack.MsgOptionText(rendered, false)}
if c.threadTS != "" {
opts = append(opts, slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ThreadTimestamp: c.threadTS}))
}
_, ts, err := c.client.PostMessageContext(ctx, c.channel, opts...)
return ts, err
}
// render posts the card on first use, then edits it in place thereafter.
// Caller must hold c.mu.
func (c *slackStreamingCard) render(ctx context.Context, rendered string) error {
if c.ts == "" {
opts := []slack.MsgOption{slack.MsgOptionText(rendered, false)}
if c.threadTS != "" {
opts = append(opts, slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ThreadTimestamp: c.threadTS}))
}
_, ts, err := c.client.PostMessageContext(ctx, c.channel, opts...)
ts, err := c.postFresh(ctx, rendered)
if err != nil {
return err
}
@@ -68,6 +87,11 @@ func (c *slackStreamingCard) render(ctx context.Context, rendered string) error
// Update renders the latest aggregated content. The first post is immediate;
// subsequent edits are coalesced to ~cardUpdateMinInterval. Transient errors are
// swallowed (Finalize retries) so a blip doesn't abort the turn.
//
// Once the rendered payload exceeds slackUpdateMaxText we stop attempting
// chat.update edits (which would fail with `msg_too_long`); the existing
// streaming card stays at the last fitting snapshot and Finalize delivers the
// full reply via a fresh postMessage.
func (c *slackStreamingCard) Update(ctx context.Context, content string) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -81,7 +105,13 @@ func (c *slackStreamingCard) Update(ctx context.Context, content string) error {
if rendered == "" || rendered == c.lastSent {
return nil
}
if c.ts != "" && len(rendered) > slackUpdateMaxText {
slog.Debug("slack: streaming card update skipped: payload exceeds chat.update limit",
"size", len(rendered), "limit", slackUpdateMaxText)
return nil
}
if err := c.render(ctx, rendered); err != nil {
slog.Debug("slack: streaming card update failed (will retry on next tick / finalize)", "error", err)
return nil
}
c.lastUpdate = time.Now()
@@ -90,8 +120,11 @@ func (c *slackStreamingCard) Update(ctx context.Context, content string) error {
}
// Finalize writes the final content unconditionally (no throttle); it posts the
// card if it was never posted. On error it marks the card failed and returns the
// error so the engine falls back to a normal message.
// card if it was never posted. When the final payload exceeds the chat.update
// size limit AND a card has already been posted, we deliver the full reply as
// a fresh postMessage (the streaming card stays at its last fitting snapshot)
// instead of failing with `msg_too_long`. The engine sees success, so no
// fallback duplicate is sent.
func (c *slackStreamingCard) Finalize(ctx context.Context, content string) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -102,6 +135,20 @@ func (c *slackStreamingCard) Finalize(ctx context.Context, content string) error
if rendered == "" || rendered == c.lastSent {
return nil
}
// Long reply + card already posted: chat.update would 413 with msg_too_long;
// post a fresh message with the full content instead. This is the in-house
// equivalent of "see full reply below" — the partial streaming card stays
// visible above the new full-content message.
if c.ts != "" && len(rendered) > slackUpdateMaxText {
slog.Debug("slack: streaming card finalize switching to fresh postMessage (payload exceeds chat.update limit)",
"size", len(rendered), "limit", slackUpdateMaxText)
if _, err := c.postFresh(ctx, rendered); err != nil {
c.failed = true
return fmt.Errorf("slack: finalize streaming card: %w", err)
}
c.lastSent = rendered
return nil
}
if err := c.render(ctx, rendered); err != nil {
c.failed = true
return fmt.Errorf("slack: finalize streaming card: %w", err)

View File

@@ -0,0 +1,140 @@
package slack
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/slack-go/slack"
)
// TestStreamingCard_FinalizeFallsBackToFreshPostOnOversize covers the
// `msg_too_long` regression observed during v1.4.0-beta.1 QA: when an agent
// reply grew past Slack's chat.update text limit (~4000 bytes), Finalize
// failed with msg_too_long and the engine had to send a duplicate fallback
// message. After the fix, Finalize detects the overflow up-front and posts
// the full reply as a fresh message via chat.postMessage (which has a much
// larger 40k limit), so the engine sees success and no duplicate is sent.
func TestStreamingCard_FinalizeFallsBackToFreshPostOnOversize(t *testing.T) {
var (
postCalls int32
updateCalls int32
lastPosted string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/chat.postMessage":
atomic.AddInt32(&postCalls, 1)
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
lastPosted = r.FormValue("text")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"channel": "C1",
"ts": "1700000000.0001",
})
case "/chat.update":
atomic.AddInt32(&updateCalls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"channel": "C1",
"ts": "1700000000.0001",
"text": "edited",
})
default:
t.Fatalf("unexpected slack API path %q", r.URL.Path)
}
}))
defer srv.Close()
client := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
card := &slackStreamingCard{client: client, channel: "C1"}
// First (short) update posts the card via chat.postMessage.
if err := card.Update(context.Background(), "hello"); err != nil {
t.Fatalf("first Update: %v", err)
}
if got := atomic.LoadInt32(&postCalls); got != 1 {
t.Fatalf("postMessage calls after first update = %d, want 1", got)
}
if card.ts == "" {
t.Fatal("card.ts not set after first post")
}
// Oversized reply: bigger than slackUpdateMaxText. Finalize must NOT
// hit chat.update (would 413 with msg_too_long); it should fall back
// to a fresh postMessage with the full content.
huge := strings.Repeat("a", slackUpdateMaxText+1024)
if err := card.Finalize(context.Background(), huge); err != nil {
t.Fatalf("Finalize (oversized): %v", err)
}
if got := atomic.LoadInt32(&updateCalls); got != 0 {
t.Fatalf("chat.update calls = %d, want 0 (oversized payload should NOT touch chat.update)", got)
}
if got := atomic.LoadInt32(&postCalls); got != 2 {
t.Fatalf("postMessage calls after oversized finalize = %d, want 2 (initial + fallback)", got)
}
if !strings.HasPrefix(lastPosted, "aaaa") || len(lastPosted) < len(huge) {
t.Fatalf("oversized fallback postMessage text len=%d, want >= %d and prefixed with payload", len(lastPosted), len(huge))
}
if card.failed {
t.Fatal("card marked failed despite successful oversized fallback")
}
}
// TestStreamingCard_UpdateSkipsOversizedPayload ensures that once the rendered
// content crosses the chat.update size cap, subsequent Update() ticks become
// silent no-ops (the engine sees success, the partial card stays on the prior
// snapshot, and Finalize handles the full payload via postFresh).
func TestStreamingCard_UpdateSkipsOversizedPayload(t *testing.T) {
var (
postCalls int32
updateCalls int32
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/chat.postMessage":
atomic.AddInt32(&postCalls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "channel": "C1", "ts": "1700000000.0001"})
case "/chat.update":
atomic.AddInt32(&updateCalls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "channel": "C1", "ts": "1700000000.0001", "text": "edited"})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer srv.Close()
client := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
card := &slackStreamingCard{client: client, channel: "C1"}
// First post — sets card.ts and bypasses the throttle.
if err := card.Update(context.Background(), "hi"); err != nil {
t.Fatalf("first Update: %v", err)
}
if got := atomic.LoadInt32(&postCalls); got != 1 {
t.Fatalf("postMessage calls = %d, want 1", got)
}
// Drop the throttle so the next Update() is admissible, then feed it an
// oversized payload — Update must silently skip without touching
// chat.update.
card.lastUpdate = card.lastUpdate.Add(-2 * cardUpdateMinInterval)
huge := strings.Repeat("b", slackUpdateMaxText+100)
if err := card.Update(context.Background(), huge); err != nil {
t.Fatalf("oversized Update: %v", err)
}
if got := atomic.LoadInt32(&updateCalls); got != 0 {
t.Fatalf("chat.update calls after oversized Update = %d, want 0 (silent skip)", got)
}
}

View File

@@ -5,7 +5,7 @@
"providers": "Proveedores",
"sessions": "Sesiones",
"chat": "Chat",
"cron": "Cron",
"cron": "Tareas programadas",
"bridge": "Puente",
"skills": "Habilidades",
"system": "Sistema"

View File

@@ -5,7 +5,7 @@
"providers": "プロバイダー",
"sessions": "セッション",
"chat": "チャット",
"cron": "Cron",
"cron": "スケジュールジョブ",
"bridge": "ブリッジ",
"skills": "スキル",
"system": "システム"

View File

@@ -5,7 +5,7 @@
"providers": "제공업체",
"sessions": "세션",
"chat": "채팅",
"cron": "Cron",
"cron": "예약 작업",
"bridge": "브리지",
"skills": "스킬",
"system": "시스템"