package agent import ( "context" "encoding/json" "errors" "os" "path/filepath" "runtime" "strings" "testing" ) func TestPiAgent_BuildArgs(t *testing.T) { pa := &piAgent{bin: "pi"} args := pa.buildArgs() expected := []string{"--mode", "json", "--no-session"} if len(args) != len(expected) { t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) } for i, want := range expected { if args[i] != want { t.Errorf("arg[%d]: expected %q, got %q", i, want, args[i]) } } } func TestPiAgent_BuildArgs_PrependsExtraArgs(t *testing.T) { pa := &piAgent{bin: "pi", extraArgs: []string{"--provider", "google"}} args := pa.buildArgs() expected := []string{"--provider", "google", "--mode", "json", "--no-session"} if len(args) != len(expected) { t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) } for i, want := range expected { if args[i] != want { t.Errorf("arg[%d]: expected %q, got %q", i, want, args[i]) } } } func TestPiAgent_BuildPromptIncludesSchema(t *testing.T) { schema := json.RawMessage(`{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}`) prompt := buildPiPrompt("do a thing", schema) if !strings.Contains(prompt, "do a thing") { t.Errorf("prompt missing user prompt: %s", prompt) } if !strings.Contains(prompt, "no-mistakes final output contract") { t.Errorf("prompt missing contract header: %s", prompt) } if !strings.Contains(prompt, "summary") { t.Errorf("prompt missing schema property: %s", prompt) } } func TestPiAgent_BuildPromptOmitsContractWhenSchemaEmpty(t *testing.T) { prompt := buildPiPrompt("do a thing", nil) if prompt != "do a thing" { t.Errorf("expected raw prompt when no schema, got: %q", prompt) } } func writeFakePi(t *testing.T, dir, posixScript, windowsScript string) string { t.Helper() name := "pi" script := posixScript if runtime.GOOS == "windows" { name = "pi.cmd" script = windowsScript } bin := filepath.Join(dir, name) if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatalf("write fake pi: %v", err) } return bin } func TestPiAgent_RunParsesAssistantContentAndUsage(t *testing.T) { dir := t.TempDir() // Fake pi that emits a streaming text_delta plus a final message_end with // content blocks and a usage record. Mirrors the live JSONL shape. bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null printf '%s\n' '{"type":"message_update","message":{"role":"assistant","responseId":"r1"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"{\"ok"}}' printf '%s\n' '{"type":"message_update","message":{"role":"assistant","responseId":"r1"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"\":true}"}}' printf '%s\n' '{"type":"message_end","message":{"role":"assistant","responseId":"r1","content":[{"type":"text","text":"{\"ok\":true}"}],"usage":{"input":11,"output":7,"cacheRead":3,"cacheWrite":1}}}' printf '%s\n' '{"type":"agent_end","messages":[]}' `, strings.Join([]string{ "@echo off", "more > nul", "echo {\"type\":\"message_update\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\"},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"{\\\"ok\"}}", "echo {\"type\":\"message_update\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\"},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"\\\":true}\"}}", "echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":11,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}}", "echo {\"type\":\"agent_end\",\"messages\":[]}", }, "\r\n")) schema := json.RawMessage(`{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}`) pa := &piAgent{bin: bin} var chunks []string result, err := pa.Run(context.Background(), RunOpts{ Prompt: "review", CWD: t.TempDir(), JSONSchema: schema, OnChunk: func(s string) { chunks = append(chunks, s) }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if string(result.Output) != `{"ok":true}` { t.Fatalf("unexpected output: %s", string(result.Output)) } if result.Usage.InputTokens != 11 || result.Usage.OutputTokens != 7 || result.Usage.CacheReadTokens != 3 || result.Usage.CacheCreationTokens != 1 { t.Fatalf("unexpected usage: %+v", result.Usage) } if len(chunks) == 0 { t.Fatal("expected onChunk to receive streaming text") } // OnChunk must receive the incremental deltas, not cumulative state. // Otherwise the TUI log buffer (which appends each chunk) duplicates // the running prefix. wantChunks := []string{`{"ok`, `":true}`} if len(chunks) != len(wantChunks) { t.Fatalf("expected %d delta chunks, got %d: %v", len(wantChunks), len(chunks), chunks) } for i, want := range wantChunks { if chunks[i] != want { t.Errorf("chunk[%d] = %q, want %q", i, chunks[i], want) } } } func TestPiAgent_RunFallsBackToAgentEndMessages(t *testing.T) { dir := t.TempDir() bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null printf '%s\n' '{"type":"agent_end","messages":[{"role":"user","content":"prompt"},{"role":"assistant","content":"{\"ok\":true}"}]}' `, strings.Join([]string{ "@echo off", "more > nul", "echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":\"prompt\"},{\"role\":\"assistant\",\"content\":\"{\\\"ok\\\":true}\"}]}", }, "\r\n")) schema := json.RawMessage(`{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}`) pa := &piAgent{bin: bin} result, err := pa.Run(context.Background(), RunOpts{ Prompt: "review", CWD: t.TempDir(), JSONSchema: schema, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if string(result.Output) != `{"ok":true}` { t.Fatalf("unexpected output: %s", string(result.Output)) } } func TestPiParser_ClearsPriorAssistantErrorAfterSuccessfulRetry(t *testing.T) { stream := strings.Join([]string{ `{"type":"message_end","message":{"role":"assistant","responseId":"r1","stopReason":"error","errorMessage":"transient failure"}}`, `{"type":"message_end","message":{"role":"assistant","responseId":"r2","stopReason":"stop","content":[{"type":"text","text":"success"}]}}`, `{"type":"agent_end","messages":[{"role":"assistant","responseId":"r1","stopReason":"error","errorMessage":"transient failure"},{"role":"assistant","responseId":"r2","stopReason":"stop","content":[{"type":"text","text":"success"}]}]}`, }, "\n") pp := &piParser{} if err := pp.parse(context.Background(), strings.NewReader(stream)); err != nil { t.Fatalf("parse: %v", err) } if pp.assistantError != "" { t.Fatalf("expected successful retry to clear assistant error, got %q", pp.assistantError) } if got := pp.finalText(); got != "success" { t.Fatalf("expected final retry text, got %q", got) } } func TestPiParser_SumsUniqueAssistantUsageAcrossTurns(t *testing.T) { stream := strings.Join([]string{ `{"type":"message_end","message":{"role":"assistant","responseId":"r1","stopReason":"toolUse","content":[{"type":"toolCall","name":"bash"}],"usage":{"input":10,"output":2,"cacheRead":3,"cacheWrite":4}}}`, `{"type":"turn_end","message":{"role":"assistant","responseId":"r1","stopReason":"toolUse","content":[{"type":"toolCall","name":"bash"}],"usage":{"input":10,"output":2,"cacheRead":3,"cacheWrite":4}}}`, `{"type":"message_end","message":{"role":"assistant","responseId":"r2","stopReason":"stop","content":[{"type":"text","text":"done"}],"usage":{"input":1,"output":5,"cacheRead":6,"cacheWrite":7}}}`, `{"type":"turn_end","message":{"role":"assistant","responseId":"r2","stopReason":"stop","content":[{"type":"text","text":"done"}],"usage":{"input":1,"output":5,"cacheRead":6,"cacheWrite":7}}}`, `{"type":"agent_end","messages":[{"role":"assistant","responseId":"r1","stopReason":"toolUse","content":[{"type":"toolCall","name":"bash"}],"usage":{"input":10,"output":2,"cacheRead":3,"cacheWrite":4}},{"role":"toolResult","content":[{"type":"text","text":"ok"}]},{"role":"assistant","responseId":"r2","stopReason":"stop","content":[{"type":"text","text":"done"}],"usage":{"input":1,"output":5,"cacheRead":6,"cacheWrite":7}}]}`, }, "\n") pp := &piParser{} if err := pp.parse(context.Background(), strings.NewReader(stream)); err != nil { t.Fatalf("parse: %v", err) } want := TokenUsage{InputTokens: 11, OutputTokens: 7, CacheReadTokens: 9, CacheCreationTokens: 11, Reported: true, CacheCreationReported: true} if pp.usage != want { t.Fatalf("usage = %+v, want %+v", pp.usage, want) } } func TestPiAgent_RunRejectsAssistantError(t *testing.T) { dir := t.TempDir() bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null printf '%s\n' '{"type":"message_end","message":{"role":"assistant","stopReason":"error","errorMessage":"auth failed","content":[{"type":"text","text":"{\"ok\":true}"}]}}' `, strings.Join([]string{ "@echo off", "more > nul", "echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"stopReason\":\"error\",\"errorMessage\":\"auth failed\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}]}}", }, "\r\n")) pa := &piAgent{bin: bin} _, err := pa.Run(context.Background(), RunOpts{ Prompt: "review", CWD: t.TempDir(), JSONSchema: json.RawMessage(`{"type":"object"}`), }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "auth failed") { t.Errorf("expected error to mention 'auth failed', got: %v", err) } } func TestPiAgent_RunRejectsEmptyOutput(t *testing.T) { dir := t.TempDir() bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null printf '%s\n' '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":" "}]}}' `, strings.Join([]string{ "@echo off", "more > nul", "echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\" \"}]}}", }, "\r\n")) pa := &piAgent{bin: bin} _, err := pa.Run(context.Background(), RunOpts{ Prompt: "review", CWD: t.TempDir(), }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "no text output") { t.Errorf("expected 'no text output', got: %v", err) } } func TestPiAgent_RunSurfacesNonZeroExit(t *testing.T) { dir := t.TempDir() bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null echo "boom" >&2 exit 2 `, strings.Join([]string{ "@echo off", "more > nul", "echo boom 1>&2", "exit /b 2", }, "\r\n")) pa := &piAgent{bin: bin} _, err := pa.Run(context.Background(), RunOpts{ Prompt: "review", CWD: t.TempDir(), }) if err == nil { t.Fatal("expected non-zero exit error") } if !strings.Contains(err.Error(), "boom") { t.Errorf("expected stderr in error message, got: %v", err) } } func TestPiAgent_RunCancelledByContext(t *testing.T) { dir := t.TempDir() bin := writeFakePi(t, dir, `#!/bin/sh cat > /dev/null sleep 30 `, strings.Join([]string{ "@echo off", "more > nul", "timeout /t 30 /nobreak > nul", }, "\r\n")) pa := &piAgent{bin: bin} ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := pa.Run(ctx, RunOpts{ Prompt: "review", CWD: t.TempDir(), }) if err == nil { t.Fatal("expected error from cancelled context") } if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") { t.Logf("got error: %v", err) } }