mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-10 01:17:30 +08:00
Merge branch 'main' into feat/outbound-bot-mention-resolution
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ".",
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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/.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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..."}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) / 每条历史记录最大字符数(默认 1000;0 表示不截断)
|
||||
# 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. 800–1200ms) 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 且仍被拆成多轮,可调高(如 800–1200ms);如果
|
||||
# # 只是单图发送,希望响应更快,也可以适当调低。
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
175
core/api_test.go
175
core/api_test.go
@@ -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
80
core/cmdopts.go
Normal 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
319
core/cmdopts_test.go
Normal 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
|
||||
}
|
||||
599
core/engine.go
599
core/engine.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
262
core/restart_notify_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 且仍被拆成多轮回复(每张图独立处理),可调高到 800–1200ms;如果以单图为主、希望响应更快,可适当调低。设为 `0` 时回退到默认 500ms。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>]` | 列出可用模型或按别名切换 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
140
platform/slack/streaming_card_test.go
Normal file
140
platform/slack/streaming_card_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"providers": "Proveedores",
|
||||
"sessions": "Sesiones",
|
||||
"chat": "Chat",
|
||||
"cron": "Cron",
|
||||
"cron": "Tareas programadas",
|
||||
"bridge": "Puente",
|
||||
"skills": "Habilidades",
|
||||
"system": "Sistema"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"providers": "プロバイダー",
|
||||
"sessions": "セッション",
|
||||
"chat": "チャット",
|
||||
"cron": "Cron",
|
||||
"cron": "スケジュールジョブ",
|
||||
"bridge": "ブリッジ",
|
||||
"skills": "スキル",
|
||||
"system": "システム"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"providers": "제공업체",
|
||||
"sessions": "세션",
|
||||
"chat": "채팅",
|
||||
"cron": "Cron",
|
||||
"cron": "예약 작업",
|
||||
"bridge": "브리지",
|
||||
"skills": "스킬",
|
||||
"system": "시스템"
|
||||
|
||||
Reference in New Issue
Block a user