package claudecode import ( "os" "path/filepath" "strings" "testing" ) func TestDiscover_FindsJSONLRecursively(t *testing.T) { root := t.TempDir() paths := []string{ filepath.Join(root, "session-a.jsonl"), filepath.Join(root, "nested", "agent-1.jsonl"), filepath.Join(root, "nested", "ignore.txt"), } for _, path := range paths { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("MkdirAll(%q): %v", path, err) } if err := os.WriteFile(path, []byte("fixture\n"), 0o600); err != nil { t.Fatalf("WriteFile(%q): %v", path, err) } } p := New("laptop") files, err := p.Discover(root) if err != nil { t.Fatalf("Discover: %v", err) } if len(files) != 2 { t.Fatalf("Discover count = %d, want 2", len(files)) } if got, want := files[0].Path, filepath.Join(root, "nested", "agent-1.jsonl"); got != want { t.Fatalf("files[0].Path = %q, want %q", got, want) } if got, want := files[1].Path, filepath.Join(root, "session-a.jsonl"); got != want { t.Fatalf("files[1].Path = %q, want %q", got, want) } } func TestParse_MapsClaudeConversationRecords(t *testing.T) { path := writeTranscript(t, `{"type":"permission-mode","permissionMode":"default","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f"}`+"\n"+ `{"parentUuid":null,"isSidechain":false,"promptId":"77bd4c73-a032-45a6-ae10-0cae4c51d522","type":"user","message":{"role":"user","content":"let's configure meli client for gmail email"},"uuid":"4aad19e9-ffe0-47df-a54b-5e5cab1fcb57","timestamp":"2026-05-02T10:01:16.914Z","cwd":"/Users/blikh/data/home","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f","version":"2.1.126","gitBranch":"HEAD"}`+"\n"+ `{"parentUuid":"x","isSidechain":false,"message":{"model":"claude-opus-4-7","type":"message","role":"assistant","content":[{"type":"text","text":"Before configuring, I need a couple of decisions from you."}],"usage":{"input_tokens":1,"output_tokens":42}},"requestId":"req_123","type":"assistant","uuid":"d801eb4d-4a19-4b1e-ad13-09a5a8745ca5","timestamp":"2026-05-02T10:02:10.076Z","cwd":"/Users/blikh/data/home","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f","version":"2.1.126","gitBranch":"HEAD"}`+"\n"+ `{"parentUuid":"x","isSidechain":false,"message":{"model":"claude-opus-4-7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01R9FjyoMf16Sm9to16YeDUE","name":"ToolSearch","input":{"query":"select:AskUserQuestion","max_results":1,"description":"Load the question tool"}}],"usage":{"input_tokens":1,"output_tokens":8}},"requestId":"req_124","type":"assistant","uuid":"11aba1df-59e0-49ae-aa23-3862a2b517c9","timestamp":"2026-05-02T10:02:10.522Z","cwd":"/Users/blikh/data/home","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f","version":"2.1.126","gitBranch":"HEAD"}`+"\n"+ `{"parentUuid":"x","isSidechain":false,"promptId":"77bd4c73-a032-45a6-ae10-0cae4c51d522","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01R9FjyoMf16Sm9to16YeDUE","type":"tool_result","content":"ToolSearch loaded AskUserQuestion","is_error":false}]},"uuid":"383c9596-a2ff-41be-a540-816798450b5b","timestamp":"2026-05-02T10:02:11.111Z","toolUseResult":{"stdout":"ToolSearch loaded AskUserQuestion","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"cwd":"/Users/blikh/data/home","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f","version":"2.1.126","gitBranch":"HEAD"}`+"\n"+ `{"type":"ai-title","aiTitle":"Configure meli client for Gmail email","sessionId":"6d3afaab-4fe0-4bfd-a488-b43a9302e17f"}`+"\n", ) p := New("laptop") events, nextOffset, err := p.Parse(path, 0) if err != nil { t.Fatalf("Parse: %v", err) } if len(events) != 4 { t.Fatalf("len(events) = %d, want 4", len(events)) } info, err := os.Stat(path) if err != nil { t.Fatalf("Stat: %v", err) } if nextOffset != info.Size() { t.Fatalf("nextOffset = %d, want %d", nextOffset, info.Size()) } if got := events[0].Role; got != "user" { t.Fatalf("events[0].Role = %q, want user", got) } if got := events[0].Content; got != "let's configure meli client for gmail email" { t.Fatalf("events[0].Content = %q", got) } if events[0].Host != "laptop" || events[0].Tool != "claude-code" { t.Fatalf("events[0] identity = %s/%s, want claude-code/laptop", events[0].Tool, events[0].Host) } if got := events[0].SessionMeta.SourceFile; got != path { t.Fatalf("events[0].SessionMeta.SourceFile = %q, want %q", got, path) } if got := events[1].Role; got != "assistant" { t.Fatalf("events[1].Role = %q, want assistant", got) } if got := events[1].Content; got != "Before configuring, I need a couple of decisions from you." { t.Fatalf("events[1].Content = %q", got) } if events[1].Model == nil || *events[1].Model != "claude-opus-4-7" { t.Fatalf("events[1].Model = %v, want claude-opus-4-7", events[1].Model) } if events[1].TokensIn == nil || *events[1].TokensIn != 1 { t.Fatalf("events[1].TokensIn = %v, want 1", events[1].TokensIn) } if events[1].TokensOut == nil || *events[1].TokensOut != 42 { t.Fatalf("events[1].TokensOut = %v, want 42", events[1].TokensOut) } if got := events[2].Role; got != "tool" { t.Fatalf("events[2].Role = %q, want tool", got) } if got := events[2].Content; got != "" { t.Fatalf("events[2].Content = %q", got) } if !strings.Contains(string(events[2].ToolCalls), `"ToolSearch"`) { t.Fatalf("events[2].ToolCalls = %s, want ToolSearch payload", string(events[2].ToolCalls)) } if got := events[3].Role; got != "tool" { t.Fatalf("events[3].Role = %q, want tool", got) } if got := events[3].Content; got != "" { t.Fatalf("events[3].Content = %q", got) } if !strings.Contains(string(events[3].ToolCalls), `"stdout":"ToolSearch loaded AskUserQuestion"`) { t.Fatalf("events[3].ToolCalls = %s, want toolUseResult payload", string(events[3].ToolCalls)) } } func TestParse_IgnoresPartialTrailingRecordAndResumesFromOffset(t *testing.T) { path := writeTranscript(t, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u-1","timestamp":"2026-05-03T09:00:00Z","cwd":"/Users/blikh/data/home/lethe","sessionId":"sess-1"}`+"\n"+ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"usage":{"input_tokens":2,"output_tokens":3}},"timestamp":"2026-05-03T09:00:01Z","cwd":"/Users/blikh/data/home/lethe","sessionId":"sess-1"}`, ) p := New("laptop") events, nextOffset, err := p.Parse(path, 0) if err != nil { t.Fatalf("Parse initial: %v", err) } if len(events) != 1 { t.Fatalf("initial len(events) = %d, want 1", len(events)) } if got := events[0].Content; got != "hello" { t.Fatalf("events[0].Content = %q, want hello", got) } f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("OpenFile append: %v", err) } defer func() { _ = f.Close() }() if _, err := f.WriteString("\n"); err != nil { t.Fatalf("append newline: %v", err) } events, finalOffset, err := p.Parse(path, nextOffset) if err != nil { t.Fatalf("Parse resumed: %v", err) } if len(events) != 1 { t.Fatalf("resumed len(events) = %d, want 1", len(events)) } if got := events[0].Role; got != "assistant" { t.Fatalf("events[0].Role = %q, want assistant", got) } if got := events[0].Content; got != "partial" { t.Fatalf("events[0].Content = %q, want partial", got) } if events[0].TurnID == "" { t.Fatal("events[0].TurnID is empty, want synthesized ID") } info, err := os.Stat(path) if err != nil { t.Fatalf("Stat after resume: %v", err) } if finalOffset != info.Size() { t.Fatalf("finalOffset = %d, want %d", finalOffset, info.Size()) } } func writeTranscript(t *testing.T, body string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "session-1.jsonl") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } return path }