Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions internal/parser/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,22 @@ func parsePiLikeSession(
project = ExtractProjectFromCwd(cwd)
}

// branchedFrom handling: store basename without extension.
// Branch lineage. Upstream pi records the parent as branchedFrom, a
// file path whose basename without extension is the parent's session
// ID. OMP (Oh My Pi) v3 headers instead record parentSession, the
// parent's session ID directly. branchedFrom wins when present so
// upstream pi is unchanged; parentSession is the OMP-only fallback.
// Both paths reuse this session's own idPrefix, so the mapped value
// matches the parent's stored ID (idPrefix + its session id) and
// lineage resolves.
var parentSessionID string
branchedFrom := gjson.Get(headerLine, "branchedFrom").Str
if branchedFrom != "" {
if branchedFrom := gjson.Get(headerLine, "branchedFrom").Str; branchedFrom != "" {
base := filepath.Base(branchedFrom)
parentSessionID = idPrefix + strings.TrimSuffix(base, filepath.Ext(base))
} else if agent == AgentOMP {
if parentSession := gjson.Get(headerLine, "parentSession").Str; parentSession != "" {
parentSessionID = idPrefix + parentSession
}
}

// V1 detection: if header has no id, we may need to derive from filename.
Expand Down
117 changes: 117 additions & 0 deletions internal/parser/pi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,123 @@ func TestPiProviderParsesBranchedFrom(t *testing.T) {
})
}

// 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"}`,
Expand Down