Files
multica/server/pkg/agent/qoder_test.go
DylanLi 78342a39ce MUL-3305: feat(agent): add qoder CLI as a choice of agent provider. (#2461)
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage

- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)

Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden Qoder ACP drain and wire project skills path

- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qoder): add provider logo and wire MCP config into ACP sessions

- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
  the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
  config (object map) into ACP array format for session/new and
  session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
  empty/nil, and multi-server cases

Co-authored-by: Orca <help@stably.ai>

* fix(test): capture both return values from InjectRuntimeConfig in Qoder test

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): preserve remote MCP headers and promote provider errors

Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.

1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
   only forwarded url/type, so an authenticated remote MCP server looked
   configured in Multica but failed inside the Qoder session. Replace it
   with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
   use), which preserves headers as [{name, value}], sorts for
   deterministic output, and handles remote transport aliases. Fail
   closed on malformed mcp_config instead of silently dropping servers.

2. Provider failures could report as completed tasks. stderr was wired
   via io.MultiWriter and the result was only promoted to failed when
   output was empty, so a terminal upstream error (HTTP 429 / expired
   token) racing a stopReason=end_turn with text still became
   "completed". Switch to StderrPipe + an explicit copier, drain it
   (bounded by the existing grace window, since qodercli can leave a
   child holding the inherited fds) before the decision, and run the
   shared promoteACPResultOnProviderError.

Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): align ACP session handling

Co-authored-by: Orca <help@stably.ai>

* fix(agent): guard qoder late output after drain

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

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:55:45 +08:00

794 lines
26 KiB
Go

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 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 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")
}
}