Files
multica/server/pkg/agent/claude_test.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

1304 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
)
func TestClaudeHandleAssistantText(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{Type: "text", Text: "Hello world"},
},
}),
}
output, tools := b.handleAssistant(msg, ch, make(map[string]TokenUsage))
if output != "Hello world" {
t.Fatalf("expected output 'Hello world', got %q", output)
}
if tools != 0 {
t.Fatalf("expected no tool uses, got %d", tools)
}
select {
case m := <-ch:
if m.Type != MessageText || m.Content != "Hello world" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleAssistantToolUse(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{
Type: "tool_use",
ID: "call-1",
Name: "Read",
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}),
},
},
}),
}
output, tools := b.handleAssistant(msg, ch, make(map[string]TokenUsage))
if output != "" {
t.Fatalf("tool_use should not add to output, got %q", output)
}
if tools != 1 {
t.Fatalf("expected one tool use, got %d", tools)
}
select {
case m := <-ch:
if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
if m.Input["path"] != "/tmp/foo" {
t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"])
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleUserToolResult(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "user",
Message: mustMarshal(t, claudeMessageContent{
Role: "user",
Content: []claudeContentBlock{
{
Type: "tool_result",
ToolUseID: "call-1",
Content: mustMarshal(t, "file contents here"),
},
},
}),
}
if b.handleUser(msg, ch) {
t.Fatal("did not expect async launch in ordinary tool result")
}
select {
case m := <-ch:
if m.Type != MessageToolResult || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
var written bytes.Buffer
msg := claudeSDKMessage{
Type: "control_request",
RequestID: "req-42",
Request: mustMarshal(t, claudeControlRequestPayload{
Subtype: "tool_use",
ToolName: "Bash",
Input: mustMarshal(t, map[string]any{"command": "ls"}),
}),
}
b.handleControlRequest(msg, &written)
var resp map[string]any
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "control_response" {
t.Fatalf("expected type control_response, got %v", resp["type"])
}
respInner := resp["response"].(map[string]any)
if respInner["request_id"] != "req-42" {
t.Fatalf("expected request_id req-42, got %v", respInner["request_id"])
}
innerResp := respInner["response"].(map[string]any)
if innerResp["behavior"] != "allow" {
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
}
updatedInput := innerResp["updatedInput"].(map[string]any)
if _, ok := updatedInput["run_in_background"]; ok {
t.Fatal("did not expect run_in_background to be injected into ordinary tool input")
}
}
func TestClaudeHandleControlRequestForcesBackgroundToolsForeground(t *testing.T) {
t.Parallel()
for _, toolName := range []string{"Bash", "Agent"} {
t.Run(toolName, func(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
var written bytes.Buffer
msg := claudeSDKMessage{
Type: "control_request",
RequestID: "req-42",
Request: mustMarshal(t, claudeControlRequestPayload{
Subtype: "tool_use",
ToolName: toolName,
Input: mustMarshal(t, map[string]any{
"command": "sleep 60",
"run_in_background": true,
}),
}),
}
b.handleControlRequest(msg, &written)
var resp map[string]any
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
respInner := resp["response"].(map[string]any)
innerResp := respInner["response"].(map[string]any)
if innerResp["behavior"] != "allow" {
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
}
updatedInput := innerResp["updatedInput"].(map[string]any)
if updatedInput["run_in_background"] != false {
t.Fatalf("expected run_in_background=false, got %v", updatedInput["run_in_background"])
}
if updatedInput["command"] != "sleep 60" {
t.Fatalf("expected original command to be preserved, got %v", updatedInput["command"])
}
})
}
}
func TestClaudeHandleUserDetectsAsyncLaunchedToolResult(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "user",
Message: mustMarshal(t, claudeMessageContent{
Role: "user",
Content: []claudeContentBlock{
{
Type: "tool_result",
ToolUseID: "call-1",
Content: mustMarshal(t, map[string]any{
"status": "async_launched",
"message": "background task launched",
}),
},
},
}),
}
if !b.handleUser(msg, ch) {
t.Fatal("expected async launch to be detected")
}
}
func TestClaudeHandleUserIgnoresAsyncLaunchedTextOutput(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "user",
Message: mustMarshal(t, claudeMessageContent{
Role: "user",
Content: []claudeContentBlock{
{
Type: "tool_result",
ToolUseID: "call-1",
Content: mustMarshal(t, map[string]any{
"stdout": `fixture contained {"status":"async_launched"} as plain text`,
}),
},
},
}),
}
if b.handleUser(msg, ch) {
t.Fatal("did not expect async launch to be detected in ordinary text output")
}
}
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "assistant",
Message: json.RawMessage(`invalid json`),
}
// Should not panic
output, tools := b.handleAssistant(msg, ch, make(map[string]TokenUsage))
if output != "" {
t.Fatalf("expected empty output for invalid JSON, got %q", output)
}
if tools != 0 {
t.Fatalf("expected no tool uses for invalid JSON, got %d", tools)
}
select {
case m := <-ch:
t.Fatalf("expected no message, got %+v", m)
default:
}
}
func TestTrySendDropsWhenFull(t *testing.T) {
t.Parallel()
ch := make(chan Message, 1)
// Fill the channel
trySend(ch, Message{Type: MessageText, Content: "first"})
// This should not block
trySend(ch, Message{Type: MessageText, Content: "second"})
m := <-ch
if m.Content != "first" {
t.Fatalf("expected 'first', got %q", m.Content)
}
select {
case m := <-ch:
t.Fatalf("expected empty channel, got %+v", m)
default:
}
}
func TestBuildClaudeArgsInheritsMCPByDefault(t *testing.T) {
t.Parallel()
args := buildClaudeArgs(ExecOptions{}, slog.Default())
expected := []string{
"-p",
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
"--disallowedTools", "AskUserQuestion",
}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Fatalf("expected args[%d] = %q, got %q", i, want, args[i])
}
}
}
func TestBuildClaudeArgsUsesStrictMCPForManagedConfig(t *testing.T) {
t.Parallel()
args := buildClaudeArgs(ExecOptions{McpConfig: json.RawMessage(`{}`)}, slog.Default())
if !slices.Contains(args, "--strict-mcp-config") {
t.Fatalf("managed MCP config must enable strict mode, got %v", args)
}
}
func TestArgsRequestBypassPermissions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want bool
}{
{
name: "permission mode bypass",
args: []string{"--permission-mode", "bypassPermissions"},
want: true,
},
{
name: "dangerous skip permissions",
args: []string{"--dangerously-skip-permissions"},
want: true,
},
{
name: "neither",
args: []string{"--model", "sonnet"},
want: false,
},
{
name: "permission mode default",
args: []string{"--permission-mode", "default"},
want: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := argsRequestBypassPermissions(tc.args); got != tc.want {
t.Fatalf("argsRequestBypassPermissions(%v) = %v, want %v", tc.args, got, tc.want)
}
})
}
}
func TestFilterCustomArgsBlocksProtocolFlags(t *testing.T) {
t.Parallel()
blocked := map[string]blockedArgMode{
"--output-format": blockedWithValue,
"--permission-mode": blockedWithValue,
"-p": blockedStandalone,
}
logger := slog.Default()
// Blocks flag with separate value
result := filterCustomArgs([]string{"--output-format", "text", "--model", "o3"}, blocked, logger)
if len(result) != 2 || result[0] != "--model" || result[1] != "o3" {
t.Fatalf("expected [--model o3], got %v", result)
}
// Blocks flag=value form
result = filterCustomArgs([]string{"--permission-mode=plan", "--verbose"}, blocked, logger)
if len(result) != 1 || result[0] != "--verbose" {
t.Fatalf("expected [--verbose], got %v", result)
}
// Blocks standalone short flags without consuming next arg
result = filterCustomArgs([]string{"-p", "--max-turns", "10"}, blocked, logger)
if len(result) != 2 || result[0] != "--max-turns" || result[1] != "10" {
t.Fatalf("expected [--max-turns 10], got %v", result)
}
// Passes through non-blocked args
result = filterCustomArgs([]string{"--model", "o3", "--max-turns", "50"}, blocked, logger)
if len(result) != 4 {
t.Fatalf("expected all 4 args to pass through, got %v", result)
}
// Handles nil blocked map
result = filterCustomArgs([]string{"--anything"}, nil, logger)
if len(result) != 1 {
t.Fatalf("expected args to pass through with nil blocked map, got %v", result)
}
// Handles empty args
result = filterCustomArgs(nil, blocked, logger)
if result != nil {
t.Fatalf("expected nil for nil input, got %v", result)
}
}
func TestFilterCustomArgsStripsShellQuotes(t *testing.T) {
t.Parallel()
logger := slog.Default()
// Single-quoted inline value: --deny-tool='write' → --deny-tool=write
result := filterCustomArgs([]string{"--deny-tool='write'"}, nil, logger)
if len(result) != 1 || result[0] != "--deny-tool=write" {
t.Fatalf("expected [--deny-tool=write], got %v", result)
}
// Double-quoted inline value: --deny-tool="write" → --deny-tool=write
result = filterCustomArgs([]string{`--deny-tool="write"`}, nil, logger)
if len(result) != 1 || result[0] != "--deny-tool=write" {
t.Fatalf("expected [--deny-tool=write], got %v", result)
}
// Standalone quoted value: 'write' → write
result = filterCustomArgs([]string{"'write'"}, nil, logger)
if len(result) != 1 || result[0] != "write" {
t.Fatalf("expected [write], got %v", result)
}
// Non-flag assignments may use quotes semantically (for example Codex
// `-c model="o3"`), so they must survive unchanged.
result = filterCustomArgs([]string{`model="o3"`}, nil, logger)
if len(result) != 1 || result[0] != `model="o3"` {
t.Fatalf("expected [model=\"o3\"], got %v", result)
}
// Unquoted arg passes through unchanged
result = filterCustomArgs([]string{"--deny-tool=write"}, nil, logger)
if len(result) != 1 || result[0] != "--deny-tool=write" {
t.Fatalf("expected [--deny-tool=write], got %v", result)
}
// Mismatched quotes are not stripped
result = filterCustomArgs([]string{"--flag='val\""}, nil, logger)
if len(result) != 1 || result[0] != "--flag='val\"" {
t.Fatalf("mismatched quotes should not be stripped, got %v", result)
}
// Blocked flag with quoted value: the blocking still fires, the unquoted
// form passes the flag-match, and the arg is dropped.
blocked := map[string]blockedArgMode{"--output-format": blockedWithValue}
result = filterCustomArgs([]string{"--output-format='json'", "--verbose"}, blocked, logger)
if len(result) != 1 || result[0] != "--verbose" {
t.Fatalf("quoted blocked flag should still be dropped, got %v", result)
}
}
func TestBuildClaudeArgsPassesThroughCustomArgs(t *testing.T) {
t.Parallel()
args := buildClaudeArgs(ExecOptions{
CustomArgs: []string{"--max-turns", "50", "--verbose"},
}, slog.Default())
// Custom args should appear at the end
found := 0
for i, a := range args {
if a == "--max-turns" && i+1 < len(args) && args[i+1] == "50" {
found++
}
}
if found != 1 {
t.Fatalf("expected --max-turns 50 in args: %v", args)
}
}
func TestBuildClaudeArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()
args := buildClaudeArgs(ExecOptions{
CustomArgs: []string{"--output-format", "text", "--model", "o3"},
}, slog.Default())
// --output-format text should be stripped
for _, a := range args[len(args)-2:] {
if a == "text" {
// "text" should not be in the last args since --output-format was blocked
// The actual --output-format stream-json is earlier in the list
}
}
// --model o3 should pass through
foundModel := false
for i, a := range args {
if a == "--model" && i+1 < len(args) && args[i+1] == "o3" {
foundModel = true
}
// Verify no duplicate --output-format with value "text"
if a == "--output-format" && i+1 < len(args) && args[i+1] == "text" {
t.Fatalf("blocked --output-format text should have been filtered: %v", args)
}
}
if !foundModel {
t.Fatalf("expected --model o3 in args but it was missing: %v", args)
}
}
func TestBuildClaudeInputEncodesUserMessage(t *testing.T) {
t.Parallel()
data, err := buildClaudeInput("say pong")
if err != nil {
t.Fatalf("buildClaudeInput: %v", err)
}
if len(data) == 0 || data[len(data)-1] != '\n' {
t.Fatalf("expected newline-terminated payload, got %q", data)
}
var payload map[string]any
if err := json.Unmarshal(bytes.TrimSpace(data), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload["type"] != "user" {
t.Fatalf("expected type user, got %v", payload["type"])
}
message, ok := payload["message"].(map[string]any)
if !ok {
t.Fatalf("expected message object, got %T", payload["message"])
}
if message["role"] != "user" {
t.Fatalf("expected role user, got %v", message["role"])
}
content, ok := message["content"].([]any)
if !ok || len(content) != 1 {
t.Fatalf("expected one content block, got %v", message["content"])
}
block, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("expected content block object, got %T", content[0])
}
if block["type"] != "text" || block["text"] != "say pong" {
t.Fatalf("unexpected content block: %v", block)
}
}
func TestMergeEnvFiltersClaudeCodeVars(t *testing.T) {
t.Parallel()
env := mergeEnv([]string{
"PATH=/usr/bin",
"CLAUDECODE=1",
"CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/opt/claude",
"CLAUDE_CODE_SESSION_ID=abc123",
"CLAUDE_CODE_SSE_PORT=9999",
"CLAUDECODEX=keep-me",
"CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe",
"CLAUDE_CODE_USE_BEDROCK=1",
"CLAUDE_CODE_TMPDIR=/custom/tmp",
"MULTICA_LLM_API_KEY=daemon-secret",
"MULTICA_SERVER_URL=https://daemon.example",
}, map[string]string{
"FOO": "bar",
"MULTICA_SERVER_URL": "https://task.example",
"MULTICA_TOKEN": "mat_task",
})
// Internal runtime/session markers must be stripped so the child does not
// inherit the parent's identity or transport.
filteredOut := []string{
"CLAUDECODE=1",
"CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/opt/claude",
"CLAUDE_CODE_SESSION_ID=abc123",
"CLAUDE_CODE_SSE_PORT=9999",
"MULTICA_LLM_API_KEY=daemon-secret",
"MULTICA_SERVER_URL=https://daemon.example",
}
for _, entry := range env {
for _, banned := range filteredOut {
if entry == banned {
t.Fatalf("expected internal Claude Code marker %q to be filtered, got %v", banned, env)
}
}
}
found := map[string]bool{}
for _, entry := range env {
found[entry] = true
}
if !found["PATH=/usr/bin"] {
t.Fatalf("expected PATH to be preserved, got %v", env)
}
if !found["CLAUDECODEX=keep-me"] {
t.Fatalf("expected unrelated env vars to be preserved, got %v", env)
}
// User-facing CLAUDE_CODE_* config must reach the child — stripping
// CLAUDE_CODE_GIT_BASH_PATH is what broke Claude Code on Windows (#3671).
if !found["CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe"] {
t.Fatalf("expected CLAUDE_CODE_GIT_BASH_PATH to be preserved, got %v", env)
}
if !found["CLAUDE_CODE_USE_BEDROCK=1"] {
t.Fatalf("expected CLAUDE_CODE_USE_BEDROCK to be preserved, got %v", env)
}
// CLAUDE_CODE_TMPDIR is a documented user-configurable temp-dir override, not
// an internal per-session marker, so it must reach the child.
if !found["CLAUDE_CODE_TMPDIR=/custom/tmp"] {
t.Fatalf("expected CLAUDE_CODE_TMPDIR to be preserved, got %v", env)
}
if !found["FOO=bar"] {
t.Fatalf("expected extra env var to be appended, got %v", env)
}
if !found["MULTICA_SERVER_URL=https://task.example"] || !found["MULTICA_TOKEN=mat_task"] {
t.Fatalf("expected explicit task MULTICA_* values to be appended, got %v", env)
}
}
func TestBuildEnvAppendsExtras(t *testing.T) {
t.Parallel()
env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"})
found := 0
for _, e := range env {
if e == "FOO=bar" || e == "BAZ=qux" {
found++
}
}
if found != 2 {
t.Fatalf("expected 2 extra env vars, found %d", found)
}
}
func TestBuildEnvNilExtras(t *testing.T) {
t.Parallel()
env := buildEnv(nil)
if len(env) == 0 {
t.Fatal("expected at least system env vars")
}
}
func TestEnvHasSandbox(t *testing.T) {
t.Parallel()
cases := []struct {
name string
env []string
want bool
}{
{name: "one", env: []string{"IS_SANDBOX=1"}, want: true},
{name: "true", env: []string{"IS_SANDBOX=true"}, want: true},
{name: "yes", env: []string{"IS_SANDBOX=yes"}, want: true},
{name: "on", env: []string{"IS_SANDBOX=on"}, want: true},
{name: "zero", env: []string{"IS_SANDBOX=0"}, want: false},
{name: "false", env: []string{"IS_SANDBOX=false"}, want: false},
{name: "empty", env: []string{"IS_SANDBOX="}, want: false},
{name: "absent", env: []string{"PATH=/usr/bin"}, want: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := envHasSandbox(tc.env); got != tc.want {
t.Fatalf("envHasSandbox(%v) = %v, want %v", tc.env, got, tc.want)
}
})
}
}
func TestClaudeRootSudoPreflight(t *testing.T) {
t.Parallel()
t.Run("sandbox bypass allowed", func(t *testing.T) {
t.Parallel()
err := claudeRootSudoPreflight(
[]string{"--permission-mode", "bypassPermissions"},
[]string{"IS_SANDBOX=1"},
)
if err != nil {
t.Fatalf("expected sandboxed bypass to pass preflight, got %v", err)
}
})
t.Run("non bypass allowed", func(t *testing.T) {
t.Parallel()
err := claudeRootSudoPreflight(
[]string{"--permission-mode", "default"},
nil,
)
if err != nil {
t.Fatalf("expected non-bypass args to pass preflight, got %v", err)
}
})
t.Run("root bypass without sandbox errors", func(t *testing.T) {
t.Parallel()
if os.Geteuid() != 0 {
t.Skip("root-only preflight assertion")
}
err := claudeRootSudoPreflight(
[]string{"--permission-mode", "bypassPermissions"},
nil,
)
if err == nil {
t.Fatal("expected root bypass without sandbox to fail preflight")
}
if !strings.Contains(err.Error(), "IS_SANDBOX") || !strings.Contains(err.Error(), "non-root") {
t.Fatalf("expected actionable root guidance, got %q", err.Error())
}
})
}
func TestBuildClaudeArgsBlocksMcpConfig(t *testing.T) {
t.Parallel()
// --mcp-config is hardcoded by the daemon — it must not be overridable via custom_args.
args := buildClaudeArgs(ExecOptions{
CustomArgs: []string{"--mcp-config", "/tmp/evil.json", "--model", "o3"},
}, slog.Default())
for i, a := range args {
if a == "--mcp-config" {
t.Fatalf("--mcp-config should be blocked from custom_args, found at index %d: %v", i, args)
}
if a == "/tmp/evil.json" {
t.Fatalf("--mcp-config value should be consumed when blocking, but found it at index %d: %v", i, args)
}
}
// Non-blocked args should still pass through.
foundModel := false
for i, a := range args {
if a == "--model" && i+1 < len(args) && args[i+1] == "o3" {
foundModel = true
}
}
if !foundModel {
t.Fatalf("expected --model o3 in args after blocking --mcp-config: %v", args)
}
}
func TestWriteMcpConfigToTemp(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{"mcpServers":{"test":{"command":"echo","args":["hello"]}}}`)
path, err := writeMcpConfigToTemp(raw)
if err != nil {
t.Fatalf("writeMcpConfigToTemp: %v", err)
}
// File should exist and contain exactly the raw JSON.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp file %s: %v", path, err)
}
if !bytes.Equal(data, []byte(raw)) {
t.Fatalf("expected %s, got %s", raw, data)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat temp file %s: %v", path, err)
}
if info.Mode().Perm()&0o077 != 0 {
t.Fatalf("temp MCP file permissions = %o, want no group/other access", info.Mode().Perm())
}
}
// Cleanup should remove the temp directory and every related sidecar file.
cleanupMcpConfigTemp(path)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected temp file to be removed, but it still exists")
}
}
func TestResumeWasRejected(t *testing.T) {
t.Parallel()
cases := []struct {
name string
requested string
emitted string
failed bool
texts []string
want bool
}{
{
// Verbatim from multica-ai/multica#5704 (Claude Code 2.1.207,
// zh-CN). This is the failure the whole signal exists for: the
// session belongs to the account the user switched away from, and
// claude echoes the requested id back rather than a fresh one.
name: "account binding 400 echoing the requested id is a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"400 此 session 已绑定另外的ai账号请执行 /new 开启新 session"},
want: true,
},
{
name: "no conversation found on stderr is a rejection",
requested: "sess-dead",
emitted: "sess-dead",
failed: true,
texts: []string{"No conversation found with session ID: sess-dead"},
want: true,
},
{
name: "a different emitted id means the resume did not land",
requested: "sess-dead",
emitted: "fresh-new",
failed: true,
want: true,
},
// Failures a fresh session cannot cure. None of these may set the
// flag: doing so would throw away a session the platform's own retry
// is expected to resume.
{
name: "mid-response network drop is not a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"API Error: Connection closed mid-response"},
want: false,
},
{
name: "rate limit is not a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"API Error: 429 rate_limit_error: rate limit exceeded"},
want: false,
},
{
name: "overloaded is not a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"API Error: 529 overloaded_error"},
want: false,
},
{
name: "quota exhaustion is not a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"API Error: 402 credit balance is too low"},
want: false,
},
{
// Reported alongside the 400 in #5704 and easy to conflate with
// it. This one needs a re-login, not a new session.
name: "revoked oauth token is not a rejection",
requested: "sess-old",
emitted: "sess-old",
failed: true,
texts: []string{"401 Invalid authentication credentials: OAuth access token has been revoked"},
want: false,
},
{
name: "a run that succeeded is never a rejection",
requested: "sess-old",
emitted: "fresh-new",
failed: false,
texts: []string{"No conversation found with session ID: sess-old"},
want: false,
},
{
name: "no resume requested is never a rejection",
requested: "",
emitted: "fresh-abc",
failed: true,
texts: []string{"No conversation found with session ID: whatever"},
want: false,
},
{
// A failure before claude emitted anything. Empty is not
// evidence of anything, which is why the daemon stopped reading
// an empty SessionID as "the resume was rejected".
name: "failure with no emitted id and no matching text is not a rejection",
requested: "sess-old",
emitted: "",
failed: true,
texts: []string{"exit status 1"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := resumeWasRejected(tc.requested, tc.emitted, tc.failed, tc.texts...)
if got != tc.want {
t.Fatalf("resumeWasRejected(%q, %q, %v, %q) = %v, want %v",
tc.requested, tc.emitted, tc.failed, tc.texts, got, tc.want)
}
})
}
}
func TestResolveSessionID(t *testing.T) {
t.Parallel()
cases := []struct {
name string
requested string
emitted string
failed bool
stderr string
want string
}{
{
name: "no resume requested propagates emitted",
requested: "",
emitted: "fresh-abc",
failed: false,
stderr: "",
want: "fresh-abc",
},
{
name: "resume succeeded keeps matching id",
requested: "sess-old",
emitted: "sess-old",
failed: false,
stderr: "",
want: "sess-old",
},
{
name: "resume succeeded but run failed mid-turn keeps id for later retry",
requested: "sess-old",
emitted: "sess-old",
failed: true,
stderr: "",
want: "sess-old",
},
{
name: "resume did not land and run failed clears id so daemon fallback fires",
requested: "sess-dead",
emitted: "fresh-new",
failed: true,
stderr: "",
want: "",
},
{
name: "resume not found stderr clears matching id so daemon fallback fires",
requested: "sess-dead",
emitted: "sess-dead",
failed: true,
stderr: "No conversation found with session ID: sess-dead",
want: "",
},
{
name: "resume did not land but run succeeded keeps fresh id (defensive)",
requested: "sess-dead",
emitted: "fresh-new",
failed: false,
stderr: "No conversation found with session ID: sess-dead",
want: "fresh-new",
},
{
name: "no emitted id leaves result empty",
requested: "sess-old",
emitted: "",
failed: true,
stderr: "",
want: "",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := resolveSessionID(tc.requested, tc.emitted, tc.failed, tc.stderr)
if got != tc.want {
t.Fatalf("resolveSessionID(%q, %q, %v, %q) = %q, want %q",
tc.requested, tc.emitted, tc.failed, tc.stderr, got, tc.want)
}
})
}
}
func TestClaudeExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Fake claude binary: reads the initial stdin frame so writeClaudeInput
// succeeds, writes a canonical V8-abort line to stderr, then exits
// non-zero before emitting any stream-json to stdout. This is the exact
// failure mode that motivated PR #1674 — without sampling stderrBuf.Tail()
// after cmd.Wait() returns, Result.Error would be a useless
// "exit status 3".
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"IFS= read -r _\n" +
"echo \"FATAL ERROR: V8 abort: assertion failed\" >&2\n" +
"exit 3\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{
ExecutablePath: fakePath,
Env: map[string]string{"IS_SANDBOX": "1"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
// Drain message stream so the lifecycle goroutine can progress.
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "claude exited with error") {
t.Fatalf("expected error to mention exit, got %q", result.Error)
}
if !strings.Contains(result.Error, "V8 abort: assertion failed") {
t.Fatalf("expected error to include stderr hint, got %q", result.Error)
}
if !strings.Contains(result.Error, "claude stderr:") {
t.Fatalf("expected stderr label in error, got %q", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// End-to-end through the backend rather than the predicate alone: a fake
// claude reproduces the account-switch guardrail from #5704 — it echoes the
// resumed session id back on the init frame, then fails the turn with the
// 400. The result must carry ResumeRejected so the daemon retries fresh, and
// must not persist the dead session id.
func TestClaudeExecuteReportsResumeRejectedOnAccountBinding(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"IFS= read -r _\n" +
`echo '{"type":"system","subtype":"init","session_id":"sess-old"}'` + "\n" +
`echo '{"type":"result","subtype":"error","is_error":true,"session_id":"sess-old","result":"400 此 session 已绑定另外的ai账号请执行 /new 开启新 session"}'` + "\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{
ExecutablePath: fakePath,
Env: map[string]string{"IS_SANDBOX": "1"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt", ExecOptions{
ResumeSessionID: "sess-old",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q", result.Status)
}
if !result.ResumeRejected {
t.Fatalf("expected ResumeRejected for the account-binding 400, got error=%q", result.Error)
}
// The session belongs to the previous account; keeping it would make
// every later dispatch on this (agent, workdir) fail identically.
if result.SessionID != "" {
t.Fatalf("expected the dead session id to be dropped, got %q", result.SessionID)
}
// The provider's own wording has to survive so a matcher miss on some
// other locale is diagnosable from the failure itself.
if !strings.Contains(result.Error, "已绑定另外") {
t.Fatalf("expected the raw provider error to be preserved, got %q", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// The counterpart: a resumed run that dies from a transient network fault
// must NOT be reported as a rejected resume, and must keep its session id so
// the platform's resume-safe retry can continue the conversation.
func TestClaudeExecuteKeepsSessionOnNetworkFailure(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"IFS= read -r _\n" +
`echo '{"type":"system","subtype":"init","session_id":"sess-old"}'` + "\n" +
`echo '{"type":"result","subtype":"error","is_error":true,"session_id":"sess-old","result":"API Error: Connection closed mid-response"}'` + "\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{
ExecutablePath: fakePath,
Env: map[string]string{"IS_SANDBOX": "1"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt", ExecOptions{
ResumeSessionID: "sess-old",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.ResumeRejected {
t.Fatal("a network drop must not be reported as a rejected resume")
}
if result.SessionID != "sess-old" {
t.Fatalf("expected the session to survive a network failure, got %q", result.SessionID)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestClaudeExecuteRecordsResultModelUsage(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"IFS= read -r _\n" +
"printf '%s\\n' '{\"type\":\"system\",\"session_id\":\"sess-result-usage\"}'\n" +
"printf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"session_id\":\"sess-result-usage\",\"result\":\"done\",\"modelUsage\":{\"zhipu/coding-plan\":{\"inputTokens\":123,\"outputTokens\":45,\"cacheReadInputTokens\":7,\"cacheCreationInputTokens\":11,\"costUSD\":0.01}}}'\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{
ExecutablePath: fakePath,
Env: map[string]string{"IS_SANDBOX": "1"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
usage, ok := result.Usage["zhipu/coding-plan"]
if !ok {
t.Fatalf("expected usage for zhipu/coding-plan, got %#v", result.Usage)
}
if usage.InputTokens != 123 || usage.OutputTokens != 45 || usage.CacheReadTokens != 7 || usage.CacheWriteTokens != 11 {
t.Fatalf("unexpected usage: %+v", usage)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return data
}
func TestBuildClaudeArgsExtraArgsBeforeCustomArgsAndFiltersBoth(t *testing.T) {
args := buildClaudeArgs(ExecOptions{
ExtraArgs: []string{"--output-format", "text", "--max-budget-usd", "1.00"},
CustomArgs: []string{"--max-budget-usd", "2.00", "--permission-mode", "plan"},
}, slog.Default())
joined := strings.Join(args, " ")
if strings.Contains(joined, "--output-format text") || strings.Contains(joined, "--permission-mode plan") {
t.Fatalf("blocked args should be filtered from both layers: %v", args)
}
extraIdx, customIdx := -1, -1
for i := 0; i+1 < len(args); i++ {
if args[i] == "--max-budget-usd" && args[i+1] == "1.00" {
extraIdx = i
}
if args[i] == "--max-budget-usd" && args[i+1] == "2.00" {
customIdx = i
}
}
if extraIdx == -1 || customIdx == -1 || extraIdx > customIdx {
t.Fatalf("expected extra args before custom args, got %v", args)
}
}
func TestBuildClaudeArgsManagedSkillSettingsWins(t *testing.T) {
args := buildClaudeArgs(ExecOptions{
ClaudeSettingsPath: "/tmp/multica-claude-settings.json",
ExtraArgs: []string{"--settings", "/tmp/default.json"},
CustomArgs: []string{"--settings=/tmp/agent.json", "--max-turns", "7"},
}, slog.Default())
joined := strings.Join(args, " ")
if strings.Contains(joined, "default.json") || strings.Contains(joined, "agent.json") {
t.Fatalf("competing settings args were not filtered: %v", args)
}
if !strings.Contains(joined, "--settings /tmp/multica-claude-settings.json") {
t.Fatalf("managed settings missing: %v", args)
}
if !strings.Contains(joined, "--max-turns 7") {
t.Fatalf("unrelated custom arg was dropped: %v", args)
}
}