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 != "<tool_use: ToolSearch - Load the question tool>" {
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 != "<tool_result: ToolSearch loaded AskUserQuestion>" {
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
}