package parser import ( "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" "time" "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "go.kenn.io/agentsview/internal/testjsonl" ) func runClaudeParserTest(t *testing.T, fileName, content string) (ParsedSession, []ParsedMessage) { t.Helper() if fileName == "" { fileName = "test.jsonl" } path := createTestFile(t, fileName, content) results, err := parseClaudeSession(path, "my_app", "local") require.NoError(t, err) require.NotEmpty(t, results) return results[0].Session, results[0].Messages } func callParseClaudeSessionFrom( path string, offset int64, startOrdinal int, lastEntryUUID string, ) ([]ParsedMessage, time.Time, int64, error) { msgs, _, endedAt, consumed, err := callParseClaudeSessionFromWithLinks( path, offset, startOrdinal, lastEntryUUID, ) return msgs, endedAt, consumed, err } func callParseClaudeSessionFromWithLinks( path string, offset int64, startOrdinal int, lastEntryUUID string, ) ([]ParsedMessage, []ClaudeSubagentLink, time.Time, int64, error) { fn := reflect.ValueOf(claudeParseSessionFrom) args := []reflect.Value{ reflect.ValueOf(path), reflect.ValueOf(offset), reflect.ValueOf(startOrdinal), } if fn.Type().NumIn() == 4 { args = append(args, reflect.ValueOf(lastEntryUUID)) } out := fn.Call(args) var msgs []ParsedMessage if !out[0].IsNil() { msgs = out[0].Interface().([]ParsedMessage) } var links []ClaudeSubagentLink endedIdx := 1 consumedIdx := 2 errIdx := 3 if len(out) == 5 { if !out[1].IsNil() { links = out[1].Interface().([]ClaudeSubagentLink) } endedIdx = 2 consumedIdx = 3 errIdx = 4 } endedAt := out[endedIdx].Interface().(time.Time) consumed := out[consumedIdx].Interface().(int64) var err error if !out[errIdx].IsNil() { err = out[errIdx].Interface().(error) } return msgs, links, endedAt, consumed, err } // TestParseClaudeSession_UsageProbe verifies that sessions whose only // real user turn is the /usage command (content-free usage probes such // as CodexBar's ClaudeProbe) are skipped during parsing, while sessions // that also contain a real prompt or a different command are kept. func TestParseClaudeSession_UsageProbe(t *testing.T) { const usageCmd = "/usage\n" + " usage\n" + " " parse := func(t *testing.T, content string) []ParseResult { t.Helper() path := createTestFile(t, "probe.jsonl", content) results, err := parseClaudeSession(path, "ClaudeProbe", "local") require.NoError(t, err) return results } t.Run("skips a /usage-only session", func(t *testing.T) { content := testjsonl.ClaudeUserJSON(usageCmd, tsEarly) assert.Empty(t, parse(t, content), "a session whose only user turn is /usage must be skipped") }) t.Run("skips a queued /usage-only session", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeAssistantJSON("checking usage", tsEarly), testjsonl.ClaudeQueuedCommandJSON("/usage", tsEarlyS1), ) assert.Empty(t, parse(t, content), "a queued /usage with no real prompt must be skipped") }) t.Run("skips the real exemplar shape (caveat then /usage)", func(t *testing.T) { // The actual CodexBar probe leads with a // marker (classified as a system message) before the /usage // command, with no assistant turn. The caveat must not count as a // real user turn, leaving /usage as the sole turn so the probe is // skipped. content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "Caveat: The messages below were "+ "generated by the user while running local commands. DO NOT "+ "respond to these messages unless explicitly asked."+ "", tsEarly, ), testjsonl.ClaudeUserJSON(usageCmd, tsEarlyS1), ) assert.Empty(t, parse(t, content), "caveat is a system marker, so the real probe shape must be skipped") }) t.Run("keeps a session with /usage and a real prompt", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON(usageCmd, tsEarly), testjsonl.ClaudeUserJSON("now fix the login bug", tsEarlyS1), testjsonl.ClaudeAssistantJSON("on it", tsEarlyS5), ) results := parse(t, content) require.Len(t, results, 1) assert.Equal(t, "now fix the login bug", results[0].Session.FirstMessage) }) t.Run("keeps a non-/usage command-only session", func(t *testing.T) { content := testjsonl.ClaudeUserJSON( "/clear\n", tsEarly, ) assert.Len(t, parse(t, content), 1, "only /usage probes are skipped, not other commands") }) } func TestParseClaudeSession_Basic(t *testing.T) { content := loadFixture(t, "claude/valid_session.jsonl") sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assertMessageCount(t, len(msgs), 4) assertMessageCount(t, sess.MessageCount, 4) assertSessionMeta(t, &sess, "test", "my_app", AgentClaude) assert.Equal(t, "Fix the login bug", sess.FirstMessage) assertMessage(t, msgs[0], RoleUser, "") assertMessage(t, msgs[1], RoleAssistant, "") assert.True(t, msgs[1].HasToolUse) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolUseID: "toolu_1", ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"src/auth.ts"}`}}) assert.Equal(t, 0, msgs[0].Ordinal) assert.Equal(t, 1, msgs[1].Ordinal) } func TestParseClaudeSession_HyphenatedFilename(t *testing.T) { content := loadFixture(t, "claude/valid_session.jsonl") sess, _ := runClaudeParserTest(t, "my-test-session.jsonl", content) assert.Equal(t, "my-test-session", sess.ID) } func TestParseClaudeSession_EdgeCases(t *testing.T) { t.Run("empty file", func(t *testing.T) { sess, msgs := runClaudeParserTest(t, "test.jsonl", "") assert.Empty(t, msgs) assert.Equal(t, 0, sess.MessageCount) }) t.Run("skips blank content", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("", tsZero), testjsonl.ClaudeUserJSON(" ", tsZeroS1), testjsonl.ClaudeUserJSON("actual message", tsZeroS2), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 1, sess.MessageCount) }) t.Run("truncates long first message", func(t *testing.T) { content := testjsonl.ClaudeUserJSON(generateLargeString(400), tsZero) + "\n" sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 303, len(sess.FirstMessage)) }) t.Run("skips invalid JSON lines", func(t *testing.T) { content := "not valid json\n" + testjsonl.ClaudeUserJSON("hello", tsZero) + "\n" + "also not valid\n" sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 1, sess.MessageCount) }) t.Run("malformed UTF-8", func(t *testing.T) { badUTF8 := `{"type":"user","timestamp":"` + tsZeroS1 + `","message":{"content":"bad ` + string([]byte{0xff, 0xfe}) + `"}}` + "\n" content := testjsonl.ClaudeUserJSON("valid message", tsZero) + "\n" + badUTF8 sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.GreaterOrEqual(t, sess.MessageCount, 1) }) t.Run("very large message", func(t *testing.T) { content := testjsonl.ClaudeUserJSON(generateLargeString(1024*1024), tsZero) + "\n" _, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 1024*1024, msgs[0].ContentLength) }) t.Run("skips empty lines in file", func(t *testing.T) { content := "\n\n" + testjsonl.ClaudeUserJSON("msg1", tsZero) + "\n \n\t\n" + testjsonl.ClaudeAssistantJSON([]map[string]any{{"type": "text", "text": "reply"}}, tsZeroS1) + "\n\n" sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) }) t.Run("skips partial/truncated JSON", func(t *testing.T) { content := testjsonl.ClaudeUserJSON("first", tsZero) + "\n" + `{"type":"user","truncated` + "\n" + testjsonl.ClaudeAssistantJSON([]map[string]any{{"type": "text", "text": "last"}}, tsZeroS2) + "\n" sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) }) } func TestParseClaudeSession_SkippedMessages(t *testing.T) { t.Run("skips isMeta user messages", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeMetaUserJSON("meta context", tsZero, true, false), testjsonl.ClaudeUserJSON("real question", tsZeroS1), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 1, sess.MessageCount) assert.Equal(t, "real question", sess.FirstMessage) }) t.Run("persists isCompactSummary as system message", func(t *testing.T) { compactLine := `{"type":"user","isCompactSummary":true,` + `"message":{"role":"user","content":[` + `{"type":"text","text":"Summary of conversation so far..."}` + `]},"uuid":"compact-uuid","parentUuid":"parent-uuid",` + `"isSidechain":false,"timestamp":"` + tsZero + `"}` content := testjsonl.JoinJSONL( compactLine, testjsonl.ClaudeUserJSON("actual prompt", tsZeroS1), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) // Compact boundary + real user message = 2 messages. assert.Equal(t, 2, sess.MessageCount) // Only real user messages count toward UserMessageCount. assert.Equal(t, 1, sess.UserMessageCount) // FirstMessage is from the first real user message. assert.Equal(t, "actual prompt", sess.FirstMessage) // Verify compact boundary message fields. cb := msgs[0] assert.Equal(t, RoleAssistant, cb.Role) assert.True(t, cb.IsSystem) assert.True(t, cb.IsCompactBoundary) assert.Equal(t, "system", cb.SourceType) assert.Equal(t, "compact_boundary", cb.SourceSubtype) assert.Equal(t, "compact-uuid", cb.SourceUUID) assert.Equal(t, "parent-uuid", cb.SourceParentUUID) assert.False(t, cb.IsSidechain) assert.Contains(t, cb.Content, "Summary of conversation so far...") assert.Equal(t, 0, cb.Ordinal) // Real user message follows with next ordinal. assert.Equal(t, 1, msgs[1].Ordinal) assert.Equal(t, RoleUser, msgs[1].Role) }) t.Run("promotes and skips system-injected patterns", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("This session is being continued from a previous conversation.", tsZero), testjsonl.ClaudeUserJSON("[Request interrupted by user]", tsZeroS1), testjsonl.ClaudeUserJSON("Caveat: resumed", tsZeroS2), testjsonl.ClaudeUserJSON("data", "2024-01-01T00:00:04Z"), // Non-caveat local-command is pure noise and stays skipped. testjsonl.ClaudeUserJSON("ok", "2024-01-01T00:00:05Z"), testjsonl.ClaudeUserJSON("Stop hook feedback: rejected", "2024-01-01T00:00:06Z"), testjsonl.ClaudeUserJSON("real user message", "2024-01-01T00:00:07Z"), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) // 5 promoted system + 1 real user; // is still skipped. assert.Equal(t, 6, sess.MessageCount) assert.Equal(t, 1, sess.UserMessageCount) assert.Equal(t, "real user message", sess.FirstMessage) wantSubtypes := []string{ "continuation", "interrupted", "resume", "task_notification", "stop_hook", } require.Len(t, msgs, 6) for i, want := range wantSubtypes { assert.True(t, msgs[i].IsSystem, "msgs[%d] should be system", i) // Promoted system markers keep Role=user so // role-keyed analytics don't count them as // assistant replies. assert.Equal(t, RoleUser, msgs[i].Role) assert.Equal(t, "system", msgs[i].SourceType) assert.Equal(t, want, msgs[i].SourceSubtype) } // Final message is the real user message. assert.False(t, msgs[5].IsSystem) assert.Equal(t, RoleUser, msgs[5].Role) assert.Equal(t, "real user message", msgs[5].Content) }) t.Run("skill invocation shown as user message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "roborev-fix\n/roborev-fix\n450", tsZero, ), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "Looking at issue 450..."}, }, tsZeroS1), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) assert.Equal(t, 1, sess.UserMessageCount) assert.Equal(t, "", sess.FirstMessage, "slash command with no follow-up yields empty first_message") assert.Equal(t, RoleUser, msgs[0].Role) assert.Equal(t, "/roborev-fix 450", msgs[0].Content) }) t.Run("skill invocation without args shown as user message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "superpowers:brainstorming\n/superpowers:brainstorming", tsZero, ), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "Starting brainstorming..."}, }, tsZeroS1), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) assert.Equal(t, "/superpowers:brainstorming", sess.FirstMessage) assert.Equal(t, RoleUser, msgs[0].Role) assert.Equal(t, "/superpowers:brainstorming", msgs[0].Content) }) t.Run("assistant with system-like content not filtered", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsZero), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "This session is being continued from a previous conversation."}, }, tsZeroS1), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) }) t.Run("firstMsg from first non-system user message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeMetaUserJSON("context data", tsZero, true, false), testjsonl.ClaudeUserJSON("This session is being continued from a previous conversation.", tsZeroS1), testjsonl.ClaudeUserJSON("Fix the auth bug", tsZeroS2), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) // Meta user is skipped, continuation is promoted to a // system message, real user is kept. assert.Equal(t, 2, sess.MessageCount) assert.Equal(t, 1, sess.UserMessageCount) assert.Equal(t, "Fix the auth bug", sess.FirstMessage) }) } func TestParseClaudeSession_QueuedCommand(t *testing.T) { t.Run("surfaces as user message between turns", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("first request", tsZero), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "starting work"}, }, tsZeroS1), testjsonl.ClaudeQueuedCommandJSON( "hold on, also do X", tsZeroS2, ), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "OK doing X too"}, }, "2024-01-01T00:00:03Z"), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) require.Len(t, msgs, 4) assert.Equal(t, 4, sess.MessageCount) // Original first user + queued command both count. assert.Equal(t, 2, sess.UserMessageCount) assert.Equal(t, "first request", sess.FirstMessage) assert.Equal(t, RoleUser, msgs[0].Role) assert.Equal(t, "first request", msgs[0].Content) assert.Equal(t, RoleAssistant, msgs[1].Role) assert.Equal(t, RoleUser, msgs[2].Role) assert.Equal(t, "hold on, also do X", msgs[2].Content) assert.False(t, msgs[2].IsSystem) assert.Equal(t, "user", msgs[2].SourceType) assert.Equal(t, "queued_command", msgs[2].SourceSubtype) assert.Equal(t, RoleAssistant, msgs[3].Role) for i, m := range msgs { assert.Equal(t, i, m.Ordinal, "ordinal mismatch at %d", i) } }) t.Run("becomes first message when session opens with one", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeQueuedCommandJSON( "queued opener", tsZero, ), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "ok"}, }, tsZeroS1), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) require.Len(t, msgs, 2) assert.Equal(t, 1, sess.UserMessageCount) assert.Equal(t, "queued opener", sess.FirstMessage) assert.Equal(t, "queued_command", msgs[0].SourceSubtype) }) t.Run("empty prompt is skipped", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hi", tsZero), testjsonl.ClaudeQueuedCommandJSON(" ", tsZeroS1), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "ok"}, }, tsZeroS2), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) require.Len(t, msgs, 2) }) t.Run("non-queued attachment types are dropped", func(t *testing.T) { taskReminder := `{"type":"attachment","timestamp":"` + tsZeroS1 + `","attachment":{"type":"task_reminder",` + `"content":"reminder"}}` content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hi", tsZero), taskReminder, testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "ok"}, }, tsZeroS2), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 2, sess.MessageCount) require.Len(t, msgs, 2) }) t.Run("works in DAG sessions with uuids on real entries", func(t *testing.T) { // Real entries form a uuid chain; the attachment has // no uuid but must still surface as a user message. userLine := `{"type":"user","uuid":"u1","timestamp":"` + tsZero + `","message":{"content":"hi"}}` assistant1 := `{"type":"assistant","uuid":"a1",` + `"parentUuid":"u1","timestamp":"` + tsZeroS1 + `","message":{"content":[{"type":"text",` + `"text":"work"}]}}` attachment := testjsonl.ClaudeQueuedCommandJSON( "also do X", tsZeroS2, ) assistant2 := `{"type":"assistant","uuid":"a2",` + `"parentUuid":"a1","timestamp":` + `"2024-01-01T00:00:03Z","message":{"content":[` + `{"type":"text","text":"done"}]}}` content := testjsonl.JoinJSONL( userLine, assistant1, attachment, assistant2, ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) require.Len(t, msgs, 4) assert.Equal(t, "also do X", msgs[2].Content) assert.Equal(t, "queued_command", msgs[2].SourceSubtype) assert.Equal(t, 2, sess.UserMessageCount) }) } func TestParseClaudeSession_ParentSessionID(t *testing.T) { t.Run("sessionId != fileId sets ParentSessionID", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserWithSessionIDJSON("hello", tsZero, "parent-uuid"), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "hi"}, }, tsZeroS1), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, "parent-uuid", sess.ParentSessionID) }) t.Run("sessionId == fileId yields empty ParentSessionID", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserWithSessionIDJSON("hello", tsZero, "test"), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Empty(t, sess.ParentSessionID) }) t.Run("no sessionId field yields empty ParentSessionID", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsZero), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Empty(t, sess.ParentSessionID) }) } func TestParseClaudeSessionFrom_Incremental(t *testing.T) { t.Parallel() // Build initial content: user + assistant. initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello world", tsEarly), testjsonl.ClaudeAssistantJSON("hi there", tsEarlyS1), ) path := createTestFile(t, "inc-claude.jsonl", initial) // Full parse to get baseline. results, err := parseClaudeSession(path, "proj", "local") require.NoError(t, err) require.NotEmpty(t, results) assert.Equal(t, 2, len(results[0].Messages)) assert.Equal(t, 0, results[0].Messages[0].Ordinal) assert.Equal(t, 1, results[0].Messages[1].Ordinal) // Record file size as the incremental offset. info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append new messages. appended := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("follow up", tsEarlyS5), testjsonl.ClaudeAssistantJSON("got it", tsLate), ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) // Incremental parse from offset. newMsgs, endedAt, _, err := callParseClaudeSessionFrom( path, offset, 2, "", ) require.NoError(t, err) assert.Equal(t, 2, len(newMsgs)) // Ordinals continue from startOrdinal=2. assert.Equal(t, 2, newMsgs[0].Ordinal) assert.Equal(t, RoleUser, newMsgs[0].Role) assert.Contains(t, newMsgs[0].Content, "follow up") assert.Equal(t, 3, newMsgs[1].Ordinal) assert.Equal(t, RoleAssistant, newMsgs[1].Role) assert.Contains(t, newMsgs[1].Content, "got it") assert.False(t, endedAt.IsZero()) } func TestParseClaudeSessionFrom_QueuedCommand(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), testjsonl.ClaudeAssistantJSON("hi", tsEarlyS1), ) path := createTestFile(t, "inc-queued.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append: queued command + assistant turn. appended := testjsonl.JoinJSONL( testjsonl.ClaudeQueuedCommandJSON("plus do X", tsEarlyS5), testjsonl.ClaudeAssistantJSON("done", tsLate), ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 2, "") require.NoError(t, err) require.Len(t, newMsgs, 2) // queued_command first (earlier timestamp), then assistant. assert.Equal(t, RoleUser, newMsgs[0].Role) assert.Equal(t, "plus do X", newMsgs[0].Content) assert.Equal(t, "queued_command", newMsgs[0].SourceSubtype) assert.Equal(t, 2, newMsgs[0].Ordinal) assert.Equal(t, RoleAssistant, newMsgs[1].Role) assert.Equal(t, 3, newMsgs[1].Ordinal) } func TestParseClaudeSessionFrom_QueueOperationPreservesSubagentMapping( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile(t, "inc-subagent.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := strings.Join([]string{ `{"type":"assistant","timestamp":"` + tsEarlyS1 + `","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_agent","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`, `{"type":"queue-operation","operation":"enqueue","timestamp":"` + tsEarlyS5 + `","sessionId":"test-session","content":"{\"task_id\":\"child123\",\"tool_use_id\":\"toolu_agent\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}`, }, "\n") + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 1, "") require.NoError(t, err) require.Len(t, newMsgs, 1) require.Len(t, newMsgs[0].ToolCalls, 1) assert.Equal( t, "agent-child123", newMsgs[0].ToolCalls[0].SubagentSessionID, ) } func TestParseClaudeSessionFrom_SkipsNonMessages( t *testing.T, ) { t.Parallel() // Initial content with a "system" type line mixed in. initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("first", tsEarly), ) path := createTestFile( t, "inc-claude-skip.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append a system line followed by a real message. appended := `{"type":"system","timestamp":"` + tsEarlyS5 + `","message":{}}` + "\n" + testjsonl.ClaudeAssistantJSON("response", tsLate) + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := callParseClaudeSessionFrom( path, offset, 1, "", ) require.NoError(t, err) assert.Equal(t, 1, len(newMsgs)) assert.Equal(t, RoleAssistant, newMsgs[0].Role) assert.Equal(t, 1, newMsgs[0].Ordinal) } func TestParseClaudeSessionFrom_NoNewData(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("only msg", tsEarly), ) path := createTestFile( t, "inc-claude-empty.jsonl", content, ) info, err := os.Stat(path) require.NoError(t, err) // Parse from EOF — should return empty. newMsgs, endedAt, _, err := callParseClaudeSessionFrom( path, info.Size(), 1, "", ) require.NoError(t, err) assert.Empty(t, newMsgs) assert.True(t, endedAt.IsZero()) } func TestParseClaudeSessionFrom_PartialLineAtEOF( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-partial.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append a complete line + a partial (truncated) line. complete := testjsonl.ClaudeAssistantJSON( "complete", tsEarlyS5, ) + "\n" partial := `{"type":"user","timestamp":"` + tsLate f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(complete + partial) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, consumed, err := callParseClaudeSessionFrom( path, offset, 1, "", ) require.NoError(t, err) assert.Equal(t, 1, len(newMsgs)) assert.Equal(t, RoleAssistant, newMsgs[0].Role) // consumed should cover only the complete line, not // the partial one. assert.Equal(t, int64(len(complete)), consumed) } func TestParseClaudeSessionFrom_DAGDetected( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-dag.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append two entries that form a fork: both have the // same parentUuid but different uuids. fork1 := `{"type":"user","uuid":"child-1",` + `"parentUuid":"root-1",` + `"timestamp":"` + tsEarlyS5 + `","message":{"content":"branch A"}}` + "\n" fork2 := `{"type":"assistant","uuid":"child-2",` + `"parentUuid":"root-1",` + `"timestamp":"` + tsLate + `","message":{"content":[` + `{"type":"text","text":"branch B"}]}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(fork1 + fork2) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom( path, offset, 1, "", ) assert.ErrorIs(t, err, ErrDAGDetected) } func TestParseClaudeSessionFrom_DAGBoundaryAgainstStoredLastUUID( t *testing.T, ) { t.Parallel() path := createTestFile( t, "inc-dag-boundary.jsonl", testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ), ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := testjsonl.JoinJSONL( `{"type":"assistant","uuid":"a1","parentUuid":"wrong-parent","timestamp":"` + tsEarlyS5 + `","message":{"content":[{"type":"text","text":"branch"}]}}`, ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended + "\n") require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom( path, offset, 1, "stored-tip", ) assert.ErrorIs(t, err, ErrDAGDetected) } func TestParseClaudeSessionFrom_DAGAcrossNonUUID( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-dag-gap.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append: UUID entry, then a non-UUID entry (no uuid // field), then another UUID entry whose parentUuid // doesn't match the first UUID entry. The non-UUID gap // must not prevent fork detection. line1 := `{"type":"user","uuid":"u1",` + `"parentUuid":"pre",` + `"timestamp":"` + tsEarlyS5 + `","message":{"content":"a"}}` + "\n" noUUID := `{"type":"user",` + `"timestamp":"` + tsLate + `","message":{"content":"gap"}}` + "\n" line2 := `{"type":"assistant","uuid":"u2",` + `"parentUuid":"other",` + `"timestamp":"` + tsLate + `","message":{"content":[` + `{"type":"text","text":"b"}]}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(line1 + noUUID + line2) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom( path, offset, 1, "", ) assert.ErrorIs(t, err, ErrDAGDetected) } func TestParseClaudeSessionFrom_LinearUUID( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-linear-uuid.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append UUID-bearing entries that form a linear chain // (each entry's parentUuid == previous entry's uuid). // This should NOT trigger ErrDAGDetected. line1 := `{"type":"user","uuid":"u1",` + `"parentUuid":"pre-existing",` + `"timestamp":"` + tsEarlyS5 + `","message":{"content":"msg1"}}` + "\n" line2 := `{"type":"assistant","uuid":"u2",` + `"parentUuid":"u1",` + `"timestamp":"` + tsLate + `","message":{"content":[` + `{"type":"text","text":"reply"}]}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(line1 + line2) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, endedAt, _, err := callParseClaudeSessionFrom( path, offset, 1, "", ) require.NoError(t, err) assert.Equal(t, 2, len(newMsgs)) assert.Equal(t, 1, newMsgs[0].Ordinal) assert.Equal(t, 2, newMsgs[1].Ordinal) assert.False(t, endedAt.IsZero()) } // Appended user entry carries toolUseResult.agentId. The incremental // parser should return a typed linkage effect while still parsing the // appended user message. func TestParseClaudeSessionFrom_ToolUseResultAgentIDLinksIncrementally( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-tool-result-agentid.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := `{"type":"user","uuid":"u1",` + `"parentUuid":"pre",` + `"timestamp":"` + tsEarlyS5 + `","message":{"content":[` + `{"type":"tool_result","tool_use_id":"toolu_x",` + `"content":"done"}]},` + `"toolUseResult":{"status":"completed",` + `"agentId":"abc123"}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, links, _, _, err := callParseClaudeSessionFromWithLinks( path, offset, 1, "", ) require.NoError(t, err) assert.Len(t, newMsgs, 1) require.Len(t, links, 1) assert.Equal(t, "toolu_x", links[0].ToolUseID) assert.Equal(t, "agent-abc123", links[0].SubagentSessionID) } func TestParseClaudeSession_ResolvesPersistedToolResultOutput( t *testing.T, ) { t.Parallel() dir := t.TempDir() sessionDir := filepath.Join(dir, "project", "parent-session") resultPath := filepath.Join(sessionDir, "tool-results", "b123.txt") require.NoError(t, os.MkdirAll(filepath.Dir(resultPath), 0o755)) fullOutput := "full output line 1\nfull output line 2\n" require.NoError(t, os.WriteFile(resultPath, []byte(fullOutput), 0o644)) resultPathJSON := mustJSONString(t, resultPath) persistedContentJSON := mustJSONString(t, "\n"+ "Output too large (32B). Full output saved to: "+resultPath+ "\n\nPreview (first 2KB):\npreview only\n") content := strings.Join([]string{ `{"type":"user","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"content":"run it"},"cwd":"/tmp/project"}`, `{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"make logs"}}]}}`, `{"type":"user","timestamp":"2024-01-01T00:00:02Z","uuid":"u2","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":` + persistedContentJSON + `,"is_error":false}]},"toolUseResult":{"persistedOutputPath":` + resultPathJSON + `,"persistedOutputSize":32}}`, }, "\n") sessionPath := filepath.Join(dir, "project", "parent-session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) results, err := parseClaudeSession(sessionPath, "project", "local") require.NoError(t, err) require.Len(t, results, 1) require.Len(t, results[0].Messages, 3) require.Len(t, results[0].Messages[2].ToolResults, 1) got := results[0].Messages[2].ToolResults[0] assert.Equal(t, len(fullOutput), got.ContentLength) assert.Equal(t, fullOutput, DecodeContent(got.ContentRaw)) } func TestParseClaudeSession_PersistedToolResultDoesNotOverwriteSiblings( t *testing.T, ) { t.Parallel() dir := t.TempDir() sessionDir := filepath.Join(dir, "project", "parent-session") resultPath := filepath.Join(sessionDir, "tool-results", "b123.txt") require.NoError(t, os.MkdirAll(filepath.Dir(resultPath), 0o755)) fullOutput := "full persisted output\n" require.NoError(t, os.WriteFile(resultPath, []byte(fullOutput), 0o644)) resultPathJSON := mustJSONString(t, resultPath) persistedContentJSON := mustJSONString(t, "\n"+ "Output too large. Full output saved to: "+resultPath+ "\n\nPreview (first 2KB):\npreview only\n") content := strings.Join([]string{ `{"type":"user","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"content":"run it"},"cwd":"/tmp/project"}`, `{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_big","name":"Bash","input":{"command":"make logs"}},{"type":"tool_use","id":"toolu_small","name":"Read","input":{"file_path":"README.md"}}]}}`, `{"type":"user","timestamp":"2024-01-01T00:00:02Z","uuid":"u2","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_big","content":` + persistedContentJSON + `,"is_error":false},{"type":"tool_result","tool_use_id":"toolu_small","content":"small inline result","is_error":false}]},"toolUseResult":{"persistedOutputPath":` + resultPathJSON + `,"persistedOutputSize":22}}`, }, "\n") sessionPath := filepath.Join(dir, "project", "parent-session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) results, err := parseClaudeSession(sessionPath, "project", "local") require.NoError(t, err) require.Len(t, results, 1) require.Len(t, results[0].Messages, 3) require.Len(t, results[0].Messages[2].ToolResults, 2) toolResults := results[0].Messages[2].ToolResults assert.Equal(t, fullOutput, DecodeContent(toolResults[0].ContentRaw)) assert.Equal(t, "small inline result", DecodeContent(toolResults[1].ContentRaw)) assert.Equal(t, len("small inline result"), toolResults[1].ContentLength) } func mustJSONString(t *testing.T, value string) string { t.Helper() encoded, err := json.Marshal(value) require.NoError(t, err) return string(encoded) } // Two appended assistant entries with the same message.id form a // run that the full parser merges into one message; the incremental // path would otherwise produce two separate stored messages, so it // must signal full-parse fallback. func TestParseClaudeSessionFrom_SameMessageIDFallsBack(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-same-msgid.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() a1 := `{"type":"assistant","uuid":"a1",` + `"parentUuid":"pre",` + `"timestamp":"` + tsEarlyS5 + `","message":{"id":"msg_run","content":[` + `{"type":"text","text":"Hi"}]}}` + "\n" a2 := `{"type":"assistant","uuid":"a2",` + `"parentUuid":"a1",` + `"timestamp":"` + tsLate + `","message":{"id":"msg_run","content":[` + `{"type":"text","text":"Hi there"}]}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(a1 + a2) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom(path, offset, 1, "") assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse) assert.True(t, IsIncrementalFullParseFallback(err)) } func TestParseClaudeSessionFrom_QueueOperationOnlyFallsBack(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( `{"type":"user","uuid":"u1","timestamp":"`+tsEarly+`","message":{"content":"go"}}`, `{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"`+tsEarlyS5+`","message":{"id":"msg_queue","content":[{"type":"tool_use","id":"toolu_queue","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`, ) path := createTestFile( t, "inc-queue-only.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := `{"type":"queue-operation","operation":"enqueue","timestamp":"` + tsLate + `","sessionId":"queued-subagent","content":"{\"task_id\":\"childqueue\",\"tool_use_id\":\"toolu_queue\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom(path, offset, 2, "a1") assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse) assert.True(t, IsIncrementalFullParseFallback(err)) } func TestParseClaudeSessionFrom_ProgressOnlyFallsBack(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( `{"type":"user","uuid":"u1","timestamp":"`+tsEarly+`","message":{"content":"go"}}`, `{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"`+tsEarlyS5+`","message":{"id":"msg_progress","content":[{"type":"tool_use","id":"toolu_progress","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}]}}`, ) path := createTestFile( t, "inc-progress-only.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := `{"type":"progress","timestamp":"` + tsLate + `","parentToolUseID":"toolu_progress","data":{"type":"agent_progress","agentId":"childprogress"}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = callParseClaudeSessionFrom(path, offset, 2, "a1") assert.ErrorIs(t, err, ErrClaudeIncrementalNeedsFullParse) assert.True(t, IsIncrementalFullParseFallback(err)) } // Sanity: a benign incremental append (one user, one assistant // with a unique message.id, no toolUseResult.agentId) must NOT // trigger fallback. func TestParseClaudeSessionFrom_BenignAppendNoFallback(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsEarly), ) path := createTestFile( t, "inc-benign.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() u := `{"type":"user","uuid":"u1",` + `"parentUuid":"pre",` + `"timestamp":"` + tsEarlyS5 + `","message":{"content":"more input"}}` + "\n" a := `{"type":"assistant","uuid":"a1","parentUuid":"u1",` + `"timestamp":"` + tsLate + `","message":{"id":"msg_unique","content":[` + `{"type":"text","text":"reply"}]}}` + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(u + a) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := callParseClaudeSessionFrom(path, offset, 1, "") require.NoError(t, err) assert.Len(t, newMsgs, 2) } func TestParseClaudeSession_TerminationStatus(t *testing.T) { t.Run("clean", func(t *testing.T) { content := loadFixture(t, "claude/valid_session.jsonl") sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, TerminationClean, sess.TerminationStatus) }) t.Run("tool_call_pending", func(t *testing.T) { content := loadFixture(t, "claude/tool_call_pending.jsonl") sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, TerminationToolCallPending, sess.TerminationStatus) }) t.Run("truncated", func(t *testing.T) { content := loadFixture(t, "claude/truncated.jsonl") sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, TerminationTruncated, sess.TerminationStatus) }) } func TestParseClaudeSession_TokenUsage(t *testing.T) { t.Run("explicit parser presence beats fallback inference", func(t *testing.T) { msg := ParsedMessage{ TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":50}`), tokenPresenceKnown: true, } msgHasCtx, msgHasOut := msg.TokenPresence() assert.False(t, msgHasCtx) assert.False(t, msgHasOut) sess := ParsedSession{ TotalOutputTokens: 50, PeakContextTokens: 100, aggregateTokenPresenceKnown: true, } sessHasTotal, sessHasPeak := sess.AggregateTokenPresence() assert.False(t, sessHasTotal) assert.False(t, sessHasPeak) }) t.Run("per-message token fields from fixture", func(t *testing.T) { content := loadFixture(t, "claude/valid_session.jsonl") _, msgs := runClaudeParserTest(t, "test.jsonl", content) // msgs[0] is user (no usage), msgs[1] is assistant (has usage), // msgs[2] is user (no usage), msgs[3] is assistant (has usage). assert.Equal(t, 0, msgs[0].ContextTokens) assert.Equal(t, 0, msgs[0].OutputTokens) assert.False(t, msgs[0].HasContextTokens) assert.False(t, msgs[0].HasOutputTokens) assert.Empty(t, msgs[0].Model) assert.Empty(t, msgs[0].TokenUsage) // input=100, cache_creation=200, cache_read=300 -> context=600 assert.Equal(t, 600, msgs[1].ContextTokens) assert.Equal(t, 50, msgs[1].OutputTokens) assert.True(t, msgs[1].HasContextTokens) assert.True(t, msgs[1].HasOutputTokens) assert.Equal(t, "claude-sonnet-4-20250514", msgs[1].Model) assert.Contains(t, string(msgs[1].TokenUsage), `"input_tokens":100`) assert.Equal(t, 0, msgs[2].ContextTokens) assert.Equal(t, 0, msgs[2].OutputTokens) assert.False(t, msgs[2].HasContextTokens) assert.False(t, msgs[2].HasOutputTokens) // input=150, cache_creation=0, cache_read=500 -> context=650 assert.Equal(t, 650, msgs[3].ContextTokens) assert.Equal(t, 75, msgs[3].OutputTokens) assert.True(t, msgs[3].HasContextTokens) assert.True(t, msgs[3].HasOutputTokens) assert.Equal(t, "claude-sonnet-4-20250514", msgs[3].Model) assert.Contains(t, string(msgs[3].TokenUsage), `"input_tokens":150`) }) t.Run("session totals from fixture", func(t *testing.T) { content := loadFixture(t, "claude/valid_session.jsonl") sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 125, sess.TotalOutputTokens) assert.Equal(t, 650, sess.PeakContextTokens) assert.True(t, sess.HasTotalOutputTokens) assert.True(t, sess.HasPeakContextTokens) }) t.Run("messages without usage get zero values", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsZero), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "hi there"}, }, tsZeroS1), ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, 0, msgs[0].ContextTokens) assert.Equal(t, 0, msgs[1].ContextTokens) assert.Equal(t, 0, msgs[1].OutputTokens) assert.False(t, msgs[0].HasContextTokens) assert.False(t, msgs[0].HasOutputTokens) assert.False(t, msgs[1].HasContextTokens) assert.False(t, msgs[1].HasOutputTokens) assert.Empty(t, msgs[1].TokenUsage) assert.Equal(t, 0, sess.TotalOutputTokens) assert.Equal(t, 0, sess.PeakContextTokens) assert.False(t, sess.HasTotalOutputTokens) assert.False(t, sess.HasPeakContextTokens) }) t.Run("zero-valued usage keys preserve coverage", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsZero), `{"type":"assistant","timestamp":"`+tsZeroS1+`","message":{"model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"still counted"}],"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}`, ) sess, msgs := runClaudeParserTest(t, "test.jsonl", content) require.Equal(t, 2, len(msgs)) assert.Equal(t, 0, msgs[1].ContextTokens) assert.Equal(t, 0, msgs[1].OutputTokens) assert.True(t, msgs[1].HasContextTokens) assert.True(t, msgs[1].HasOutputTokens) msgHasCtx, msgHasOut := msgs[1].TokenPresence() assert.True(t, msgHasCtx) assert.True(t, msgHasOut) assert.Equal(t, 0, sess.TotalOutputTokens) assert.Equal(t, 0, sess.PeakContextTokens) assert.True(t, sess.HasTotalOutputTokens) assert.True(t, sess.HasPeakContextTokens) sessHasTotal, sessHasPeak := sess.AggregateTokenPresence() assert.True(t, sessHasTotal) assert.True(t, sessHasPeak) coverageTotal, coveragePeak := sess.TokenCoverage(msgs) assert.True(t, coverageTotal) assert.True(t, coveragePeak) }) } func loadFixture(t *testing.T, name string) string { t.Helper() path := filepath.Join("testdata", name) data, err := os.ReadFile(path) require.NoError(t, err) return string(data) } func TestTruncateRespectsRuneBoundaries(t *testing.T) { tests := []struct { name string input string maxLen int want string }{ { name: "ASCII within limit", input: "hello", maxLen: 10, want: "hello", }, { name: "ASCII truncated", input: "hello world", maxLen: 5, want: "hello...", }, { name: "multibyte within limit", input: "café", maxLen: 10, want: "café", }, { name: "multibyte at boundary", // 4 runes: c, a, f, é — truncate at 3 runes input: "café", maxLen: 3, want: "caf...", }, { name: "CJK characters", // 3 runes, each 3 bytes input: "你好世界", maxLen: 2, want: "你好...", }, { name: "ellipsis character preserved", // U+2026 is 3 bytes but 1 rune input: "abc\u2026def", maxLen: 4, want: "abc\u2026...", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := truncate(tc.input, tc.maxLen) if got != tc.want { t.Errorf( "truncate(%q, %d) = %q, want %q", tc.input, tc.maxLen, got, tc.want, ) } // Verify result is valid UTF-8. if !utf8.ValidString(got) { t.Errorf( "truncate produced invalid UTF-8: %q", got, ) } }) } } func TestParseClaudeSession_ExtractsMessageIDAndRequestID(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sess-1.jsonl") // Single assistant line with usage + id + requestId. line := `{"type":"assistant","uuid":"u1","parentUuid":"",` + `"timestamp":"2026-04-10T10:00:00.000Z",` + `"requestId":"req_01ABC",` + `"message":{"id":"msg_01XYZ","model":"claude-opus-4-6",` + `"content":[{"type":"text","text":"hi"}],` + `"usage":{"input_tokens":10,"output_tokens":20,` + `"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}` if err := os.WriteFile(path, []byte(line+"\n"), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } results, err := parseClaudeSession(path, "proj", "m") if err != nil { t.Fatalf("parse: %v", err) } if len(results) != 1 { t.Fatalf("results = %d, want 1", len(results)) } msgs := results[0].Messages if len(msgs) != 1 { t.Fatalf("messages = %d, want 1", len(msgs)) } m := msgs[0] if m.ClaudeMessageID != "msg_01XYZ" { t.Errorf("ClaudeMessageID = %q, want msg_01XYZ", m.ClaudeMessageID) } if m.ClaudeRequestID != "req_01ABC" { t.Errorf("ClaudeRequestID = %q, want req_01ABC", m.ClaudeRequestID) } if m.OutputTokens != 20 { t.Errorf("OutputTokens = %d, want 20", m.OutputTokens) } } func TestParseClaudeSession_SameMessageIDStreamingSnapshots(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_stream","content":[{"type":"text","text":"Work"}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_stream","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_same","name":"Agent","input":{"description":"same","subagent_type":"Explore","prompt":"same"}}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_stream","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_same","name":"Agent","input":{"description":"same","subagent_type":"Explore","prompt":"same"}}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"tool_use"}}`, } _, msgs := runClaudeParserTest( t, "same-message-streaming.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] assert.Equal(t, "Working\n[Task: same (Explore)]", msg.Content) assert.Equal(t, 3, msg.OutputTokens) assert.Len(t, msg.ToolCalls, 1) assert.Equal(t, "toolu_same", msg.ToolCalls[0].ToolUseID) } // Cumulative snapshots where the same tool_use id appears with // progressively more complete input JSON across snapshots. Without // id-keyed dedup these would each be a distinct raw block and produce // duplicate tool calls; the latest snapshot must win. func TestParseClaudeSession_SameMessageIDProgressiveToolUseInput(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_grow","content":[{"type":"text","text":"Work"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"insp"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_grow","content":[{"type":"text","text":"Working"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"inspect","subagent_type":"Explore"}}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_grow","content":[{"type":"text","text":"Working on it"},{"type":"tool_use","id":"toolu_grow","name":"Agent","input":{"description":"inspect schema","subagent_type":"Explore","prompt":"inspect the schema"}}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"tool_use"}}`, } _, msgs := runClaudeParserTest( t, "progressive-tool-input.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] require.Len(t, msg.ToolCalls, 1) tc := msg.ToolCalls[0] assert.Equal(t, "toolu_grow", tc.ToolUseID) assert.JSONEq(t, `{"description":"inspect schema","subagent_type":"Explore","prompt":"inspect the schema"}`, tc.InputJSON) assert.Equal(t, 3, msg.OutputTokens) assert.Contains(t, msg.Content, "Working on it") } // Additive same-message.id chunks where each chunk has one distinct // text block. Ordinal-only matching would have collapsed all three // to whichever was longest; cumulative-vs-additive detection sees // that each chunk fails the leading-block alignment check and // appends them as distinct content. func TestParseClaudeSession_SameMessageIDAdditiveDistinctTextBlocks(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_add","content":[{"type":"text","text":"First sentence."}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"end_turn"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_add","content":[{"type":"text","text":"Second sentence."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_add","content":[{"type":"text","text":"Third sentence."}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"end_turn"}}`, } _, msgs := runClaudeParserTest( t, "additive-distinct-text.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] require.Equal(t, RoleAssistant, msg.Role) assert.Contains(t, msg.Content, "First sentence.") assert.Contains(t, msg.Content, "Second sentence.") assert.Contains(t, msg.Content, "Third sentence.") } // Two single-text-block additive chunks where the second's text // begins byte-for-byte with the first AND the first chunk's // stop_reason="end_turn" marks the message as already terminated. // Subsequent same-message.id chunks are additive rather than // streaming snapshots, so the prefix relationship must NOT be // classified as cumulative growth. func TestParseClaudeSession_SameMessageIDAdditivePrefixCollidingSingleBlocks(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_pref","content":[{"type":"text","text":"First."}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"end_turn"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_pref","content":[{"type":"text","text":"First. Continued."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`, } _, msgs := runClaudeParserTest( t, "additive-prefix-single.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] require.Equal(t, RoleAssistant, msg.Role) assert.Contains(t, msg.Content, "First.") assert.Contains(t, msg.Content, "First. Continued.") } // Two single-text-block snapshots where the second extends the // first as a byte-for-byte prefix and neither has finalized the // message (no stop_reason="end_turn" in the first snapshot). The // merge must treat this as cumulative streaming and REPLACE the // partial snapshot — not concatenate as additive content. func TestParseClaudeSession_SameMessageIDStreamingSingleBlockReplaces(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_partial","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_partial","content":[{"type":"text","text":"Hello world"}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`, } _, msgs := runClaudeParserTest( t, "streaming-single-block.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] require.Equal(t, RoleAssistant, msg.Role) // Exact equality: the partial "Hello" must be replaced by the // final "Hello world", not joined as "Hello\nHello world". assert.Equal(t, "Hello world", msg.Content) } // Same-message.id chunk merging round-trips the base entry's JSON // through Unmarshal/Marshal to swap in the merged content blocks. // json.Unmarshal decodes numbers as float64 by default, which // truncates integers larger than 2^53. Use json.Decoder.UseNumber // to preserve the raw textual form so large integer fields (e.g. // future large token-count metrics) survive the merge. func TestParseClaudeSession_SameMessageIDPreservesLargeIntegers(t *testing.T) { // 9999999999999999 > 2^53 — float64 cannot represent it exactly. const bigInt = "9999999999999999" lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_big","content":[{"type":"text","text":"Hi"}],"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":` + bigInt + `},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_big","content":[{"type":"text","text":"Hi there"}],"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":` + bigInt + `},"stop_reason":"end_turn"}}`, } _, msgs := runClaudeParserTest( t, "big-int.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) assert.Contains( t, string(msgs[1].TokenUsage), bigInt, "large integer in usage must survive the merge "+ "round-trip; got %s", string(msgs[1].TokenUsage), ) } // Cumulative streaming snapshots of one response that contains // text + tool_use + text. The third block in the second snapshot // happens to start with the first text block's exact bytes. Snapshot- // level cumulative detection keeps both text blocks separately; the // prior prefix heuristic would have collapsed the pre-tool intro into // the post-tool follow-up. func TestParseClaudeSession_SameMessageIDPrefixCollidingTextBlocks(t *testing.T) { lines := []string{ `{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"hello"},"cwd":"/tmp"}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_prefix","content":[{"type":"text","text":"Looking at this"},{"type":"tool_use","id":"toolu_one","name":"Read","input":{"file_path":"/tmp/foo.txt"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`, `{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_prefix","content":[{"type":"text","text":"Looking at this"},{"type":"tool_use","id":"toolu_one","name":"Read","input":{"file_path":"/tmp/foo.txt"}},{"type":"text","text":"Looking at this code carefully."}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`, } _, msgs := runClaudeParserTest( t, "prefix-colliding-text.jsonl", testjsonl.JoinJSONL(lines...), ) require.Len(t, msgs, 2) msg := msgs[1] require.Equal(t, RoleAssistant, msg.Role) // Both distinct text blocks must survive — the second is // post-tool follow-up, not a streaming continuation of the first. assert.Equal(t, 2, strings.Count(msg.Content, "Looking at this"), "both distinct text blocks should appear in merged content; got %q", msg.Content) assert.Contains(t, msg.Content, "Looking at this code carefully.") require.Len(t, msg.ToolCalls, 1) assert.Equal(t, "toolu_one", msg.ToolCalls[0].ToolUseID) } func TestParseClaudeSession_CompactBoundary(t *testing.T) { t.Parallel() compactEntry := `{"type":"user","isCompactSummary":true,` + `"message":{"role":"user","content":[` + `{"type":"text","text":"Summary of conversation so far..."},` + `{"type":"text","text":"Additional context."}` + `]},"uuid":"compact-uuid","parentUuid":"parent-uuid",` + `"isSidechain":true,"timestamp":"` + tsZeroS1 + `"}` t.Run("linear path", func(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("hello", tsZero), compactEntry, testjsonl.ClaudeUserJSON("after compact", tsZeroS2), ) sess, msgs := runClaudeParserTest( t, "test.jsonl", content, ) require.Len(t, msgs, 3) assert.Equal(t, 3, sess.MessageCount) // Only real user messages count. assert.Equal(t, 2, sess.UserMessageCount) assert.Equal(t, "hello", sess.FirstMessage) // Verify compact boundary at ordinal 1. cb := msgs[1] assert.Equal(t, 1, cb.Ordinal) assert.Equal(t, RoleAssistant, cb.Role) assert.True(t, cb.IsSystem) assert.True(t, cb.IsCompactBoundary) assert.Equal(t, "system", cb.SourceType) assert.Equal(t, "compact_boundary", cb.SourceSubtype) assert.Equal(t, "compact-uuid", cb.SourceUUID) assert.Equal(t, "parent-uuid", cb.SourceParentUUID) assert.True(t, cb.IsSidechain) assert.Equal( t, "Summary of conversation so far...\n"+ "Additional context.", cb.Content, ) assert.Equal(t, len(cb.Content), cb.ContentLength) // Following message has ordinal 2. assert.Equal(t, 2, msgs[2].Ordinal) assert.Equal(t, RoleUser, msgs[2].Role) }) t.Run("DAG path", func(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( `{"type":"user","uuid":"u1","parentUuid":"",`+ `"timestamp":"`+tsZero+`",`+ `"message":{"content":"hello"}}`, `{"type":"user","isCompactSummary":true,`+ `"uuid":"u2","parentUuid":"u1",`+ `"timestamp":"`+tsZeroS1+`",`+ `"isSidechain":false,`+ `"message":{"role":"user","content":[`+ `{"type":"text","text":"DAG compact summary"}`+ `]}}`, `{"type":"user","uuid":"u3","parentUuid":"u2",`+ `"timestamp":"`+tsZeroS2+`",`+ `"message":{"content":"after compact"}}`, ) sess, msgs := runClaudeParserTest( t, "test.jsonl", content, ) require.Len(t, msgs, 3) assert.Equal(t, 2, sess.UserMessageCount) cb := msgs[1] assert.Equal(t, RoleAssistant, cb.Role) assert.True(t, cb.IsSystem) assert.True(t, cb.IsCompactBoundary) assert.Equal(t, "DAG compact summary", cb.Content) assert.Equal(t, "u2", cb.SourceUUID) assert.Equal(t, "u1", cb.SourceParentUUID) assert.False(t, cb.IsSidechain) }) t.Run("incremental path", func(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("first", tsEarly), ) path := createTestFile( t, "inc-compact.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append compact boundary + real message. appended := compactEntry + "\n" + testjsonl.ClaudeUserJSON( "after compact", tsEarlyS5, ) + "\n" f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := callParseClaudeSessionFrom( path, offset, 1, "", ) require.NoError(t, err) require.Len(t, newMsgs, 2) cb := newMsgs[0] assert.Equal(t, 1, cb.Ordinal) assert.Equal(t, RoleAssistant, cb.Role) assert.True(t, cb.IsSystem) assert.True(t, cb.IsCompactBoundary) assert.Equal(t, "system", cb.SourceType) assert.Equal(t, "compact_boundary", cb.SourceSubtype) assert.Equal(t, 2, newMsgs[1].Ordinal) assert.Equal(t, RoleUser, newMsgs[1].Role) }) } func TestExtractTextContent_ReturnsThinkingText(t *testing.T) { t.Parallel() t.Run("joins multiple thinking blocks", func(t *testing.T) { content := gjson.Parse(`[ {"type":"thinking","thinking":"first thought"}, {"type":"text","text":"reply A"}, {"type":"thinking","thinking":"second thought"}, {"type":"text","text":"reply B"} ]`) text, thinking, hasThinking, _, _, _ := ExtractTextContent(content) assert.True(t, hasThinking) assert.Equal(t, "first thought\n\nsecond thought", thinking) assert.Contains(t, text, "[Thinking]\nfirst thought\n[/Thinking]") assert.Contains(t, text, "reply A") }) t.Run("skips empty thinking blocks", func(t *testing.T) { content := gjson.Parse(`[ {"type":"thinking","thinking":""}, {"type":"thinking","thinking":"real thought"} ]`) _, thinking, _, _, _, _ := ExtractTextContent(content) assert.Equal(t, "real thought", thinking) }) } func TestClassifyClaudeSystemMessage(t *testing.T) { t.Parallel() cases := []struct { name string content string expected string }{ {"continuation", "This session is being continued from a previous conversation...", "continuation"}, {"resume caveat", "Caveat: ...", "resume"}, {"interrupted", "[Request interrupted by user]", "interrupted"}, {"task notification", "done", "task_notification"}, {"stop hook", "Stop hook feedback: ...", "stop_hook"}, {"bom prefix", "\uFEFF This session is being continued", "continuation"}, {"non-caveat local-command", "foo", ""}, {"regular text", "what do you think?", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := classifyClaudeSystemMessage(c.content) assert.Equal(t, c.expected, got) }) } } func TestParseClaudeSession_PromotesSystemSubtypes(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON("what time is it?", tsZero), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "it is 3pm"}, }, tsZeroS1), // Promoted: continuation marker on resume. testjsonl.ClaudeUserJSON( "This session is being continued from a previous conversation.", tsZeroS2, ), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "welcome back"}, }, "2024-01-01T00:00:03Z"), // Promoted: interrupt marker. testjsonl.ClaudeUserJSON( "[Request interrupted by user]", "2024-01-01T00:00:04Z", ), ) _, msgs := runClaudeParserTest(t, "test.jsonl", content) var systems []ParsedMessage for _, m := range msgs { if m.IsSystem { systems = append(systems, m) } } require.Len(t, systems, 2) assert.Equal(t, "continuation", systems[0].SourceSubtype) assert.Equal(t, "system", systems[0].SourceType) // Role stays "user" so analytics that key off role alone // don't count these markers as assistant replies. The UI uses // is_system + source_subtype for routing. assert.Equal(t, RoleUser, systems[0].Role) assert.Contains(t, systems[0].Content, "This session is being continued") assert.Equal(t, "interrupted", systems[1].SourceSubtype) assert.Equal(t, "system", systems[1].SourceType) assert.Equal(t, RoleUser, systems[1].Role) } func TestIsSkippablePreviewCommand(t *testing.T) { cases := []struct { name string content string want bool }{ // Explicit commands still work. {"bare /clear", "/clear", true}, {"bare /effort", "/effort", true}, {"/clear with trailing space", "/clear ", true}, {"/clear with args", "/clear foo", true}, {"/effort with args", "/effort max", true}, {"surrounded by whitespace", " /clear ", true}, {"/clear with tab", "/clear\tfoo", true}, {"/clear with newline", "/clear\nfoo", true}, // Generic slash commands are now skipped too. {"bare /login", "/login", true}, {"bare /plan", "/plan", true}, {"/plan with args", "/plan my project", true}, {"/clearcache", "/clearcache", true}, {"/effortless", "/effortless", true}, {"/cleareffort", "/cleareffort", true}, {"arbitrary /unrelated", "/unrelated", true}, // Hyphenated and underscored command names are skipped. {"/clear-xyz", "/clear-xyz", true}, {"/roborev-fix", "/roborev-fix", true}, {"/skill_name", "/skill_name", true}, {"/roborev-fix with args", "/roborev-fix some args", true}, // Non-commands are not skipped. {"empty string", "", false}, {"prose containing /clear", "hello /clear", false}, {"file path reference", "/usr/local/bin gives an error", false}, {"plain text", "Fix the login bug", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := isSkippablePreviewCommand(tc.content) assert.Equal(t, tc.want, got, "content=%q", tc.content) }) } } func TestParseClaudeSession_SkipClearEffortFirstMessage(t *testing.T) { t.Run("single /clear followed by real message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "/clear", tsZero, ), testjsonl.ClaudeUserJSON("Fix the login bug", tsZeroS1), testjsonl.ClaudeAssistantJSON([]map[string]any{ {"type": "text", "text": "ok"}, }, tsZeroS2), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, "Fix the login bug", sess.FirstMessage) assert.Equal(t, 2, sess.UserMessageCount, "skipped commands still count as user turns") }) t.Run("cascade /effort then /clear then real", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "/effort\nmax", tsZero, ), testjsonl.ClaudeUserJSON( "/clear", tsZeroS1, ), testjsonl.ClaudeUserJSON("Real question", tsZeroS2), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, "Real question", sess.FirstMessage) assert.Equal(t, 3, sess.UserMessageCount) }) t.Run("all messages are skipped commands", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "/clear", tsZero, ), testjsonl.ClaudeUserJSON( "/effort", tsZeroS1, ), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, "", sess.FirstMessage) assert.Equal(t, 2, sess.UserMessageCount) }) t.Run("hyphenated slash command is skipped, next message becomes first_message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.ClaudeUserJSON( "roborev-fix\n/roborev-fix\n450", tsZero, ), testjsonl.ClaudeUserJSON("follow-up", tsZeroS1), ) sess, _ := runClaudeParserTest(t, "test.jsonl", content) assert.Equal(t, "follow-up", sess.FirstMessage) }) }