Files
multica/server/pkg/agent/claude_test.go
Bohan Jiang 8d872b7521 fix(daemon): disable Claude AskUserQuestion in non-interactive mode (MUL-2244) (#2656)
* fix(daemon): disable Claude AskUserQuestion in non-interactive mode (MUL-2244)

GitHub #2588: when Claude Code calls its built-in AskUserQuestion tool
inside the daemon's stream-json runtime, the question never reaches the
user — there's no UI to render it — so the SDK returns an empty answer
and the agent silently "infers" and continues. From the issue's
perspective, execution looks stuck while the agent is actually charging
ahead on its own guess.

Two-part fix:

- `buildClaudeArgs` now passes `--disallowedTools AskUserQuestion` so
  the tool is not exposed to the model at all.
- The Claude-specific runtime brief tells the agent to use a `blocked`
  issue comment for genuine clarification, or to state an explicit
  assumption and proceed.

Adds a regression test that pins both: AskUserQuestion is forbidden in
CLAUDE.md and is NOT mentioned in the AGENTS.md emitted for non-Claude
providers (the tool is Claude-specific).

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

* refactor(daemon): drop CLAUDE.md AskUserQuestion guidance, rely on --disallowedTools

The --disallowedTools flag already prevents Claude from invoking
AskUserQuestion, so duplicating the rule in the runtime brief just bloats
the prompt without changing behavior. Removes the section and its
regression test; the argv-level test in pkg/agent already pins the flag.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 12:42:23 +08:00

629 lines
16 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestClaudeHandleAssistantText(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{Type: "text", Text: "Hello world"},
},
}),
}
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "Hello world" {
t.Fatalf("expected output 'Hello world', got %q", output.String())
}
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)
var output strings.Builder
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"}),
},
},
}),
}
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "" {
t.Fatalf("tool_use should not add to output, got %q", output.String())
}
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"),
},
},
}),
}
b.handleUser(msg, ch)
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"])
}
}
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: json.RawMessage(`invalid json`),
}
// Should not panic
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "" {
t.Fatalf("expected empty output for invalid JSON, got %q", output.String())
}
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 TestBuildClaudeArgsIncludesStrictMCPConfig(t *testing.T) {
t.Parallel()
args := buildClaudeArgs(ExecOptions{}, slog.Default())
expected := []string{
"-p",
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
"--strict-mcp-config",
"--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 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 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",
"CLAUDECODEX=keep-me",
}, map[string]string{"FOO": "bar"})
for _, entry := range env {
if entry == "CLAUDECODE=1" || entry == "CLAUDE_CODE_ENTRYPOINT=cli" {
t.Fatalf("expected CLAUDECODE vars to be filtered, got %v", 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)
}
if !found["FOO=bar"] {
t.Fatalf("expected extra env var 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 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)
}
// Cleanup should remove the file.
if err := os.Remove(path); err != nil {
t.Fatalf("remove temp file: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected temp file to be removed, but it still exists")
}
}
func TestResolveSessionID(t *testing.T) {
t.Parallel()
cases := []struct {
name string
requested string
emitted string
failed bool
want string
}{
{
name: "no resume requested propagates emitted",
requested: "",
emitted: "fresh-abc",
failed: false,
want: "fresh-abc",
},
{
name: "resume succeeded keeps matching id",
requested: "sess-old",
emitted: "sess-old",
failed: false,
want: "sess-old",
},
{
name: "resume succeeded but run failed mid-turn keeps id for later retry",
requested: "sess-old",
emitted: "sess-old",
failed: true,
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,
want: "",
},
{
name: "resume did not land but run succeeded keeps fresh id (defensive)",
requested: "sess-dead",
emitted: "fresh-new",
failed: false,
want: "fresh-new",
},
{
name: "no emitted id leaves result empty",
requested: "sess-old",
emitted: "",
failed: true,
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)
if got != tc.want {
t.Fatalf("resolveSessionID(%q, %q, %v) = %q, want %q",
tc.requested, tc.emitted, tc.failed, 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: drains stdin 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" +
"cat >/dev/null\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, 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")
}
}
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)
}
}