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 <here>."),
{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 <here>.","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("a<b && c>d"))
if strings.TrimSpace(out.String()) != `{"text":"a<b && c>d","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(root, agent.SessionDir)
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
}