Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1019 lines
41 KiB
Go

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")
}