package agent import ( "context" "encoding/json" "log/slog" "os" "path/filepath" "strings" "testing" "time" ) func TestNewReturnsKiroBackend(t *testing.T) { t.Parallel() b, err := New("kiro", Config{ExecutablePath: "/nonexistent/kiro-cli"}) if err != nil { t.Fatalf("New(kiro) error: %v", err) } if _, ok := b.(*kiroBackend); !ok { t.Fatalf("expected *kiroBackend, got %T", b) } } func TestKiroToolNameFromTitle(t *testing.T) { t.Parallel() tests := []struct { title string want string }{ {"Read file: /tmp/foo.go", "read_file"}, {"Write: /tmp/bar.go", "write_file"}, {"Patch: /tmp/x", "edit_file"}, {"Shell: ls -la", "terminal"}, {"Run command: pwd", "terminal"}, {"grep", "search_files"}, {"Glob: *.go", "glob"}, {"Code", "code"}, {"Todo List", "todo_write"}, {"Custom Thing", "custom_thing"}, {"", ""}, } for _, tt := range tests { got := kiroToolNameFromTitle(tt.title) if got != tt.want { t.Errorf("kiroToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want) } } } func fakeKiroACPScript() string { return `#!/bin/sh if [ -n "$KIRO_ARGS_FILE" ]; then for arg in "$@"; do printf '%s\n' "$arg" >> "$KIRO_ARGS_FILE" done fi while IFS= read -r line; do if [ -n "$KIRO_REQUESTS_FILE" ]; then printf '%s\n' "$line" >> "$KIRO_REQUESTS_FILE" fi id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"currentModelId":"auto","availableModels":[{"modelId":"auto","name":"auto"}]}}}\n' "$id" ;; *'"method":"session/load"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"history should be ignored"}}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"UsageUpdate","usage":{"inputTokens":1000,"outputTokens":1000,"cachedReadTokens":100}}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCall","toolCallId":"tc-current","name":"Shell","status":"pending","parameters":{"command":"echo replay"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; *'"method":"session/resume"'*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"session/resume should not be used for kiro"}}\n' "$id" ;; *'"method":"session/set_model"'*) case "$line" in *bogus-model*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id" exit 0 ;; *) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; esac ;; *'"method":"session/prompt"'*) case "$line" in *'"content":'*) ;; *) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id" exit 0 ;; esac case "$line" in *'"prompt":'*) ;; *) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id" exit 0 ;; esac printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCallUpdate","toolCallId":"tc-current","status":"completed","name":"Shell","parameters":{"command":"echo current"},"output":"current tool output\\n"}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"loaded"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":1}}}\n' "$id" exit 0 ;; esac done ` } func TestKiroBackendSetModelFailureFailsTask(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new kiro backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ Model: "bogus-model", 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 (error=%q)", result.Status, result.Error) } if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) { t.Errorf("expected error to name the requested model, got %q", result.Error) } if !strings.Contains(result.Error, "model not available") { t.Errorf("expected error to surface upstream message, got %q", result.Error) } if result.SessionID != "ses_new" { t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } } func fakeKiroACPGoalCompleteCloseErrorScript(goalStatus string) string { return `#!/bin/sh while IFS= read -r line; do id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_goal_done"}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_goal_done","update":{"type":"ToolCall","toolCallId":"tc-goal","name":"goal_complete","status":"pending","parameters":{}}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_goal_done","update":{"type":"ToolCallUpdate","toolCallId":"tc-goal","status":"` + goalStatus + `","name":"goal_complete","output":"ok"}}}\n' printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"Kiro failed to generate a response"}}\n' "$id" exit 0 ;; esac done ` } func TestKiroBackendTreatsGoalCompleteCloseErrorAsCompleted(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("completed"))) backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new kiro 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") } if result.Status != "completed" { t.Fatalf("expected status=completed after goal_complete close error, got %q (error=%q)", result.Status, result.Error) } if result.Error != "" { t.Fatalf("expected close-handshake error to be suppressed, got %q", result.Error) } if result.SessionID != "ses_goal_done" { t.Fatalf("session id = %q, want ses_goal_done", result.SessionID) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } } func TestKiroBackendDoesNotCompleteAfterFailedGoalComplete(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("failed"))) backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new kiro 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") } if result.Status != "failed" { t.Fatalf("expected status=failed after failed goal_complete, got %q (error=%q)", result.Status, result.Error) } if !strings.Contains(result.Error, "Kiro failed to generate a response") { t.Fatalf("expected original prompt error to be preserved, got %q", result.Error) } if result.SessionID != "ses_goal_done" { t.Fatalf("session id = %q, want ses_goal_done", result.SessionID) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } } // fakeKiroACPStaleLoadSetModelScript impersonates kiro when a resumed // session is gone and the caller picked a model: session/load returns // an empty result (so the requested id is kept), then // session/set_model rejects the unknown session with kiro's observed // wording — -32603 with "No session found with id ..." in data. func fakeKiroACPStaleLoadSetModelScript() string { return `#!/bin/sh while IFS= read -r line; do id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id" ;; *'"method":"session/load"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; *'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Internal error","data":"No session found with id ses_stale"}}\n' "$id" exit 0 ;; esac done ` } // TestKiroBackendClearsSessionIDWhenSetModelSessionNotFound pins the // set_model sibling of the resumed-session fix: with a model override, // session/set_model runs before session/prompt, so a dead resumed // session surfaces there. The Result must carry an empty SessionID so // the daemon's fresh-session retry (gated on SessionID == "") fires. func TestKiroBackendClearsSessionIDWhenSetModelSessionNotFound(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPStaleLoadSetModelScript())) backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new kiro 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, ResumeSessionID: "ses_stale", Model: "auto", }) 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 (error=%q)", result.Status, result.Error) } if !strings.Contains(result.Error, `could not switch to model "auto"`) { t.Errorf("expected error to name the requested model, got %q", result.Error) } if result.SessionID != "" { t.Errorf("expected empty session id so the daemon's fresh-session retry fires, got %q", result.SessionID) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } } func TestKiroBackendInvokesACPWithTrustAllTools(t *testing.T) { t.Parallel() tempDir := t.TempDir() argsFile := filepath.Join(tempDir, "argv.txt") fakePath := filepath.Join(tempDir, "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) backend, err := New("kiro", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"KIRO_ARGS_FILE": argsFile}, }) if err != nil { t.Fatalf("new kiro backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ Model: "bogus-model", Timeout: 5 * time.Second, CustomArgs: []string{"acp", "--trust-tools", "shell", "-a", "--agent", "multica"}, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() <-session.Result raw, err := os.ReadFile(argsFile) if err != nil { t.Fatalf("read args file: %v", err) } lines := strings.Split(strings.TrimSpace(string(raw)), "\n") wantPrefix := []string{"acp", "--trust-all-tools"} if len(lines) < len(wantPrefix) { t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines) } for i, want := range wantPrefix { if lines[i] != want { t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines) } } for _, blocked := range []string{"--trust-tools", "shell", "-a"} { for _, got := range lines { if got == blocked { t.Errorf("protocol-critical custom arg %q was not filtered: %q", blocked, lines) } } } if strings.Join(lines, "\n") != strings.Join([]string{"acp", "--trust-all-tools", "--agent", "multica"}, "\n") { t.Errorf("unexpected argv after filtering: %q", lines) } } func TestKiroBackendUsesSessionLoadForResume(t *testing.T) { t.Parallel() tempDir := t.TempDir() requestsFile := filepath.Join(tempDir, "requests.jsonl") fakePath := filepath.Join(tempDir, "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) backend, err := New("kiro", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"KIRO_REQUESTS_FILE": requestsFile}, }) if err != nil { t.Fatalf("new kiro backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "continue", ExecOptions{ ResumeSessionID: "ses_existing", Timeout: 5 * time.Second, }) if err != nil { t.Fatalf("execute: %v", err) } var messages []Message messagesDone := make(chan struct{}) go func() { defer close(messagesDone) for msg := range session.Messages { messages = append(messages, msg) } }() result := <-session.Result <-messagesDone if result.Status != "completed" { t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error) } if result.Output != "loaded" { t.Fatalf("output = %q, want loaded", result.Output) } if usage := result.Usage["unknown"]; usage.InputTokens != 2 || usage.OutputTokens != 1 || usage.CacheReadTokens != 0 { t.Fatalf("usage = %+v, want input=2 output=1 cache_read=0", usage) } if len(messages) != 3 { t.Fatalf("messages = %+v, want current tool use, tool result, and text only", messages) } if messages[0].Type != MessageToolUse { t.Fatalf("messages[0].Type = %v, want MessageToolUse", messages[0].Type) } if messages[0].Tool != "terminal" { t.Fatalf("messages[0].Tool = %q, want terminal", messages[0].Tool) } if command, _ := messages[0].Input["command"].(string); command != "echo current" { t.Fatalf("messages[0].Input[command] = %q, want echo current", command) } if messages[1].Type != MessageToolResult { t.Fatalf("messages[1].Type = %v, want MessageToolResult", messages[1].Type) } if messages[1].Output != "current tool output\n" { t.Fatalf("messages[1].Output = %q, want current tool output", messages[1].Output) } if messages[2].Type != MessageText || messages[2].Content != "loaded" { t.Fatalf("messages[2] = %+v, want text loaded", messages[2]) } if result.SessionID != "ses_existing" { t.Fatalf("session id = %q, want ses_existing", result.SessionID) } raw, err := os.ReadFile(requestsFile) if err != nil { t.Fatalf("read requests file: %v", err) } requests := string(raw) if !strings.Contains(requests, `"method":"session/load"`) { t.Fatalf("expected session/load request, got:\n%s", requests) } if strings.Contains(requests, `"method":"session/resume"`) { t.Fatalf("kiro backend must not call session/resume, got:\n%s", requests) } if !strings.Contains(requests, `"mcpServers":[]`) { t.Fatalf("session/load must include mcpServers, got:\n%s", requests) } // Kiro docs use content, but Kiro CLI 2.1.1 still requires prompt. if !strings.Contains(requests, `"content":[`) { t.Fatalf("session/prompt must send Kiro content field, got:\n%s", requests) } if !strings.Contains(requests, `"prompt":[`) { t.Fatalf("session/prompt must send standard ACP prompt field for Kiro 2.1.1 compatibility, got:\n%s", requests) } } // TestKiroLoadIncludesMcpServersFromConfig pins that the agent's managed // MCP set actually reaches the wire on session/load — the resume path is // otherwise indistinguishable from the no-config case, which is how the // missing-on-resume bug got past the first round of review. func TestKiroLoadIncludesMcpServersFromConfig(t *testing.T) { t.Parallel() recordPath := filepath.Join(t.TempDir(), "frames.jsonl") fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_load", `{}`))) backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new kiro 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, ResumeSessionID: "ses_load", McpConfig: json.RawMessage(`{"mcpServers":{"fetch":{"command":"uvx"}}}`), }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case <-session.Result: case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } frame := findRecordedFrame(t, recordPath, "session/load") params := frame["params"].(map[string]any) servers, ok := params["mcpServers"].([]any) if !ok { t.Fatalf("session/load.mcpServers: got %T, want []any", params["mcpServers"]) } if len(servers) != 1 || servers[0].(map[string]any)["name"] != "fetch" { t.Fatalf("session/load.mcpServers: got %v, want one entry named fetch", servers) } }