Skip to content

Commit 06f456e

Browse files
authored
fix(parser): map OMP parentSession header to ParentSessionID (#995)
OMP (Oh My Pi) v3 session headers record branch lineage as `parentSession` — the parent's session ID — while upstream pi records `branchedFrom`, a file path. `parsePiLikeSession` only read `branchedFrom`, so branched OMP sessions never got a `ParentSessionID` and their lineage never resolved. Follow-up to #980, which made these sessions discoverable in the first place. This maps `header.parentSession` to `ParentSessionID` as an OMP-only fallback when `branchedFrom` is absent, reusing the session's own ID prefix. Because a stored session ID is the ID prefix plus the header's session id, and `parentSession` carries the parent's raw session ID, the mapped value equals the parent's stored ID and lineage resolves with no extra lookup. `branchedFrom` still wins when present, and pi sessions never consult `parentSession`, so upstream pi behavior is unchanged. Where to look: the branch-lineage block in `internal/parser/pi.go`; `TestPiProviderOMPParentSessionMatchesParentID` pins that a branched child's mapped `ParentSessionID` equals its parent's stored ID. Limitation: other OMP-specific entry types observed in the same investigation (`title_change`, `mcp_tool_selection`, `custom`, `custom_message`, `fileMention`) still fall through silently; they carry no lineage information and are deliberately left as-is. Co-authored-by: Matthew Jacobs <mjacobs@users.noreply.github.com>
1 parent 3ce0acd commit 06f456e

2 files changed

Lines changed: 130 additions & 3 deletions

File tree

internal/parser/pi.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,22 @@ func parsePiLikeSession(
8484
project = ExtractProjectFromCwd(cwd)
8585
}
8686

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

95105
// V1 detection: if header has no id, we may need to derive from filename.

internal/parser/pi_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,123 @@ func TestPiProviderParsesBranchedFrom(t *testing.T) {
451451
})
452452
}
453453

454+
// parsePiLikeTestSession parses content as the given pi-family agent
455+
// (AgentPi or AgentOMP) so tests can exercise provider-specific header
456+
// handling such as OMP's parentSession branch lineage. The project is
457+
// hard-coded so callers need not deal with cwd extraction.
458+
func parsePiLikeTestSession(
459+
t *testing.T, agent AgentType, content string,
460+
) (*ParsedSession, []ParsedMessage) {
461+
t.Helper()
462+
path := createTestFile(t, "pilike-session.jsonl", content)
463+
provider, ok := NewProvider(agent, ProviderConfig{
464+
Roots: []string{filepath.Dir(filepath.Dir(path))},
465+
Machine: "local",
466+
})
467+
require.True(t, ok)
468+
outcome, err := provider.Parse(context.Background(), ParseRequest{
469+
Source: SourceRef{
470+
Provider: agent,
471+
Key: path,
472+
DisplayPath: path,
473+
FingerprintKey: path,
474+
ProjectHint: "my_project",
475+
Opaque: JSONLSource{
476+
Root: filepath.Dir(filepath.Dir(path)),
477+
Path: path,
478+
},
479+
},
480+
Machine: "local",
481+
})
482+
require.NoError(t, err)
483+
require.Len(t, outcome.Results, 1)
484+
result := outcome.Results[0].Result
485+
return &result.Session, result.Messages
486+
}
487+
488+
// TestPiProviderParsesOMPParentSession verifies OMP (Oh My Pi) branch
489+
// lineage (kata 9nz9): OMP v3 headers record the parent as parentSession,
490+
// a session ID, rather than pi's branchedFrom, a file path. parentSession
491+
// is mapped to ParentSessionID with the agent's ID prefix, but only as a
492+
// fallback -- branchedFrom keeps winning when present, and upstream pi
493+
// sessions ignore parentSession entirely.
494+
func TestPiProviderParsesOMPParentSession(t *testing.T) {
495+
const ts = `"timestamp":"2026-07-03T06:30:58.508Z"`
496+
tests := []struct {
497+
name string
498+
agent AgentType
499+
header string
500+
wantPSI string
501+
}{
502+
{
503+
name: "OMP parentSession only is mapped and prefixed",
504+
agent: AgentOMP,
505+
header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","parentSession":"parent-abc"}`,
506+
wantPSI: "omp:parent-abc",
507+
},
508+
{
509+
name: "OMP branchedFrom wins over parentSession",
510+
agent: AgentOMP,
511+
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"}`,
512+
wantPSI: "omp:2026-07-03T06-00-00-000Z_parent-file",
513+
},
514+
{
515+
name: "OMP with neither field yields empty parent",
516+
agent: AgentOMP,
517+
header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x"}`,
518+
wantPSI: "",
519+
},
520+
{
521+
name: "pi ignores parentSession (branchedFrom-only lineage)",
522+
agent: AgentPi,
523+
header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","parentSession":"parent-abc"}`,
524+
wantPSI: "",
525+
},
526+
{
527+
name: "pi branchedFrom still maps unchanged",
528+
agent: AgentPi,
529+
header: `{"type":"session","version":3,"id":"child",` + ts + `,"cwd":"/repos/x","branchedFrom":"/data/2026-07-03T06-00-00-000Z_parent-file.jsonl"}`,
530+
wantPSI: "pi:2026-07-03T06-00-00-000Z_parent-file",
531+
},
532+
}
533+
for _, tt := range tests {
534+
t.Run(tt.name, func(t *testing.T) {
535+
content := strings.Join([]string{
536+
tt.header,
537+
`{"type":"message","id":"msg-1","parentId":null,"timestamp":"2026-07-03T06:31:00.000Z","message":{"role":"user","content":"hello"}}`,
538+
"",
539+
}, "\n")
540+
sess, _ := parsePiLikeTestSession(t, tt.agent, content)
541+
assert.Equal(t, tt.wantPSI, sess.ParentSessionID)
542+
})
543+
}
544+
}
545+
546+
// TestPiProviderOMPParentSessionMatchesParentID proves the mapped
547+
// ParentSessionID resolves: a child OMP session's parentSession header
548+
// (the parent's raw session ID) maps to exactly the stored ID of the
549+
// parent session, so lineage links up rather than dangling.
550+
func TestPiProviderOMPParentSessionMatchesParentID(t *testing.T) {
551+
parentContent := strings.Join([]string{
552+
`{"type":"session","version":3,"id":"parent-abc","timestamp":"2026-07-03T06:00:00.000Z","cwd":"/repos/x"}`,
553+
`{"type":"message","id":"p1","parentId":null,"timestamp":"2026-07-03T06:00:01.000Z","message":{"role":"user","content":"root"}}`,
554+
"",
555+
}, "\n")
556+
childContent := strings.Join([]string{
557+
`{"type":"session","version":3,"id":"child-def","timestamp":"2026-07-03T06:30:00.000Z","cwd":"/repos/x","parentSession":"parent-abc"}`,
558+
`{"type":"message","id":"c1","parentId":null,"timestamp":"2026-07-03T06:30:01.000Z","message":{"role":"user","content":"branch"}}`,
559+
"",
560+
}, "\n")
561+
562+
parent, _ := parsePiLikeTestSession(t, AgentOMP, parentContent)
563+
child, _ := parsePiLikeTestSession(t, AgentOMP, childContent)
564+
565+
assert.Equal(t, "omp:parent-abc", parent.ID)
566+
assert.Equal(t, "omp:child-def", child.ID)
567+
assert.Equal(t, parent.ID, child.ParentSessionID,
568+
"child parentSession must map to the parent's stored session ID")
569+
}
570+
454571
func TestParsePiSession_MessageLineageContinuity(t *testing.T) {
455572
content := strings.Join([]string{
456573
`{"type":"session","version":3,"id":"tree-sess","timestamp":"2025-01-01T10:00:00Z","cwd":"/Users/alice/code/my-project"}`,

0 commit comments

Comments
 (0)