package agent import ( "context" "encoding/json" "log/slog" "os" "path/filepath" "strings" "testing" "time" ) func fakeQoderACPScript() string { return `#!/bin/sh # Fake qodercli — exercises argv (--yolo --acp), blocked custom_args, set_model failure, and prompt success. if [ -n "$QODER_ARGS_FILE" ]; then for arg in "$@"; do printf '%s\n' "$arg" >> "$QODER_ARGS_FILE" done fi 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id" ;; *'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id" exit 0 ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"ok"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":1,"outputTokens":2}}}\n' "$id" exit 0 ;; esac done ` } func fakeQoderACPScriptWithNarrationAndFinalAnswer() string { return `#!/bin/sh # Mirrors a real Qoder ACP turn: interim narration, a tool call, then the # user-facing answer. All text chunks belong in the transcript, but only the # post-tool chunk is the backend's deliverable Result.Output. 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_final"}}\n' "$id" ;; *'"method":"session/prompt"'*) narration='I will inspect the attachment.' if [ -n "$QODER_TEXT_BEFORE_TOOL" ]; then narration=$QODER_TEXT_BEFORE_TOOL fi if [ "$QODER_TERMINAL_ERROR_BEFORE_TOOL" = "1" ]; then narration='API call failed after 3 retries: HTTP 429' fi printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"%s"}}}}\n' "$narration" if [ "$QODER_DEFERRED_TOOL" = "1" ]; then printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"ToolCall","toolCallId":"tc-read","name":"read_file","status":"pending"}}}\n' else printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"ToolCall","toolCallId":"tc-read","name":"read_file","status":"pending","parameters":{"path":"diagram.svg"}}}}\n' fi printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"ToolCallUpdate","toolCallId":"tc-read","name":"read_file","status":"completed","output":""}}}\n' if [ "$QODER_TOOL_TERMINATED" != "1" ]; then printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"The diagram shows "}}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_final","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"the service architecture."}}}}\n' fi printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":20}}}\n' "$id" exit 0 ;; esac done ` } func fakeQoderACPStaleResumeScript() 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":{}}}\n' "$id" ;; *'"method":"session/resume"'*) sid=$(printf '%s' "$line" | sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p') printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"%s"}}\n' "$id" "$sid" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Session not found"}}\n' "$id" exit 0 ;; esac done ` } func fakeQoderACPStaleResumeSetModelScript() 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":{}}}\n' "$id" ;; *'"method":"session/resume"'*) sid=$(printf '%s' "$line" | sed -n 's/.*"sessionId":"\([^"]*\)".*/\1/p') printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"%s"}}\n' "$id" "$sid" ;; *'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"Session not found"}}\n' "$id" exit 0 ;; esac done ` } func fakeQoderACPScriptWithLeakedStdout() string { return `#!/bin/sh # Fake qodercli that returns session/prompt but leaves stdout open via a child # process, matching qodercli ACP staying alive after the turn completes. 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"ok"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":1,"outputTokens":2}}}\n' "$id" sleep 30 & wait ;; esac done ` } func fakeQoderACPScriptWithLateStdoutAfterResult() string { return `#!/bin/sh # Fake qodercli that returns session/prompt, then leaves stdout open via a # child process that writes one more notification after the bounded drain grace. 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"ok"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":1,"outputTokens":2}}}\n' "$id" ( sleep 0.08; printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"late"}}}}\n' ) & sleep 30 ;; esac done ` } // fakeQoderACPScriptCapturingRPC records every JSON-RPC line it receives to // $QODER_RPC_FILE so a test can inspect the session/new mcpServers payload that // qoder actually sent (e.g. that remote MCP headers survived the conversion). func fakeQoderACPScriptCapturingRPC() string { return `#!/bin/sh while IFS= read -r line; do if [ -n "$QODER_RPC_FILE" ]; then printf '%s\n' "$line" >> "$QODER_RPC_FILE"; fi id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) if [ "$QODER_INIT_MCP_SSE" = "1" ]; then printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"mcpCapabilities":{"sse":true}}}}\n' "$id" else printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id" fi ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"ok"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":1,"outputTokens":2}}}\n' "$id" exit 0 ;; esac done ` } // fakeQoderACPScriptTerminalProviderError streams a normal text chunk (so the // final output is non-empty) but writes a terminal upstream-LLM error to stderr // before ending the turn with end_turn. This is the case promoteACPResultOnProviderError // must catch: a successful-looking end_turn with output, masking a real failure. func fakeQoderACPScriptTerminalProviderError() 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_fake","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"partial answer"}}}}\n' printf '%s\n' '[ERROR] API call failed after 3 retries: HTTP 429 RateLimitError' >&2 printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":1,"outputTokens":2}}}\n' "$id" exit 0 ;; esac done ` } func fakeQoderACPUsageWithDefaultModelScript() 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":{}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_model","models":{"currentModelId":"qoder:auto","availableModels":[{"modelId":"qoder:auto","name":"Qoder Auto"}]}}}\n' "$id" ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":17,"outputTokens":5,"cachedReadTokens":3}}}\n' "$id" exit 0 ;; esac done ` } func TestQoderBackendSetModelFailureFailsTask(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder 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: 30 * 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_fake" { 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 TestQoderBackendInvokesACPFlagAndFiltersBlockedArgs(t *testing.T) { t.Parallel() tempDir := t.TempDir() argsFile := filepath.Join(tempDir, "argv.txt") fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"QODER_ARGS_FILE": argsFile}, }) if err != nil { t.Fatalf("new qoder 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: 30 * time.Second, CustomArgs: []string{"--acp", "acp", "--yolo", "--model", "extra"}, }) 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") if len(lines) < 2 { t.Fatalf("expected at least 2 argv entries, got %d: %q", len(lines), lines) } if lines[0] != "--yolo" || lines[1] != "--acp" { t.Fatalf("arg[0], arg[1] = %q, %q, want --yolo and --acp (full: %q)", lines[0], lines[1], lines) } for _, blocked := range []string{"acp"} { for _, got := range lines[2:] { if got == blocked { t.Errorf("custom_args must not inject standalone %q after daemon argv: %q", blocked, lines) } } } yoloCount := 0 for _, got := range lines { if got == "--yolo" { yoloCount++ } } if yoloCount != 1 { t.Fatalf("expected exactly one daemon --yolo, got count=%d argv=%q", yoloCount, lines) } want := []string{"--yolo", "--acp", "--model", "extra"} if strings.Join(lines, "\n") != strings.Join(want, "\n") { t.Errorf("unexpected argv after filtering: %q, want %q", lines, want) } } func TestQoderBackendHappyPath(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "hi", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() result := <-session.Result if result.Status != "completed" { t.Fatalf("status=%q err=%q", result.Status, result.Error) } if result.Output != "ok" { t.Fatalf("output=%q want ok", result.Output) } if result.SessionID != "ses_fake" { t.Fatalf("session=%q", result.SessionID) } if u := result.Usage["unknown"]; u.InputTokens != 1 || u.OutputTokens != 2 { t.Fatalf("usage=%+v", u) } } func TestQoderBackendDeliversFinalAnswerWithoutNarration(t *testing.T) { for _, test := range []struct { name string env map[string]string }{ {name: "tool use emitted at start"}, {name: "tool use deferred until completion", env: map[string]string{"QODER_DEFERRED_TOOL": "1"}}, } { t.Run(test.name, func(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithNarrationAndFinalAnswer())) backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: test.env, }) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "inspect this attachment", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } var streamed []Message messagesDone := make(chan struct{}) go func() { defer close(messagesDone) for msg := range session.Messages { streamed = append(streamed, msg) } }() result := <-session.Result <-messagesDone const want = "The diagram shows the service architecture." if result.Status != "completed" { t.Fatalf("status=%q err=%q", result.Status, result.Error) } if result.Output != want { t.Fatalf("Result.Output = %q, want %q", result.Output, want) } var text []string for _, msg := range streamed { if msg.Type == MessageText { text = append(text, msg.Content) } } if got := strings.Join(text, "|"); got != "I will inspect the attachment.|The diagram shows |the service architecture." { t.Fatalf("transcript text = %q, want narration and final answer", got) } }) } } func TestQoderBackendToolTerminatedTurnFallsBackToLatestTextBlock(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithNarrationAndFinalAnswer())) const want = "Done. Posted the summary to the issue." backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{ "QODER_TEXT_BEFORE_TOOL": want, "QODER_TOOL_TERMINATED": "1", }, }) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "post the summary", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() result := <-session.Result if result.Status != "completed" { t.Fatalf("status=%q err=%q", result.Status, result.Error) } if result.Output != want { t.Fatalf("Result.Output = %q, want fallback %q", result.Output, want) } } func TestQoderBackendProviderErrorDetectionUsesFullOutput(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithNarrationAndFinalAnswer())) backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"QODER_TERMINAL_ERROR_BEFORE_TOOL": "1"}, }) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "inspect this attachment", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() result := <-session.Result if result.Status != "failed" { t.Fatalf("status=%q, want failed (output=%q error=%q)", result.Status, result.Output, result.Error) } if result.Output != "The diagram shows the service architecture." { t.Fatalf("Result.Output = %q, want final answer only", result.Output) } if !strings.Contains(result.Error, "API call failed after 3 retries") { t.Fatalf("error=%q, want provider error from the full text stream", result.Error) } } func TestQoderBackendNonPositiveTimeoutDoesNotImposeDeadline(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "hi", ExecOptions{Timeout: -1}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case result := <-session.Result: if result.Status != "completed" { t.Fatalf("non-positive timeout should not impose a deadline, got status=%q error=%q", result.Status, result.Error) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for qoder result") } } func TestQoderBackendDoesNotWaitForeverForReaderAfterPromptDone(t *testing.T) { oldGrace := qoderReaderDrainGrace qoderReaderDrainGrace = 25 * time.Millisecond t.Cleanup(func() { qoderReaderDrainGrace = oldGrace }) fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithLeakedStdout())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() session, err := backend.Execute(ctx, "hi", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case result := <-session.Result: if result.Status != "completed" { t.Fatalf("status=%q err=%q", result.Status, result.Error) } if result.Output != "ok" { t.Fatalf("output=%q want ok", result.Output) } case <-time.After(3 * time.Second): t.Fatal("qoder result blocked waiting for reader shutdown") } } func TestQoderMessageStreamDropsSendAfterClose(t *testing.T) { stream := newQoderMessageStream(1) stream.close() defer func() { if r := recover(); r != nil { t.Fatalf("send after close panicked: %v", r) } }() stream.send(Message{Type: MessageText, Content: "late"}) if _, ok := <-stream.ch; ok { t.Fatal("message channel should be closed") } } func TestQoderBackendIgnoresLateReaderOutputAfterGrace(t *testing.T) { oldGrace := qoderReaderDrainGrace qoderReaderDrainGrace = 25 * time.Millisecond t.Cleanup(func() { qoderReaderDrainGrace = oldGrace }) fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithLateStdoutAfterResult())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() session, err := backend.Execute(ctx, "hi", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case result := <-session.Result: if result.Status != "completed" { t.Fatalf("status=%q err=%q", result.Status, result.Error) } if result.Output != "ok" { t.Fatalf("output=%q want ok", result.Output) } case <-time.After(3 * time.Second): t.Fatal("qoder result blocked waiting for reader shutdown") } time.Sleep(150 * time.Millisecond) } // TestQoderForwardsMcpAuthHeaderToSessionNew is the end-to-end guard for the // header-drop bug: qoder must reuse the shared buildACPMcpServers converter so a // remote MCP server's Authorization header reaches the session/new payload as // [{name, value}] instead of being silently stripped. We inspect the exact // JSON-RPC the backend wrote to the (fake) qodercli stdin. func TestQoderForwardsMcpAuthHeaderToSessionNew(t *testing.T) { t.Parallel() tempDir := t.TempDir() rpcFile := filepath.Join(tempDir, "rpc.txt") fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptCapturingRPC())) backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{ "QODER_RPC_FILE": rpcFile, "QODER_INIT_MCP_SSE": "1", }, }) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() mcpConfig := json.RawMessage(`{"mcpServers":{"fetch":{"type":"sse","url":"https://example.com/sse","headers":{"Authorization":"Bearer tok"}}}}`) session, err := backend.Execute(ctx, "hi", ExecOptions{ Timeout: 30 * time.Second, McpConfig: mcpConfig, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() <-session.Result raw, err := os.ReadFile(rpcFile) if err != nil { t.Fatalf("read rpc file: %v", err) } var sessionNew string for _, line := range strings.Split(string(raw), "\n") { if strings.Contains(line, `"method":"session/new"`) { sessionNew = line break } } if sessionNew == "" { t.Fatalf("no session/new request captured; rpc log:\n%s", raw) } if !strings.Contains(sessionNew, "Authorization") || !strings.Contains(sessionNew, "Bearer tok") { t.Fatalf("Authorization header did not survive into session/new mcpServers:\n%s", sessionNew) } // The shared converter emits headers as {name, value} pairs, not the raw // Claude object-map; assert that wire shape too. if !strings.Contains(sessionNew, `"name":"Authorization"`) || !strings.Contains(sessionNew, `"value":"Bearer tok"`) { t.Fatalf("expected headers as [{name,value}] in session/new payload:\n%s", sessionNew) } } func TestQoderFiltersRemoteMcpWhenInitializeDoesNotAdvertiseCapability(t *testing.T) { t.Parallel() tempDir := t.TempDir() rpcFile := filepath.Join(tempDir, "rpc.txt") fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptCapturingRPC())) backend, err := New("qoder", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"QODER_RPC_FILE": rpcFile}, }) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() mcpConfig := json.RawMessage(`{"mcpServers":{"local-stdio":{"command":"uvx","args":["mcp-local"]},"remote-sse":{"type":"sse","url":"https://example.com/sse","headers":{"Authorization":"Bearer tok"}}}}`) session, err := backend.Execute(ctx, "hi", ExecOptions{ Timeout: 30 * time.Second, McpConfig: mcpConfig, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() <-session.Result raw, err := os.ReadFile(rpcFile) if err != nil { t.Fatalf("read rpc file: %v", err) } var sessionNew string for _, line := range strings.Split(string(raw), "\n") { if strings.Contains(line, `"method":"session/new"`) { sessionNew = line break } } if sessionNew == "" { t.Fatalf("no session/new request captured; rpc log:\n%s", raw) } if !strings.Contains(sessionNew, `"name":"local-stdio"`) || !strings.Contains(sessionNew, `"command":"uvx"`) { t.Fatalf("stdio MCP server should remain in session/new payload:\n%s", sessionNew) } if strings.Contains(sessionNew, `"name":"remote-sse"`) || strings.Contains(sessionNew, "https://example.com/sse") || strings.Contains(sessionNew, "Bearer tok") { t.Fatalf("remote SSE MCP server should be filtered when initialize omits mcpCapabilities.sse:\n%s", sessionNew) } } // TestQoderPromotesTerminalProviderErrorWithOutput guards the second bug: a turn // that ends with stopReason=end_turn AND non-empty output must still be reported // as failed when stderr carried a terminal upstream-LLM error. Without the // StderrPipe drain + promoteACPResultOnProviderError, this run reports // "completed" and hides the failure. func TestQoderPromotesTerminalProviderErrorWithOutput(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptTerminalProviderError())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "hi", ExecOptions{Timeout: 30 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case result := <-session.Result: if result.Status != "failed" { t.Fatalf("expected status=failed for a terminal provider error, got %q (output=%q error=%q)", result.Status, result.Output, result.Error) } if result.Output != "partial answer" { t.Errorf("expected the partial output to be preserved, got %q", result.Output) } if !strings.Contains(result.Error, "provider error") || !strings.Contains(result.Error, "429") { t.Errorf("expected error to surface the terminal stderr marker, got %q", result.Error) } case <-time.After(8 * time.Second): t.Fatal("timeout waiting for result") } } func TestQoderBackendAttributesUsageToACPDefaultModel(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPUsageWithDefaultModelScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ Timeout: 30 * 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 completed result, got %q: %s", result.Status, result.Error) } if _, ok := result.Usage["unknown"]; ok { t.Fatalf("usage should not be attributed to unknown: %+v", result.Usage) } usage, ok := result.Usage["qoder:auto"] if !ok { t.Fatalf("expected usage under Qoder current model, got %+v", result.Usage) } if usage.InputTokens != 17 || usage.OutputTokens != 5 || usage.CacheReadTokens != 3 { t.Fatalf("usage = %+v, want input=17 output=5 cache_read=3", usage) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for result") } } func TestQoderBackendClearsSessionIDWhenResumedSessionNotFoundAtPrompt(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPStaleResumeScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ Timeout: 30 * time.Second, ResumeSessionID: "ses_stale", }) 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, "Session not found") { t.Errorf("expected error to surface the session-not-found message, 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 TestQoderBackendClearsSessionIDWhenResumedSessionNotFoundAtSetModel(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPStaleResumeSetModelScript())) backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new qoder backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ Timeout: 30 * time.Second, ResumeSessionID: "ses_stale", Model: "some-model", }) 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 "some-model"`) { 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") } }