package parser import ( "context" "errors" "fmt" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) // runPiParserTest creates a temp file with the given JSONL content and // parses it as a pi session. The project is hard-coded to "my_project" so // callers can verify downstream behavior without dealing with cwd extraction. func runPiParserTest(t *testing.T, content string) (*ParsedSession, []ParsedMessage) { t.Helper() path := createTestFile(t, "pi-session.jsonl", content) sess, msgs, err := parsePiTestSession(t, path, "my_project", "local") require.NoError(t, err) return sess, msgs } func parsePiTestSession( t *testing.T, path string, project string, machine string, ) (*ParsedSession, []ParsedMessage, error) { t.Helper() provider, ok := NewProvider(AgentPi, ProviderConfig{ Roots: []string{filepath.Dir(filepath.Dir(path))}, Machine: machine, }) require.True(t, ok) outcome, err := provider.Parse(context.Background(), ParseRequest{ Source: SourceRef{ Provider: AgentPi, Key: path, DisplayPath: path, FingerprintKey: path, ProjectHint: project, Opaque: JSONLSource{ Root: filepath.Dir(filepath.Dir(path)), Path: path, }, }, Machine: machine, }) if err != nil || len(outcome.Results) == 0 { return nil, nil, err } result := outcome.Results[0].Result return &result.Session, result.Messages, nil } // TestPiProviderParsesSessionHeader verifies that the session-level fields are // populated correctly from the pi fixture header (PRSR-01, PRSR-11, PRSR-10). func TestPiProviderParsesSessionHeader(t *testing.T) { fixturePath := createTestFile( t, "pi-test-session-uuid.jsonl", loadFixture(t, "pi/session.jsonl"), ) sess, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) assert.Equal(t, "pi:pi-test-session-uuid", sess.ID, "PRSR-01: session ID") assert.Equal(t, AgentPi, sess.Agent, "PRSR-11: agent type") assert.Equal(t, "/Users/alice/code/my-project", sess.Cwd, "PRSR-01: cwd from session header") // ExtractProjectFromCwd("/Users/alice/code/my-project") -> "my_project" assert.Equal(t, "my_project", sess.Project, "PRSR-01: project from cwd") // branchedFrom basename without extension, prefixed (PRSR-10) assert.Equal( t, "pi:2025-01-01T09-00-00-000Z_parent-uuid", sess.ParentSessionID, "PRSR-10: parent session ID", ) assert.Greater(t, sess.MessageCount, 0, "PRSR-01: message count > 0") assert.False(t, sess.StartedAt.IsZero(), "PRSR-01: StartedAt non-zero") _ = msgs // not the focus of this sub-test } func TestPiProviderParsesSessionInfoName(t *testing.T) { content := strings.Join([]string{ `{"type":"session","version":3,"id":"named-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}`, `{"type":"session_info","id":"info-1","parentId":null,"timestamp":"2025-01-01T10:00:01Z","name":"Original name"}`, `{"type":"message","id":"msg-1","parentId":"info-1","timestamp":"2025-01-01T10:00:02Z","message":{"role":"user","content":"hello"}}`, `{"type":"session_info","id":"info-2","parentId":"msg-1","timestamp":"2025-01-01T10:00:03Z","name":"Renamed session"}`, "", }, "\n") sess, msgs := runPiParserTest(t, content) assert.Equal(t, "Renamed session", sess.SessionName) assert.Equal(t, 1, sess.MessageCount, "session_info entries must not count as messages") require.Len(t, msgs, 1) assert.Equal(t, RoleUser, msgs[0].Role) } func TestPiProviderParsesSessionInfoLastNameWins(t *testing.T) { content := strings.Join([]string{ `{"type":"session","version":3,"id":"renamed-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}`, `{"type":"session_info","id":"info-1","parentId":null,"timestamp":"2025-01-01T10:00:01Z","name":"Initial name"}`, `{"type":"session_info","id":"info-2","parentId":"info-1","timestamp":"2025-01-01T10:00:02Z","name":"Second name"}`, `{"type":"session_info","id":"info-3","parentId":"info-2","timestamp":"2025-01-01T10:00:03Z","name":"Final name"}`, `{"type":"message","id":"msg-1","parentId":"info-3","timestamp":"2025-01-01T10:00:04Z","message":{"role":"user","content":"hello"}}`, "", }, "\n") sess, _ := runPiParserTest(t, content) assert.Equal(t, "Final name", sess.SessionName) } // TestPiProviderParsesOMPTitleSlot verifies that OMP's fixed-width title // slot line before the session header is skipped and its title becomes the // session name (issue #959: OMP v16.3+ session files start with the slot, // not the session header). func TestPiProviderParsesOMPTitleSlot(t *testing.T) { content := strings.Join([]string{ `{"type":"title","v":1,"title":"Build Bloom filter research artifact","source":"auto","updatedAt":"2026-07-03T06:32:44.479Z","pad":" "}`, `{"type":"session","version":3,"id":"omp-sess","timestamp":"2026-07-03T06:30:58.508Z","cwd":"/Users/alice/code/my-project","title":"Follow PROJECT.md instructions","titleSource":"auto"}`, `{"type":"model_change","id":"mc-1","parentId":null,"timestamp":"2026-07-03T06:30:58.610Z","model":"anthropic/claude-opus-4-8"}`, `{"type":"thinking_level_change","id":"tl-1","parentId":"mc-1","timestamp":"2026-07-03T06:30:58.611Z","thinkingLevel":"high"}`, `{"type":"message","id":"msg-1","parentId":"tl-1","timestamp":"2026-07-03T06:31:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`, `{"type":"message","id":"msg-2","parentId":"msg-1","timestamp":"2026-07-03T06:31:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"model":"claude-opus-4-8","usage":{"input":2,"output":4}}}`, "", }, "\n") sess, msgs := runPiParserTest(t, content) assert.Equal(t, "pi:omp-sess", sess.ID) assert.Equal(t, "/Users/alice/code/my-project", sess.Cwd) assert.Equal(t, "Build Bloom filter research artifact", sess.SessionName, "title slot title should become the session name") assert.Equal(t, 2, sess.MessageCount, "the title slot must not count as a message") require.Len(t, msgs, 2) assert.Equal(t, "hello", sess.FirstMessage) } // TestPiProviderParsesOMPHeaderTitleFallback verifies that when the title // slot is empty (session not yet titled) the header's auto-generated title // is used instead. func TestPiProviderParsesOMPHeaderTitleFallback(t *testing.T) { content := strings.Join([]string{ `{"type":"title","v":1,"title":"","updatedAt":"2026-07-03T06:30:58.508Z","pad":" "}`, `{"type":"session","version":3,"id":"omp-sess","timestamp":"2026-07-03T06:30:58.508Z","cwd":"/Users/alice/code/my-project","title":"Header auto title","titleSource":"auto"}`, `{"type":"message","id":"msg-1","parentId":null,"timestamp":"2026-07-03T06:31:00.000Z","message":{"role":"user","content":"hello"}}`, "", }, "\n") sess, _ := runPiParserTest(t, content) assert.Equal(t, "Header auto title", sess.SessionName) } // TestPiProviderParsesOMPTitleSlotWinsOverSessionInfo pins the title // precedence: the slot is rewritten in place and always holds the current // title, so it outranks session_info renames and the header title. func TestPiProviderParsesOMPTitleSlotWinsOverSessionInfo(t *testing.T) { content := strings.Join([]string{ `{"type":"title","v":1,"title":"Slot title","source":"user","updatedAt":"2026-07-03T06:32:00.000Z","pad":" "}`, `{"type":"session","version":3,"id":"omp-sess","timestamp":"2026-07-03T06:30:58.508Z","cwd":"/Users/alice/code/my-project","title":"Header title"}`, `{"type":"session_info","id":"info-1","parentId":null,"timestamp":"2026-07-03T06:31:00.000Z","name":"Info name"}`, `{"type":"message","id":"msg-1","parentId":"info-1","timestamp":"2026-07-03T06:31:01.000Z","message":{"role":"user","content":"hello"}}`, "", }, "\n") sess, _ := runPiParserTest(t, content) assert.Equal(t, "Slot title", sess.SessionName) } // TestPiProviderParsesOMPTitleSlotWithoutHeader verifies that a file with // only a title slot and no session header is still rejected. func TestPiProviderParsesOMPTitleSlotWithoutHeader(t *testing.T) { path := createTestFile(t, "pi-session.jsonl", `{"type":"title","v":1,"title":"orphan","updatedAt":"2026-07-03T06:30:58.508Z","pad":" "}`+"\n") _, _, err := parsePiTestSession(t, path, "my_project", "local") require.Error(t, err) assert.Contains(t, err.Error(), "not a pi session") } // TestPiProviderParsesUserMessages verifies user message content and ordinals // (PRSR-02, PRSR-01). func TestPiProviderParsesUserMessages(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) sess, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) // First non-toolResult user message at index 0. require.Greater(t, len(msgs), 0, "expected at least one message") assertMessage(t, msgs[0], RoleUser, "Fix the login bug") assert.Equal(t, 0, msgs[0].Ordinal, "first user message ordinal == 0") // sess.FirstMessage should reflect first user text. assert.Contains(t, sess.FirstMessage, "Fix the login bug", "PRSR-01: FirstMessage") } // TestPiProviderParsesAssistantMessages verifies the assistant message with // thinking, text, and tool call (PRSR-03, PRSR-04, PRSR-06). func TestPiProviderParsesAssistantMessages(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) _, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) // entry-2 is the second entry overall (index 1 in messages). var assistantMsg *ParsedMessage for i := range msgs { if msgs[i].Role == RoleAssistant && msgs[i].HasToolUse { assistantMsg = &msgs[i] break } } require.NotNil(t, assistantMsg, "expected assistant message with tool use") assert.Equal(t, RoleAssistant, assistantMsg.Role, "PRSR-03: role") assert.True(t, assistantMsg.HasThinking, "PRSR-06: HasThinking") assert.True(t, assistantMsg.HasToolUse, "PRSR-03/PRSR-04: HasToolUse") require.Len(t, assistantMsg.ToolCalls, 1, "PRSR-04: one tool call") tc := assistantMsg.ToolCalls[0] assert.Equal(t, "read", tc.ToolName, "PRSR-04: tool name") assert.Equal(t, "Read", tc.Category, "PRSR-04: normalized category via NormalizeToolCategory") assert.Equal(t, "toolu_01", tc.ToolUseID, "PRSR-04: tool use ID") assert.Contains(t, tc.InputJSON, "auth.go", "PRSR-04: input JSON contains file path") assert.Contains(t, assistantMsg.Content, "Looking at the auth module.", "assistant text content") // Thinking and tool markers are now emitted inline in Content. assert.Contains(t, assistantMsg.Content, "[Thinking]", "thinking marker in Content") assert.Contains(t, assistantMsg.Content, "[/Thinking]", "thinking end marker in Content") assert.Contains(t, assistantMsg.Content, "Let me analyze this carefully.", "thinking text in Content") assert.Contains(t, assistantMsg.Content, "[Read: auth.go]", "tool use marker in Content") } // TestPiProviderParsesToolResults verifies tool result entries are parsed // correctly (PRSR-05). func TestPiProviderParsesToolResults(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) _, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) var toolResultMsg *ParsedMessage for i := range msgs { if len(msgs[i].ToolResults) > 0 { toolResultMsg = &msgs[i] break } } require.NotNil(t, toolResultMsg, "expected a message with ToolResults") assert.Equal(t, RoleUser, toolResultMsg.Role, "tool result messages use RoleUser") require.Len(t, toolResultMsg.ToolResults, 1, "PRSR-05: one tool result") assert.Equal(t, "toolu_01", toolResultMsg.ToolResults[0].ToolUseID, "PRSR-05: tool use ID") assert.Greater(t, toolResultMsg.ToolResults[0].ContentLength, 0, "PRSR-05: content length > 0") assert.NotEmpty(t, toolResultMsg.ToolResults[0].ContentRaw, "tool result must populate ContentRaw") decoded := DecodeContent(toolResultMsg.ToolResults[0].ContentRaw) assert.Contains(t, decoded, "package auth", "ContentRaw must decode to tool output text") } func TestPiProviderParsesStringContent(t *testing.T) { header := `{"type":"session","id":"str-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" t.Run("assistant string content", func(t *testing.T) { sess, msgs := runPiParserTest(t, header+`{"type":"message","id":"e1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":"plain string response","model":"claude-opus-4-5","provider":"anthropic","stopReason":"stop","timestamp":1735725601000}}`, ) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, RoleAssistant, msgs[0].Role) assert.Equal(t, "plain string response", msgs[0].Content) }) t.Run("tool result string content", func(t *testing.T) { sess, msgs := runPiParserTest(t, header+`{"type":"message","id":"e1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"toolResult","toolCallId":"toolu_99","content":"file contents here","timestamp":1735725601000}}`, ) require.NotNil(t, sess) require.Len(t, msgs, 1) require.Len(t, msgs[0].ToolResults, 1) assert.Equal(t, "toolu_99", msgs[0].ToolResults[0].ToolUseID) assert.Equal(t, len("file contents here"), msgs[0].ToolResults[0].ContentLength) assert.NotEmpty(t, msgs[0].ToolResults[0].ContentRaw) assert.Equal(t, "file contents here", DecodeContent(msgs[0].ToolResults[0].ContentRaw)) }) } // TestPiProviderParsesThinkingBlocks verifies both explicit and redacted // thinking blocks (PRSR-06). func TestPiProviderParsesThinkingBlocks(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) _, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) t.Run("explicit thinking", func(t *testing.T) { // entry-2: thinking field non-empty, redacted=false. var msg *ParsedMessage for i := range msgs { if msgs[i].Role == RoleAssistant && msgs[i].HasThinking && strings.Contains(msgs[i].Content, "Looking at the auth module.") { msg = &msgs[i] break } } require.NotNil(t, msg, "expected explicit-thinking assistant message") assert.True(t, msg.HasThinking, "PRSR-06: HasThinking for explicit block") assert.Contains(t, msg.Content, "[Thinking]\nLet me analyze this carefully.\n[/Thinking]", "explicit thinking text emitted as inline marker") }) t.Run("redacted thinking", func(t *testing.T) { // entry-7: thinking field is empty, redacted=true, thinkingSignature non-empty. var msg *ParsedMessage for i := range msgs { if msgs[i].Role == RoleAssistant && msgs[i].HasThinking && strings.Contains(msgs[i].Content, "Looks good!") { msg = &msgs[i] break } } require.NotNil(t, msg, "expected redacted-thinking assistant message") assert.True(t, msg.HasThinking, "PRSR-06: HasThinking even when thinking field is empty (redacted)") }) } // TestPiProviderParsesUserMessageCount verifies that model_change and // compaction entries are skipped entirely and do not inflate user counts. func TestPiProviderParsesUserMessageCount(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) sess, _, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) // The fixture has 2 real user messages. Metadata rows must not count // as user messages. assert.Equal(t, 2, sess.UserMessageCount, "UserMessageCount must only count real user messages") } // TestPiProviderParsesUserMessageCountEmptyContent verifies that user messages // with non-text or empty payloads are still counted. func TestPiProviderParsesUserMessageCountEmptyContent(t *testing.T) { fixture := `{"type":"session","id":"sess-1","cwd":"/tmp","timestamp":"2025-01-01T10:00:00Z"} {"type":"message","timestamp":"2025-01-01T10:00:00Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"id":"1"} {"type":"message","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":[{"type":"image","source":{"data":"abc"}}]},"id":"2"} {"type":"message","timestamp":"2025-01-01T10:00:02Z","message":{"role":"user","content":""},"id":"3"} {"type":"message","timestamp":"2025-01-01T10:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"response"}]},"id":"4"}` fixturePath := createTestFile(t, "pi-empty-content.jsonl", fixture) sess, _, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) // All 3 user messages should be counted, even those without text content. assert.Equal(t, 3, sess.UserMessageCount, "UserMessageCount must count user messages with empty or non-text content") } // TestPiProviderParsesSilentSkips verifies that the parser silently ignores // malformed JSON, thinking_level_change entries, and unknown future entry types // without returning an error. func TestPiProviderParsesSilentSkips(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) _, _, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err, "parser must succeed despite malformed/unknown lines") } // TestPiProviderParsesV1Session verifies that a session without an id field // derives its session ID from the filename (PRSR-09). func TestPiProviderParsesV1Session(t *testing.T) { v1Content := strings.Join([]string{ `{"type":"session","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/v1-project"}`, `{"type":"message","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`, "", }, "\n") path := createTestFile(t, "v1-session.jsonl", v1Content) sess, _, err := parsePiTestSession(t, path, "v1_project", "local") require.NoError(t, err) assert.Equal(t, "pi:v1-session", sess.ID, "PRSR-09: V1 session ID from filename") } func TestParsePiSession_V1MessageLineageStaysEmpty(t *testing.T) { content := strings.Join([]string{ `{"type":"session","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/v1-project"}`, `{"type":"message","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`, `{"type":"message","timestamp":"2025-01-01T10:00:02Z","message":{"role":"assistant","content":"ok"}}`, "", }, "\n") path := createTestFile(t, "v1-lineage.jsonl", content) sess, msgs, err := parsePiTestSession(t, path, "v1_project", "local") require.NoError(t, err) assert.Equal(t, "pi:v1-lineage", sess.ID) require.Len(t, msgs, 2) for _, msg := range msgs { assert.Empty(t, msg.SourceUUID) assert.Empty(t, msg.SourceParentUUID) } } // TestPiProviderParsesBranchedFrom verifies the exact ParentSessionID value // extracted from the branchedFrom field (PRSR-10). func TestPiProviderParsesBranchedFrom(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) sess, _, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) t.Run("parent session ID from branchedFrom", func(t *testing.T) { assert.Equal( t, "pi:2025-01-01T09-00-00-000Z_parent-uuid", sess.ParentSessionID, "PRSR-10: basename of branchedFrom without .jsonl extension, prefixed", ) }) } // parsePiLikeTestSession parses content as the given pi-family agent // (AgentPi or AgentOMP) so tests can exercise provider-specific header // handling such as OMP's parentSession branch lineage. The project is // hard-coded so callers need not deal with cwd extraction. func parsePiLikeTestSession( t *testing.T, agent AgentType, content string, ) (*ParsedSession, []ParsedMessage) { t.Helper() path := createTestFile(t, "pilike-session.jsonl", content) provider, ok := NewProvider(agent, ProviderConfig{ Roots: []string{filepath.Dir(filepath.Dir(path))}, Machine: "local", }) require.True(t, ok) outcome, err := provider.Parse(context.Background(), ParseRequest{ Source: SourceRef{ Provider: agent, Key: path, DisplayPath: path, FingerprintKey: path, ProjectHint: "my_project", Opaque: JSONLSource{ Root: filepath.Dir(filepath.Dir(path)), Path: path, }, }, Machine: "local", }) require.NoError(t, err) require.Len(t, outcome.Results, 1) result := outcome.Results[0].Result return &result.Session, result.Messages } // TestPiProviderParsesOMPParentSession verifies OMP (Oh My Pi) branch // lineage (kata 9nz9): OMP v3 headers record the parent as parentSession, // a session ID, rather than pi's branchedFrom, a file path. parentSession // is mapped to ParentSessionID with the agent's ID prefix, but only as a // fallback -- branchedFrom keeps winning when present, and upstream pi // sessions ignore parentSession entirely. func TestPiProviderParsesOMPParentSession(t *testing.T) { const ts = `"timestamp":"2026-07-03T06:30:58.508Z"` tests := []struct { name string agent AgentType header string wantPSI string }{ { name: "OMP parentSession only is mapped and prefixed", agent: AgentOMP, header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","parentSession":"parent-abc"}`, wantPSI: "omp:parent-abc", }, { name: "OMP branchedFrom wins over parentSession", agent: AgentOMP, header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","branchedFrom":"/data/2026-07-03T06-00-00-000Z_parent-file.jsonl","parentSession":"parent-abc"}`, wantPSI: "omp:2026-07-03T06-00-00-000Z_parent-file", }, { name: "OMP with neither field yields empty parent", agent: AgentOMP, header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x"}`, wantPSI: "", }, { name: "pi ignores parentSession (branchedFrom-only lineage)", agent: AgentPi, header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","parentSession":"parent-abc"}`, wantPSI: "", }, { name: "pi branchedFrom still maps unchanged", agent: AgentPi, header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","branchedFrom":"/data/2026-07-03T06-00-00-000Z_parent-file.jsonl"}`, wantPSI: "pi:2026-07-03T06-00-00-000Z_parent-file", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { content := strings.Join([]string{ tt.header, `{"type":"message","id":"msg-1","parentId":null,"timestamp":"2026-07-03T06:31:00.000Z","message":{"role":"user","content":"hello"}}`, "", }, "\n") sess, _ := parsePiLikeTestSession(t, tt.agent, content) assert.Equal(t, tt.wantPSI, sess.ParentSessionID) }) } } // TestPiProviderOMPParentSessionMatchesParentID proves the mapped // ParentSessionID resolves: a child OMP session's parentSession header // (the parent's raw session ID) maps to exactly the stored ID of the // parent session, so lineage links up rather than dangling. func TestPiProviderOMPParentSessionMatchesParentID(t *testing.T) { parentContent := strings.Join([]string{ `{"type":"session","version":3,"id":"parent-abc","timestamp":"2026-07-03T06:00:00.000Z","cwd":"/repos/x"}`, `{"type":"message","id":"p1","parentId":null,"timestamp":"2026-07-03T06:00:01.000Z","message":{"role":"user","content":"root"}}`, "", }, "\n") childContent := strings.Join([]string{ `{"type":"session","version":3,"id":"child-def","timestamp":"2026-07-03T06:30:00.000Z","cwd":"/repos/x","parentSession":"parent-abc"}`, `{"type":"message","id":"c1","parentId":null,"timestamp":"2026-07-03T06:30:01.000Z","message":{"role":"user","content":"branch"}}`, "", }, "\n") parent, _ := parsePiLikeTestSession(t, AgentOMP, parentContent) child, _ := parsePiLikeTestSession(t, AgentOMP, childContent) assert.Equal(t, "omp:parent-abc", parent.ID) assert.Equal(t, "omp:child-def", child.ID) assert.Equal(t, parent.ID, child.ParentSessionID, "child parentSession must map to the parent's stored session ID") } func TestParsePiSession_MessageLineageContinuity(t *testing.T) { content := strings.Join([]string{ `{"type":"session","version":3,"id":"tree-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}`, `{"type":"message","id":"u1","parentId":null,"timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":"root"}}`, `{"type":"session_info","id":"info-1","parentId":"u1","timestamp":"2025-01-01T10:00:02Z","name":"Checkpoint"}`, `{"type":"model_change","id":"mc-1","parentId":"info-1","timestamp":"2025-01-01T10:00:03Z","provider":"anthropic","modelId":"claude-opus-4-5"}`, `{"type":"compaction","id":"cmp-1","parentId":"mc-1","timestamp":"2025-01-01T10:00:04Z","summary":"# compacted","firstKeptEntryIndex":0,"tokensBefore":5000}`, `{"type":"message","id":"u2","parentId":"cmp-1","timestamp":"2025-01-01T10:00:05Z","message":{"role":"user","content":"after compaction"}}`, `{"type":"message","id":"a2","parentId":"u2","timestamp":"2025-01-01T10:00:06Z","message":{"role":"assistant","content":"reply"}}`, `{"type":"message","id":"t1","parentId":"a2","timestamp":"2025-01-01T10:00:07Z","message":{"role":"toolResult","toolCallId":"toolu_42","content":"tool output"}}`, `{"type":"message","id":"a3","parentId":"t1","timestamp":"2025-01-01T10:00:08Z","message":{"role":"assistant","content":"after tool result"}}`, "", }, "\n") sess, msgs := runPiParserTest(t, content) assert.Equal(t, "Checkpoint", sess.SessionName) require.Len(t, msgs, 6) assert.Equal(t, "u1", msgs[0].SourceUUID) assert.Empty(t, msgs[0].SourceParentUUID) assert.Equal(t, "user", msgs[0].SourceType) assert.Equal(t, "cmp-1", msgs[1].SourceUUID) assert.Equal(t, "u1", msgs[1].SourceParentUUID) assert.Equal(t, "system", msgs[1].SourceType) assert.Equal(t, "compact_boundary", msgs[1].SourceSubtype) assert.True(t, msgs[1].IsSystem) assert.True(t, msgs[1].IsCompactBoundary) assert.Equal(t, "# compacted", msgs[1].Content) assert.Equal(t, "u2", msgs[2].SourceUUID) assert.Equal(t, "cmp-1", msgs[2].SourceParentUUID) assert.Equal(t, "user", msgs[2].SourceType) assert.Equal(t, "a2", msgs[3].SourceUUID) assert.Equal(t, "u2", msgs[3].SourceParentUUID) assert.Equal(t, "assistant", msgs[3].SourceType) assert.Equal(t, "t1", msgs[4].SourceUUID) assert.Equal(t, "a2", msgs[4].SourceParentUUID) assert.Equal(t, "toolResult", msgs[4].SourceType) require.Len(t, msgs[4].ToolResults, 1) assert.Equal(t, "toolu_42", msgs[4].ToolResults[0].ToolUseID) assert.Equal(t, "a3", msgs[5].SourceUUID) assert.Equal(t, "a2", msgs[5].SourceParentUUID) assert.Equal(t, "assistant", msgs[5].SourceType) } // TestPiProviderParsesIOError verifies that I/O errors encountered after the // session header are surfaced and that the error string contains "reading pi". func TestPiProviderParsesIOError(t *testing.T) { t.Run("error message format contains reading pi", func(t *testing.T) { ioErr := errors.New("disk read failed") err := fmt.Errorf("reading pi %s: %w", "/some/path/session.jsonl", ioErr) assert.Contains(t, err.Error(), "reading pi", "error string must contain 'reading pi'") assert.ErrorIs(t, err, ioErr, "wrapped error must be unwrappable") }) t.Run("lr.Err check does not fire on clean read", func(t *testing.T) { header := `{"type":"session","id":"pipe-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}` + "\n" msg := `{"type":"message","id":"entry-1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" path := createTestFile(t, "pi-clean-read.jsonl", header+msg) sess, msgs, parseErr := parsePiTestSession(t, path, "my_project", "local") require.NoError(t, parseErr, "clean read must not produce an error") require.NotNil(t, sess) assert.Equal(t, "pi:pipe-sess", sess.ID) assert.Len(t, msgs, 1) }) } // TestParsePiAssistantMessage_BlockOrder verifies that interleaved thinking, // text, and tool blocks preserve their order in Content. func TestParsePiAssistantMessage_BlockOrder(t *testing.T) { header := `{"type":"session","id":"order-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" // Assistant message with thinking -> text -> toolCall -> text order. assistant := `{"type":"message","id":"e1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":[` + `{"type":"thinking","thinking":"step one"},` + `{"type":"text","text":"first text"},` + `{"type":"toolCall","id":"t1","name":"bash","arguments":{"command":"ls"}},` + `{"type":"text","text":"second text"}` + `],"model":"claude-opus-4-5","provider":"anthropic","stopReason":"stop","timestamp":1735725601000}}` _, msgs := runPiParserTest(t, header+assistant) require.Len(t, msgs, 1) content := msgs[0].Content // Verify ordering: thinking marker comes before first text, // tool marker comes between first and second text. thinkIdx := strings.Index(content, "[Thinking]") firstTextIdx := strings.Index(content, "first text") toolIdx := strings.Index(content, "[Bash]") secondTextIdx := strings.Index(content, "second text") require.NotEqual(t, -1, thinkIdx, "thinking marker present") require.NotEqual(t, -1, firstTextIdx, "first text present") require.NotEqual(t, -1, toolIdx, "tool marker present") require.NotEqual(t, -1, secondTextIdx, "second text present") assert.Less(t, thinkIdx, firstTextIdx, "thinking before first text") assert.Less(t, firstTextIdx, toolIdx, "first text before tool") assert.Less(t, toolIdx, secondTextIdx, "tool before second text") } func TestFormatPiToolUse(t *testing.T) { tests := []struct { name string tool string argsRaw string want string }{ { name: "read with file_path", tool: "read", argsRaw: `{"file_path":"main.go"}`, want: "[Read: main.go]", }, { name: "bash with command", tool: "bash", argsRaw: `{"command":"ls -la"}`, want: "[Bash]\n$ ls -la", }, { name: "edit with file_path", tool: "edit", argsRaw: `{"file_path":"config.yaml"}`, want: "[Edit: config.yaml]", }, { name: "write with file_path", tool: "write", argsRaw: `{"file_path":"out.txt"}`, want: "[Write: out.txt]", }, { name: "find with pattern", tool: "find", argsRaw: `{"pattern":"*.go"}`, want: "[Find: *.go]", }, { name: "str_replace maps to Edit", tool: "str_replace", argsRaw: `{"file_path":"server.go"}`, want: "[Edit: server.go]", }, { name: "run_command maps to Bash", tool: "run_command", argsRaw: `{"command":"go test"}`, want: "[Bash]\n$ go test", }, { name: "read_file maps to Read", tool: "read_file", argsRaw: `{"file_path":"README.md"}`, want: "[Read: README.md]", }, { name: "unknown tool", tool: "custom_tool", argsRaw: `{}`, want: "[Tool: custom_tool]", }, { name: "empty arguments", tool: "bash", argsRaw: "", want: "[Bash]\n$ ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatPiToolUse(tt.tool, tt.argsRaw) assert.Equal(t, tt.want, got) }) } } // TestParsePiAssistantMessage_IntentInToolMarker verifies that // agent__intent is normalized to description before being passed // to formatPiToolUse, so the Bash description line renders correctly. func TestParsePiAssistantMessage_IntentInToolMarker(t *testing.T) { header := `{"type":"session","id":"intent-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" assistant := `{"type":"message","id":"e1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":[` + `{"type":"toolCall","id":"t1","name":"bash","arguments":{"command":"ls","agent__intent":"List files"}}` + `],"model":"claude-opus-4-5","provider":"anthropic","stopReason":"toolUse","timestamp":1735725601000}}` _, msgs := runPiParserTest(t, header+assistant) require.Len(t, msgs, 1) assert.Contains(t, msgs[0].Content, "[Bash: List files]", "agent__intent must be normalized to description for tool marker") } // TestPiProviderParsesErrorCases verifies error handling for missing, empty, // and invalid session files. func TestNormalizePiIntent(t *testing.T) { tests := []struct { name string in string want string }{ { name: "agent__intent renamed to description", in: `{"command":"ls","agent__intent":"List files"}`, want: `{"description":"List files","command":"ls"}`, }, { name: "_i renamed to description", in: `{"command":"pwd","_i":"Show directory"}`, want: `{"description":"Show directory","command":"pwd"}`, }, { name: "agent__intent preferred over _i", in: `{"command":"ls","agent__intent":"Primary","_i":"Fallback"}`, want: `{"description":"Primary","command":"ls"}`, }, { name: "existing description not overwritten", in: `{"command":"ls","description":"Already set","agent__intent":"Ignored"}`, want: `{"command":"ls","description":"Already set","agent__intent":"Ignored"}`, }, { name: "no intent fields unchanged", in: `{"command":"ls"}`, want: `{"command":"ls"}`, }, { name: "empty string unchanged", in: "", want: "", }, { name: "special characters in intent value properly escaped", in: `{"command":"echo","agent__intent":"Say \"hello\" and \n newline"}`, want: `{"description":"Say \"hello\" and \n newline","command":"echo"}`, }, { name: "backslash and quote escaping in intent", in: `{"agent__intent":"Path: C:\\Users\\test","command":"dir"}`, want: `{"description":"Path: C:\\Users\\test","command":"dir"}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := normalizePiIntent(tt.in) if tt.in == "" { assert.Equal(t, tt.want, got) } else { assert.JSONEq(t, tt.want, got) } }) } } func TestPiProviderParsesErrorCases(t *testing.T) { t.Run("missing file", func(t *testing.T) { _, _, err := parsePiTestSession(t, "/nonexistent/path/session.jsonl", "proj", "local") assert.Error(t, err, "missing file must return error") }) t.Run("empty file", func(t *testing.T) { path := createTestFile(t, "empty.jsonl", "") _, _, err := parsePiTestSession(t, path, "proj", "local") assert.Error(t, err, "empty file (no session header) must return error") }) t.Run("not a pi session", func(t *testing.T) { content := `{"type":"message","id":"entry-1","timestamp":"2025-01-01T10:00:00Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" path := createTestFile(t, "not-pi.jsonl", content) _, _, err := parsePiTestSession(t, path, "proj", "local") assert.Error(t, err, "file without session header must return error") }) t.Run("leading whitespace-only lines", func(t *testing.T) { // Matches IsPiSessionFile behavior which uses TrimSpace to skip // whitespace-only lines before the session header. header := `{"type":"session","id":"ws-sess","timestamp":"2025-06-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}` msg := `{"type":"message","id":"m1","timestamp":"2025-06-01T10:01:00Z","message":{"role":"user","content":"hello"}}` content := " \n\t\n" + header + "\n" + msg + "\n" path := createTestFile(t, "ws-leading.jsonl", content) sess, msgs, err := parsePiTestSession(t, path, "proj", "local") require.NoError(t, err, "whitespace-only leading lines must not cause parse failure") assert.Equal(t, "pi:ws-sess", sess.ID) assert.Len(t, msgs, 1) }) } // TestPiProviderParsesTokenUsageFromFixture verifies that assistant // messages in the standard pi fixture get Model and TokenUsage // populated from the inline message.model and message.usage fields. // Without this, the usage dashboard reports $0 for pi sessions. func TestPiProviderParsesTokenUsageFromFixture(t *testing.T) { fixturePath := createTestFile( t, "pi-session.jsonl", loadFixture(t, "pi/session.jsonl"), ) sess, msgs, err := parsePiTestSession(t, fixturePath, "", "local") require.NoError(t, err) var assistants []ParsedMessage for _, m := range msgs { if m.Role == RoleAssistant && m.SourceType == "assistant" { assistants = append(assistants, m) } } require.GreaterOrEqual(t, len(assistants), 2, "fixture has at least two assistant messages") for i, m := range assistants { assert.Equal(t, "claude-opus-4-5", m.Model, "assistant[%d] model populated from message.model", i) require.NotEmpty(t, m.TokenUsage, "assistant[%d] TokenUsage populated from message.usage", i) } // Fixture: entry-2 input=100,output=50; entry-7 input=200,output=10. wantInputs := []int64{100, 200} wantOutputs := []int64{50, 10} for i, m := range assistants[:2] { gotIn := gjson.GetBytes(m.TokenUsage, "input_tokens").Int() gotOut := gjson.GetBytes(m.TokenUsage, "output_tokens").Int() assert.Equal(t, wantInputs[i], gotIn, "assistant[%d] input_tokens", i) assert.Equal(t, wantOutputs[i], gotOut, "assistant[%d] output_tokens", i) } // Session totals roll up from per-message HasOutputTokens. assert.True(t, sess.HasTotalOutputTokens, "session has rolled up output tokens") assert.Equal(t, 60, sess.TotalOutputTokens, "session TotalOutputTokens = 50 + 10") assert.True(t, sess.HasPeakContextTokens, "session has rolled up context tokens") assert.Equal(t, 200, sess.PeakContextTokens, "session PeakContextTokens = max(100, 200)") } // TestPiProviderParsesModelFromModelChange verifies that when an // assistant message has no inline model field, the parser falls // back to the most recent model_change entry's modelId. func TestPiProviderParsesModelFromModelChange(t *testing.T) { header := `{"type":"session","id":"mc-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" mc := `{"type":"model_change","id":"mc1","timestamp":"2025-01-01T10:00:00.5Z","provider":"openai","modelId":"gpt-5.4"}` + "\n" user := `{"type":"message","id":"u1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":"hi"}}` + "\n" // Assistant without inline model — should inherit from model_change. asst := `{"type":"message","id":"a1","timestamp":"2025-01-01T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input":10,"output":5}}}` _, msgs := runPiParserTest(t, header+mc+user+asst) var asstMsg *ParsedMessage for i := range msgs { if msgs[i].Role == RoleAssistant { asstMsg = &msgs[i] break } } require.NotNil(t, asstMsg, "assistant message present") assert.Equal(t, "gpt-5.4", asstMsg.Model, "assistant model inherited from prior model_change") require.NotEmpty(t, asstMsg.TokenUsage, "token usage extracted from message.usage") } // TestPiProviderParsesUnknownUsageShape verifies that a present // but unrecognized usage object (empty {} or a foreign schema // with none of the keys we know about) leaves TokenUsage empty // so the usage query filter skips the row, rather than // fabricating a zero-valued record. func TestPiProviderParsesUnknownUsageShape(t *testing.T) { header := `{"type":"session","id":"uu-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" cases := []struct { name string usageRaw string }{ {"empty object", `{}`}, {"foreign keys only", `{"totalTokens":42,"promptCount":3}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { asst := `{"type":"message","id":"a1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":"hi","model":"gpt-5.4","usage":` + tc.usageRaw + `}}` _, msgs := runPiParserTest(t, header+asst) require.Len(t, msgs, 1) m := msgs[0] assert.Equal(t, "gpt-5.4", m.Model, "model still populated from message.model") assert.Empty(t, m.TokenUsage, "unrecognized usage shape must leave TokenUsage empty") assert.False(t, m.HasOutputTokens) assert.False(t, m.HasContextTokens) }) } } // TestPiProviderParsesZeroUsage verifies that an explicit usage // block with every counter at zero is preserved as "known // zero" rather than collapsed to "unknown". The normalized // token_usage is still written and coverage flags follow field // presence, matching the claude parser contract and letting // downstream rollups distinguish an errored request from a // missing usage blob. func TestPiProviderParsesZeroUsage(t *testing.T) { header := `{"type":"session","id":"zu-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" asst := `{"type":"message","id":"a1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":"oops","model":"gpt-5.4","usage":{"input":0,"output":0}}}` _, msgs := runPiParserTest(t, header+asst) require.Len(t, msgs, 1) m := msgs[0] assert.Equal(t, "gpt-5.4", m.Model) require.NotEmpty(t, m.TokenUsage, "zero-valued usage object must still write token_usage") assert.Equal(t, int64(0), gjson.GetBytes(m.TokenUsage, "input_tokens").Int()) assert.Equal(t, int64(0), gjson.GetBytes(m.TokenUsage, "output_tokens").Int()) assert.True(t, m.HasOutputTokens, "output field present => HasOutputTokens true even at zero") assert.True(t, m.HasContextTokens, "input field present => HasContextTokens true even at zero") assert.Equal(t, 0, m.OutputTokens) assert.Equal(t, 0, m.ContextTokens) } // TestPiProviderParsesNoUsageNoTokenUsage verifies that messages // without a usage block do not write an empty token_usage row, // since the eligibility filter requires token_usage != ”. func TestPiProviderParsesNoUsageNoTokenUsage(t *testing.T) { header := `{"type":"session","id":"nu-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/tmp"}` + "\n" asst := `{"type":"message","id":"a1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"assistant","content":"hello","model":"claude-opus-4-5"}}` _, msgs := runPiParserTest(t, header+asst) require.Len(t, msgs, 1) assert.Equal(t, "claude-opus-4-5", msgs[0].Model, "model still populated even when usage missing") assert.Empty(t, msgs[0].TokenUsage, "token usage left empty when message.usage absent") }