mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* 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>
1304 lines
37 KiB
Go
1304 lines
37 KiB
Go
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)
|
||
}
|
||
}
|