mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
978 lines
32 KiB
Go
978 lines
32 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 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":"<svg />"}}}\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")
|
|
}
|
|
}
|