mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* 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>
794 lines
26 KiB
Go
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")
|
|
}
|
|
}
|