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
1019 lines
41 KiB
Go
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")
|
|
}
|