package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/johannSo/codey/internal/agent" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/tools" ) // fixedPrinter is a printer on a clock the test moves and with uuids that // count, so its output can be compared byte for byte. func fixedPrinter(format string, verbose bool) (*printer, *bytes.Buffer, *bytes.Buffer, *time.Time) { var out, notes bytes.Buffer p := newPrinter(&out, ¬es, format, verbose) clock := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) p.now = func() time.Time { return clock } n := 0 p.newID = func() string { n++; return fmt.Sprintf("uuid-%d", n) } p.session = "20261002T120000Z" return p, &out, ¬es, &clock } // script plays one tool round trip into a printer, the way the loop would. func script(p *printer, clock *time.Time) { tick := func(d time.Duration) { *clock = clock.Add(d) } tick(5 * time.Millisecond) p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 1}) tick(700 * time.Millisecond) p.observe(agent.Event{Kind: agent.EventUsage, Turn: 1}) p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Response: &llm.Response{ ID: "msg_1", Model: "claude-opus-4-1", StopReason: "tool_use", Content: []llm.ContentBlock{ llm.TextBlock("Looking ."), {Type: llm.BlockToolUse, ID: "tu_1", Name: "bash", Input: json.RawMessage(`{"command":"ls"}`)}, }, Usage: llm.Usage{InputTokens: 10, OutputTokens: 5}, }}) p.observe(agent.Event{Kind: agent.EventText, Turn: 1, Text: "ignored: the message line has it"}) p.observe(agent.Event{Kind: agent.EventNotice, Text: "the window is full"}) results := llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.ToolResultBlock("tu_1", "a.go\nb.go", false)}} p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Message: &results}) p.observe(agent.Event{Kind: agent.EventCompact}) p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 2}) tick(300 * time.Millisecond) p.observe(agent.Event{Kind: agent.EventUsage, Turn: 2}) p.observe(agent.Event{Kind: agent.EventMessage, Turn: 2, Response: &llm.Response{ ID: "msg_2", StopReason: "end_turn", Content: []llm.ContentBlock{llm.TextBlock("Two files.")}, Usage: llm.Usage{InputTokens: 20, OutputTokens: 3, CacheReadInputTokens: 8}, }}) tick(50 * time.Millisecond) } const ( wantInit = `{"type":"system","subtype":"init","cwd":"/work","session_id":"20261002T120000Z","tools":["bash","mcp__db__query","read_file"],"mcp_servers":[{"name":"db","status":"connected"}],"model":"claude-opus-4-1","permissionMode":"default","uuid":"uuid-1"}` wantAssistant = `{"type":"assistant","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Looking .","type":"text"},{"id":"tu_1","input":{"command":"ls"},"name":"bash","type":"tool_use"}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-2"}` wantUser = `{"type":"user","message":{"role":"user","content":[{"content":"a.go\nb.go","tool_use_id":"tu_1","type":"tool_result"}]},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-3"}` wantCompact = `{"type":"system","subtype":"compact_boundary","session_id":"20261002T120000Z","uuid":"uuid-4"}` // The second response named no model, so the session's stands in. wantAssistant2 = `{"type":"assistant","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Two files.","type":"text"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":3,"cache_read_input_tokens":8,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-5"}` ) func resultFor(usage llm.Usage, model string) float64 { c, _ := usage.Cost(model) return c } func TestStreamJSONShape(t *testing.T) { p, out, notes, clock := fixedPrinter(formatStreamJSON, false) p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"}) script(p, clock) usage := llm.Usage{InputTokens: 30, OutputTokens: 8, CacheReadInputTokens: 8} p.finish(outcome{Text: "Two files.", Turns: 2, Usage: usage}) cost, _ := json.Marshal(resultFor(usage, "claude-opus-4-1")) wantResult := `{"type":"result","subtype":"success","is_error":false,"duration_ms":1055,"duration_api_ms":1000,"num_turns":2,"result":"Two files.","session_id":"20261002T120000Z","total_cost_usd":` + string(cost) + `,"usage":{"input_tokens":30,"output_tokens":8,"cache_read_input_tokens":8,"cache_creation_input_tokens":0},"uuid":"uuid-6"}` want := strings.Join([]string{wantInit, wantAssistant, wantUser, wantCompact, wantAssistant2, wantResult}, "\n") + "\n" if got := out.String(); got != want { t.Errorf("stream-json:\n got %s\nwant %s", got, want) } if !strings.Contains(notes.String(), "codey: the window is full") { t.Errorf("notices go to stderr, got %q", notes.String()) } // Every line is JSON a line-by-line reader can parse on its own. for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { if !json.Valid([]byte(line)) { t.Errorf("not JSON: %s", line) } } } func TestJSONIsOneResultObject(t *testing.T) { p, out, _, clock := fixedPrinter(formatJSON, false) p.begin("/work", "claude-opus-4-1", "default", nil) script(p, clock) p.finish(outcome{Text: "Two files.", Turns: 2}) var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("json mode should print one object: %v\n%s", err, out.String()) } if strings.Count(strings.TrimSpace(out.String()), "\n") != 0 { t.Errorf("one line only, got\n%s", out.String()) } for key, want := range map[string]any{ "type": "result", "subtype": "success", "is_error": false, "result": "Two files.", "session_id": "20261002T120000Z", "num_turns": 2.0, "duration_ms": 1055.0, "duration_api_ms": 1000.0, } { if got[key] != want { t.Errorf("%s = %v, want %v", key, got[key], want) } } usage, _ := got["usage"].(map[string]any) for _, k := range []string{"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"} { if _, ok := usage[k]; !ok { t.Errorf("usage has no %s: %v", k, usage) } } if _, ok := got["total_cost_usd"].(float64); !ok { t.Errorf("total_cost_usd should be a number, got %v", got["total_cost_usd"]) } if _, ok := got["errors"]; ok { t.Error("a success carries no errors field") } } func TestJSONVerboseIsTheStreamAsAnArray(t *testing.T) { p, out, _, clock := fixedPrinter(formatJSON, true) p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"}) script(p, clock) p.finish(outcome{Text: "Two files.", Turns: 2}) var lines []map[string]any if err := json.Unmarshal(out.Bytes(), &lines); err != nil { t.Fatalf("json --verbose prints an array: %v\n%s", err, out.String()) } var types []string for _, l := range lines { types = append(types, l["type"].(string)) } if strings.Join(types, ",") != "system,assistant,user,system,assistant,result" { t.Errorf("types = %v", types) } } func TestResultSubtypes(t *testing.T) { cases := []struct { name string o outcome subtype string wantResult string wantIsError bool }{ {"max turns keeps the last text", outcome{Text: "half done", Err: fmt.Errorf("%w: stopped", agent.ErrMaxTurns), Turns: 3}, resultMaxTurns, "half done", true}, {"an error with no text says why", outcome{Err: errors.New("no credentials")}, resultExecution, "no credentials", true}, {"success", outcome{Text: "ok"}, resultSuccess, "ok", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { p, out, _, _ := fixedPrinter(formatJSON, false) p.finish(c.o) var got struct { Subtype string `json:"subtype"` IsError bool `json:"is_error"` Result string `json:"result"` Errors []string `json:"errors"` } if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatal(err) } if got.Subtype != c.subtype || got.IsError != c.wantIsError || got.Result != c.wantResult { t.Errorf("got %+v", got) } if c.o.Err != nil && (len(got.Errors) != 1 || got.Errors[0] != c.o.Err.Error()) { t.Errorf("errors = %q, want the message", got.Errors) } }) } } func TestFinishPrintsOnce(t *testing.T) { p, out, _, _ := fixedPrinter(formatStreamJSON, false) p.finish(outcome{Err: errors.New("boom")}) claudeOut = p defer func() { claudeOut = nil }() reportFailure(errors.New("boom")) if n := strings.Count(out.String(), `"type":"result"`); n != 1 { t.Errorf("printed %d results, want 1", n) } } func TestTextFormatIsTheAnswerOnly(t *testing.T) { p, out, _, clock := fixedPrinter(formatText, false) p.begin("/work", "m", "default", []string{"bash"}) script(p, clock) p.finish(outcome{Text: "Two files."}) if out.String() != "Two files.\n" { t.Errorf("text = %q", out.String()) } p, out, _, _ = fixedPrinter(formatText, false) p.finish(outcome{Err: errors.New("boom")}) if out.Len() != 0 { t.Errorf("a failure prints nothing to stdout in text mode, got %q", out.String()) } } func TestUnescapeHTMLLeavesEscapedBackslashesAlone(t *testing.T) { // The escapes are put together at run time so that nothing between this // file and the compiler can mistake them for the characters they stand for. esc := func(hex string) string { return `\` + "u00" + hex } lt, amp, gt := esc("3c"), esc("26"), esc("3e") in := []byte(`{"a":"x ` + lt + ` y ` + amp + ` z ` + gt + `","b":"\\` + lt[1:] + ` stays","c":"\\` + lt + `"}`) want := `{"a":"x < y & z >","b":"\\` + lt[1:] + ` stays","c":"\\<"}` got := unescapeHTML(in) if string(got) != want { t.Errorf("got %s\nwant %s", got, want) } var v map[string]string if err := json.Unmarshal(got, &v); err != nil || v["b"] != lt+" stays" || v["c"] != `\<` { t.Errorf("the result should decode to the same strings: %v %q", err, v) } // And the encoder's own output, end to end. var out bytes.Buffer p := newPrinter(&out, io.Discard, formatStreamJSON, false) p.write(llm.TextBlock("ad")) if strings.TrimSpace(out.String()) != `{"text":"ad","type":"text"}` { t.Errorf("a content block should print with its HTML unescaped, got %s", out.String()) } } func TestUUID4(t *testing.T) { id := uuid4() if len(id) != 36 || id[14] != '4' || !strings.ContainsRune("89ab", rune(id[19])) { t.Errorf("not a version-4 uuid: %s", id) } } // A real run through the loop, against a fake Messages API: the lines on // stdout are the agent's own messages, a refused tool call included, and the // session is saved where -c will find it. func TestPrintOnceStreamsARealRun(t *testing.T) { responses := []string{ `{"id":"msg_a","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Checking."},{"type":"tool_use","id":"tu_1","name":"bash","input":{"command":"rm -rf build"}}],"stop_reason":"tool_use","usage":{"input_tokens":12,"output_tokens":4}}`, `{"id":"msg_b","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Not allowed, so I stopped."}],"stop_reason":"end_turn","usage":{"input_tokens":20,"output_tokens":6}}`, } calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.ReadAll(r.Body) if calls >= len(responses) { w.WriteHeader(http.StatusInternalServerError) return } io.WriteString(w, responses[calls]) calls++ })) defer srv.Close() root := t.TempDir() client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"), llm.WithModel("claude-opus-4-1")) state := agent.NewState() reg := tools.NewRegistry(nil).Register(tools.NewBash(root)) a := agent.New(client, reg, state, nil, agent.Options{}) policy := approval.New(nil) policy.Rules = approval.RuleList{Deny: []approval.Rule{mustParse(t, "Bash(rm:*)")}} a.Approve = policy p, out, _, _ := fixedPrinter(formatStreamJSON, false) a.Observe = p.observe p.begin(root, client.Model(), "default", []string{"bash"}) sessionDir := filepath.Join(t.TempDir(), "chats") if err := printOnce(context.Background(), a, p, sessionDir, "s1", root, client, "clean up"); err != nil { t.Fatalf("printOnce: %v", err) } var lines []map[string]any for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") { var m map[string]any if err := json.Unmarshal([]byte(l), &m); err != nil { t.Fatalf("not JSON: %s", l) } lines = append(lines, m) } var types []string for _, l := range lines { types = append(types, l["type"].(string)) } if strings.Join(types, ",") != "system,assistant,user,assistant,result" { t.Fatalf("types = %v\n%s", types, out.String()) } user := lines[2]["message"].(map[string]any)["content"].([]any)[0].(map[string]any) if user["type"] != "tool_result" || user["is_error"] != true || !strings.Contains(user["content"].(string), "Bash(rm:*)") { t.Errorf("the refused call should come back as an error result naming the rule: %v", user) } res := lines[4] if res["result"] != "Not allowed, so I stopped." || res["num_turns"] != 2.0 || res["is_error"] != false { t.Errorf("result = %v", res) } if u := res["usage"].(map[string]any); u["input_tokens"] != 32.0 || u["output_tokens"] != 10.0 { t.Errorf("usage = %v, want the two responses summed", u) } if _, err := os.Stat(filepath.Join(sessionDir, "s1.json")); err != nil { t.Errorf("the session should be saved for -c: %v", err) } } func mustParse(t *testing.T, s string) approval.Rule { t.Helper() r, err := approval.ParseRule(s) if err != nil { t.Fatal(err) } return r }