@hybridlabor-api/bdb-synapse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,517 @@
1
+ package pi
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter/claudecode"
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter/codex"
12
+ )
13
+
14
+ func writeSession(t *testing.T, lines ...string) string {
15
+ t.Helper()
16
+ dir := t.TempDir()
17
+ path := filepath.Join(dir, "2026-07-21T14-34-07-238Z_test-session.jsonl")
18
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ return path
22
+ }
23
+
24
+ func header(cwd string) string {
25
+ return fmt.Sprintf(`{"type":"session","version":3,"id":"sess-1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, cwd)
26
+ }
27
+
28
+ func TestParseLinearSession(t *testing.T) {
29
+ root := t.TempDir()
30
+ mainGo := filepath.Join(root, "src", "main.go")
31
+ path := writeSession(t,
32
+ header(root),
33
+ `{"type":"model_change","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:07.287Z","provider":"moonshotai","modelId":"kimi-k3"}`,
34
+ `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"fix the bug"}],"timestamp":1784644462960}}`,
35
+ fmt.Sprintf(`{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:25.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"model":"kimi-k3","stopReason":"toolUse"}}`, mainGo),
36
+ `{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-07-21T14:34:26.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"read","content":[{"type":"text","text":"package main"}],"isError":false}}`,
37
+ fmt.Sprintf(`{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-07-21T14:34:27.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"edit","arguments":{"path":%q,"edits":[{"oldText":"a","newText":"b"}]}}],"model":"kimi-k3","stopReason":"toolUse"}}`, mainGo),
38
+ `{"type":"message","id":"e6","parentId":"e5","timestamp":"2026-07-21T14:34:28.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"edit","content":[{"type":"text","text":"oldText not found"}],"isError":true}}`,
39
+ )
40
+
41
+ trace, err := Adapter{}.Parse(path)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if trace.Session.Harness != "pi" || trace.Session.ID != "sess-1" || trace.Session.Cwd != root {
46
+ t.Fatalf("session = %#v", trace.Session)
47
+ }
48
+ if trace.Session.Model != "kimi-k3" {
49
+ t.Fatalf("model = %q", trace.Session.Model)
50
+ }
51
+ if trace.Session.Title != "fix the bug" {
52
+ t.Fatalf("title = %q", trace.Session.Title)
53
+ }
54
+ if trace.Session.StartedAt != "2026-07-21T14:34:07.238Z" || trace.Session.EndedAt != "2026-07-21T14:34:28.000Z" {
55
+ t.Fatalf("times = %q..%q", trace.Session.StartedAt, trace.Session.EndedAt)
56
+ }
57
+ if len(trace.Events) != 2 || trace.Session.EventCount != 2 {
58
+ t.Fatalf("events = %#v", trace.Events)
59
+ }
60
+ read := trace.Events[0]
61
+ if read.Tool != "read" || read.Action != "read" || read.IsError {
62
+ t.Fatalf("read event = %#v", read)
63
+ }
64
+ if len(read.Targets) != 1 || read.Targets[0].Path != "src/main.go" || read.Targets[0].Touch != "read" {
65
+ t.Fatalf("read targets = %#v", read.Targets)
66
+ }
67
+ edit := trace.Events[1]
68
+ if edit.Tool != "edit" || edit.Action != "edit" || !edit.IsError {
69
+ t.Fatalf("edit event = %#v", edit)
70
+ }
71
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "user-message" || trace.Marks[0].Note != "fix the bug" {
72
+ t.Fatalf("marks = %#v", trace.Marks)
73
+ }
74
+ if trace.Stats.UserTurns != 1 || trace.Stats.Errors.Edit != 1 {
75
+ t.Fatalf("stats = %#v", trace.Stats)
76
+ }
77
+ if trace.Stats.Observability.Errors != "exact" {
78
+ t.Fatalf("observability = %#v", trace.Stats.Observability)
79
+ }
80
+ }
81
+
82
+ func TestParsePreservesToolOutcomeCertainty(t *testing.T) {
83
+ root := t.TempDir()
84
+ path := writeSession(t,
85
+ header(root),
86
+ `{"type":"message","id":"a1","parentId":null,"timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"success","name":"read","arguments":{"path":"ok.go"}},{"type":"toolCall","id":"failure","name":"edit","arguments":{"path":"bad.go"}},{"type":"toolCall","id":"unknown","name":"bash","arguments":{"command":"sleep 100"}},{"type":"toolCall","id":"wrong-case","name":"edit","arguments":{"path":"case.go"}}],"stopReason":"toolUse"}}`,
87
+ `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"success","toolName":"read","content":[{"type":"text","text":"ok"}],"isError":false}}`,
88
+ `{"type":"message","id":"r2","parentId":"r1","timestamp":"2026-07-21T14:35:03.000Z","message":{"role":"toolResult","toolCallId":"failure","toolName":"edit","content":[{"type":"text","text":"failed"}],"isError":true}}`,
89
+ `{"type":"message","id":"r3","parentId":"r2","timestamp":"2026-07-21T14:35:04.000Z","message":{"role":"toolResult","toolCallId":"unknown","toolName":"bash","content":[{"type":"text","text":"still running"}]}}`,
90
+ `{"type":"message","id":"r4","parentId":"r3","timestamp":"2026-07-21T14:35:05.000Z","message":{"role":"toolResult","toolCallId":"wrong-case","toolName":"edit","content":[{"type":"text","text":"ok"}],"IsError":true}}`,
91
+ )
92
+
93
+ trace, err := (Adapter{}).Parse(path)
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if len(trace.Events) != 4 {
98
+ t.Fatalf("events = %#v", trace.Events)
99
+ }
100
+ if got := trace.Events[0]; !got.OutcomeKnown || got.IsError {
101
+ t.Fatalf("success event = %#v", got)
102
+ }
103
+ if got := trace.Events[1]; !got.OutcomeKnown || !got.IsError {
104
+ t.Fatalf("failure event = %#v", got)
105
+ }
106
+ if got := trace.Events[2]; got.OutcomeKnown || got.IsError {
107
+ t.Fatalf("unknown event = %#v", got)
108
+ }
109
+ if got := trace.Events[3]; got.OutcomeKnown || got.IsError {
110
+ t.Fatalf("wrong-case error key event = %#v", got)
111
+ }
112
+ if trace.Stats.Observability.Errors != "estimated" {
113
+ t.Fatalf("observability = %#v", trace.Stats.Observability)
114
+ }
115
+ }
116
+
117
+ func TestParseFollowsTrunkAcrossBranches(t *testing.T) {
118
+ root := t.TempDir()
119
+ fileA := filepath.Join(root, "a.go")
120
+ fileB := filepath.Join(root, "b.go")
121
+ path := writeSession(t,
122
+ header(root),
123
+ `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":[{"type":"text","text":"try something"}],"timestamp":1}}`,
124
+ fmt.Sprintf(`{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, fileA),
125
+ `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`,
126
+ `{"type":"branch_summary","id":"b1","parentId":"u1","timestamp":"2026-07-21T14:36:00.000Z","fromId":"r1","summary":"abandoned the first approach"}`,
127
+ fmt.Sprintf(`{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, fileB),
128
+ `{"type":"message","id":"r2","parentId":"a2","timestamp":"2026-07-21T14:36:02.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`,
129
+ )
130
+
131
+ trace, err := Adapter{}.Parse(path)
132
+ if err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if len(trace.Events) != 1 {
136
+ t.Fatalf("events = %#v", trace.Events)
137
+ }
138
+ if got := trace.Events[0].Targets[0].Path; got != "b.go" {
139
+ t.Fatalf("target = %q, want the trunk branch's file", got)
140
+ }
141
+ var types []string
142
+ for _, mark := range trace.Marks {
143
+ types = append(types, mark.Type)
144
+ }
145
+ if len(trace.Marks) != 2 || trace.Marks[0].Type != "user-message" || trace.Marks[1].Type != "compaction" {
146
+ t.Fatalf("marks = %v", types)
147
+ }
148
+ if !strings.HasPrefix(trace.Marks[1].Note, "branch: ") {
149
+ t.Fatalf("branch note = %q", trace.Marks[1].Note)
150
+ }
151
+ }
152
+
153
+ func TestParseV1LinearWithoutIDs(t *testing.T) {
154
+ root := t.TempDir()
155
+ path := writeSession(t,
156
+ fmt.Sprintf(`{"type":"session","version":1,"id":"sess-v1","timestamp":"2026-07-21T14:34:07.238Z","cwd":%q}`, root),
157
+ `{"type":"message","timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`,
158
+ fmt.Sprintf(`{"type":"message","timestamp":"2026-07-21T14:34:23.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"toolUse"}}`, filepath.Join(root, "x.go")),
159
+ `{"type":"message","timestamp":"2026-07-21T14:34:24.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"read","content":[{"type":"text","text":"data"}],"isError":false}}`,
160
+ )
161
+
162
+ trace, err := Adapter{}.Parse(path)
163
+ if err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if len(trace.Events) != 1 || trace.Events[0].Tool != "read" {
167
+ t.Fatalf("events = %#v", trace.Events)
168
+ }
169
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "user-message" {
170
+ t.Fatalf("marks = %#v", trace.Marks)
171
+ }
172
+ }
173
+
174
+ func TestParseRejectsOtherSources(t *testing.T) {
175
+ claudeFile := writeSession(t,
176
+ `{"type":"user","sessionId":"c1","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp","message":{"role":"user","content":"hi"}}`,
177
+ )
178
+ codexFile := writeSession(t,
179
+ `{"type":"session_meta","payload":{"id":"x","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}}`,
180
+ )
181
+ for _, path := range []string{claudeFile, codexFile} {
182
+ if _, err := (Adapter{}).Parse(path); err == nil || !strings.Contains(err.Error(), "not a pi session") {
183
+ t.Fatalf("Parse(%s) err = %v", filepath.Base(path), err)
184
+ }
185
+ if _, err := (Adapter{}).Summarize(path); err == nil {
186
+ t.Fatalf("Summarize(%s) accepted a foreign session", filepath.Base(path))
187
+ }
188
+ }
189
+
190
+ empty := writeSession(t)
191
+ if _, err := (Adapter{}).Parse(empty); err == nil {
192
+ t.Fatal("Parse accepted an empty file")
193
+ }
194
+
195
+ // The reverse direction: the other adapters must not claim a pi file,
196
+ // or trial-parse dispatch would route pi sessions to the wrong source.
197
+ piFile := writeSession(t,
198
+ header(t.TempDir()),
199
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1}}`,
200
+ )
201
+ if _, err := (claudecode.Adapter{}).Parse(piFile); err == nil {
202
+ t.Fatal("claudecode adapter claimed a pi session")
203
+ }
204
+ if _, err := (codex.Adapter{}).Parse(piFile); err == nil {
205
+ t.Fatal("codex adapter claimed a pi session")
206
+ }
207
+ }
208
+
209
+ func TestParseBashExecution(t *testing.T) {
210
+ root := t.TempDir()
211
+ path := writeSession(t,
212
+ header(root),
213
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"bashExecution","command":"npm run build","output":"boom","exitCode":1,"cancelled":false,"truncated":false,"timestamp":1}}`,
214
+ `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:35:10.000Z","message":{"role":"bashExecution","command":"go test ./...","output":"ok","exitCode":0,"cancelled":false,"truncated":false,"timestamp":2}}`,
215
+ `{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:35:20.000Z","message":{"role":"bashExecution","command":"sleep 100","output":"","cancelled":true,"truncated":false,"timestamp":3}}`,
216
+ `{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-07-21T14:35:30.000Z","message":{"role":"bashExecution","command":"false","output":"boom","ExitCode":1,"cancelled":false,"truncated":false,"timestamp":4}}`,
217
+ )
218
+
219
+ trace, err := Adapter{}.Parse(path)
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ if len(trace.Events) != 4 {
224
+ t.Fatalf("events = %#v", trace.Events)
225
+ }
226
+ if trace.Events[0].Tool != "bash" || !trace.Events[0].OutcomeKnown || !trace.Events[0].IsError {
227
+ t.Fatalf("failed command event = %#v", trace.Events[0])
228
+ }
229
+ if trace.Events[1].Action != "verify" || !trace.Events[1].OutcomeKnown || trace.Events[1].IsError {
230
+ t.Fatalf("verify event = %#v", trace.Events[1])
231
+ }
232
+ if trace.Events[2].OutcomeKnown || trace.Events[2].IsError {
233
+ t.Fatalf("cancelled command without exit code has unknown outcome: %#v", trace.Events[2])
234
+ }
235
+ if trace.Events[3].OutcomeKnown || trace.Events[3].IsError {
236
+ t.Fatalf("wrong-case exit code has unknown outcome: %#v", trace.Events[3])
237
+ }
238
+ }
239
+
240
+ func TestParseExtensionChannels(t *testing.T) {
241
+ root := t.TempDir()
242
+ path := writeSession(t,
243
+ header(root),
244
+ `{"type":"custom","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","customType":"my-state","data":{"count":42}}`,
245
+ `{"type":"custom_message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:35:01.000Z","customType":"handoff","content":"resume from the previous session","display":true}`,
246
+ `{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"user","content":[{"type":"text","text":"<system-reminder>injected</system-reminder>"}],"timestamp":1}}`,
247
+ `{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-07-21T14:35:03.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"my_tool","arguments":{"action":"list"}}],"stopReason":"toolUse"}}`,
248
+ `{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-07-21T14:35:04.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"my_tool","content":[{"type":"text","text":"done"}],"isError":false}}`,
249
+ )
250
+
251
+ trace, err := Adapter{}.Parse(path)
252
+ if err != nil {
253
+ t.Fatal(err)
254
+ }
255
+ if len(trace.Marks) != 0 {
256
+ t.Fatalf("extension channels must not produce marks: %#v", trace.Marks)
257
+ }
258
+ if len(trace.Events) != 1 {
259
+ t.Fatalf("events = %#v", trace.Events)
260
+ }
261
+ event := trace.Events[0]
262
+ if event.Tool != "my_tool" || event.Action != "other" || len(event.Targets) != 0 {
263
+ t.Fatalf("extension tool event = %#v", event)
264
+ }
265
+ if trace.Stats.UserTurns != 0 {
266
+ t.Fatalf("injected message counted as a user turn: %#v", trace.Stats)
267
+ }
268
+ }
269
+
270
+ func TestParseFlushesUnpairedToolCalls(t *testing.T) {
271
+ root := t.TempDir()
272
+ path := writeSession(t,
273
+ header(root),
274
+ fmt.Sprintf(`{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"read","arguments":{"path":%q}}],"stopReason":"aborted"}}`, filepath.Join(root, "x.go")),
275
+ )
276
+
277
+ trace, err := Adapter{}.Parse(path)
278
+ if err != nil {
279
+ t.Fatal(err)
280
+ }
281
+ if len(trace.Events) != 1 || trace.Events[0].ResultBytes != 0 || trace.Events[0].IsError {
282
+ t.Fatalf("events = %#v", trace.Events)
283
+ }
284
+ }
285
+
286
+ func TestParseCompactionMark(t *testing.T) {
287
+ root := t.TempDir()
288
+ path := writeSession(t,
289
+ header(root),
290
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":"long task","timestamp":1}}`,
291
+ `{"type":"compaction","id":"e2","parentId":"e1","timestamp":"2026-07-21T15:00:00.000Z","summary":"did things","firstKeptEntryId":"e1","tokensBefore":50000}`,
292
+ )
293
+
294
+ trace, err := Adapter{}.Parse(path)
295
+ if err != nil {
296
+ t.Fatal(err)
297
+ }
298
+ if len(trace.Marks) != 2 || trace.Marks[1].Type != "compaction" {
299
+ t.Fatalf("marks = %#v", trace.Marks)
300
+ }
301
+ if trace.Stats.Compactions != 1 {
302
+ t.Fatalf("stats = %#v", trace.Stats)
303
+ }
304
+ }
305
+
306
+ func TestSummarize(t *testing.T) {
307
+ root := t.TempDir()
308
+ path := writeSession(t,
309
+ header(root),
310
+ `{"type":"model_change","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:07.287Z","provider":"moonshotai","modelId":"kimi-k3"}`,
311
+ `{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"first task"}],"timestamp":1}}`,
312
+ `{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:25.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"bash","arguments":{"command":"ls"}}],"model":"kimi-k3","stopReason":"toolUse"}}`,
313
+ `{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-07-21T14:34:26.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"bash","content":[{"type":"text","text":"ok"}],"isError":false}}`,
314
+ `{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-07-21T14:34:27.000Z","message":{"role":"user","content":[{"type":"text","text":"<turn_aborted>x</turn_aborted>"}],"timestamp":2}}`,
315
+ `{"type":"session_info","id":"e6","parentId":"e5","timestamp":"2026-07-21T14:34:28.000Z","name":"Named session"}`,
316
+ )
317
+
318
+ meta, err := Adapter{}.Summarize(path)
319
+ if err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ if meta.Harness != "pi" || meta.ID != "sess-1" || meta.Cwd != root {
323
+ t.Fatalf("meta = %#v", meta)
324
+ }
325
+ if meta.Title != "Named session" {
326
+ t.Fatalf("title = %q", meta.Title)
327
+ }
328
+ if meta.Model != "kimi-k3" {
329
+ t.Fatalf("model = %q", meta.Model)
330
+ }
331
+ if meta.EventCount != 1 || meta.UserTurns != 1 {
332
+ t.Fatalf("counts = %d events, %d turns", meta.EventCount, meta.UserTurns)
333
+ }
334
+ if meta.StartedAt != "2026-07-21T14:34:07.238Z" || meta.EndedAt != "2026-07-21T14:34:28.000Z" {
335
+ t.Fatalf("times = %q..%q", meta.StartedAt, meta.EndedAt)
336
+ }
337
+ if !strings.HasPrefix(meta.Key, "pi-") {
338
+ t.Fatalf("key = %q", meta.Key)
339
+ }
340
+ }
341
+
342
+ func TestSummarizeTitleFallsBackToFirstUserMessage(t *testing.T) {
343
+ root := t.TempDir()
344
+ path := writeSession(t,
345
+ header(root),
346
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"rename the config loader"}],"timestamp":1}}`,
347
+ )
348
+
349
+ meta, err := Adapter{}.Summarize(path)
350
+ if err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ if meta.Title != "rename the config loader" {
354
+ t.Fatalf("title = %q", meta.Title)
355
+ }
356
+ }
357
+
358
+ func TestListSessions(t *testing.T) {
359
+ dir := t.TempDir()
360
+ project := filepath.Join(dir, "--Users-me-Developer-proj--")
361
+ if err := os.MkdirAll(project, 0o755); err != nil {
362
+ t.Fatal(err)
363
+ }
364
+ session := header("/Users/me/Developer/proj") + "\n" +
365
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":"hi","timestamp":1}}` + "\n"
366
+ if err := os.WriteFile(filepath.Join(project, "a.jsonl"), []byte(session), 0o644); err != nil {
367
+ t.Fatal(err)
368
+ }
369
+ if err := os.WriteFile(filepath.Join(project, "foreign.jsonl"), []byte(`{"type":"session_meta","payload":{}}`+"\n"), 0o644); err != nil {
370
+ t.Fatal(err)
371
+ }
372
+
373
+ metas, err := Adapter{Dir: dir}.ListSessions()
374
+ if err != nil {
375
+ t.Fatal(err)
376
+ }
377
+ if len(metas) != 1 || metas[0].ID != "sess-1" {
378
+ t.Fatalf("metas = %#v", metas)
379
+ }
380
+ }
381
+
382
+ func TestParseAcceptsCwdlessLegacySession(t *testing.T) {
383
+ // pi's own readSessionHeader only requires type "session" plus a string
384
+ // id; legacy sessions can lack a cwd entirely and must still open.
385
+ noCwdField := writeSession(t,
386
+ `{"type":"session","version":1,"id":"legacy-1","timestamp":"2026-07-21T14:34:07.238Z"}`,
387
+ `{"type":"message","timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`,
388
+ )
389
+ emptyCwd := writeSession(t,
390
+ `{"type":"session","version":3,"id":"legacy-2","timestamp":"2026-07-21T14:34:07.238Z","cwd":""}`,
391
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.000Z","message":{"role":"user","content":"hello","timestamp":1}}`,
392
+ )
393
+ for _, path := range []string{noCwdField, emptyCwd} {
394
+ trace, err := (Adapter{}).Parse(path)
395
+ if err != nil {
396
+ t.Fatalf("Parse(%s) rejected a legacy session: %v", filepath.Base(path), err)
397
+ }
398
+ if trace.Session.Cwd != "" {
399
+ t.Fatalf("cwd = %q, want empty", trace.Session.Cwd)
400
+ }
401
+ meta, err := (Adapter{}).Summarize(path)
402
+ if err != nil {
403
+ t.Fatalf("Summarize(%s) rejected a legacy session: %v", filepath.Base(path), err)
404
+ }
405
+ if meta.UserTurns != 1 {
406
+ t.Fatalf("meta = %#v", meta)
407
+ }
408
+ }
409
+
410
+ headerWithoutID := writeSession(t,
411
+ `{"type":"session","version":3,"timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}`,
412
+ )
413
+ if _, err := (Adapter{}).Parse(headerWithoutID); err == nil {
414
+ t.Fatal("Parse accepted a header without an id")
415
+ }
416
+ }
417
+
418
+ func TestSummarizeMatchesParseOnBranchedSession(t *testing.T) {
419
+ root := t.TempDir()
420
+ fileA := filepath.Join(root, "a.go")
421
+ fileB := filepath.Join(root, "b.go")
422
+ path := writeSession(t,
423
+ header(root),
424
+ `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-21T14:35:00.000Z","message":{"role":"user","content":[{"type":"text","text":"try something"}],"timestamp":1}}`,
425
+ fmt.Sprintf(`{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-21T14:35:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"write","arguments":{"path":%q,"content":"x"}}],"stopReason":"toolUse"}}`, fileA),
426
+ `{"type":"message","id":"r1","parentId":"a1","timestamp":"2026-07-21T14:35:02.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`,
427
+ `{"type":"message","id":"u2","parentId":"r1","timestamp":"2026-07-21T14:35:03.000Z","message":{"role":"user","content":[{"type":"text","text":"abandoned follow-up"}],"timestamp":2}}`,
428
+ `{"type":"branch_summary","id":"b1","parentId":"u1","timestamp":"2026-07-21T14:36:00.000Z","fromId":"u2","summary":"left the first attempt"}`,
429
+ fmt.Sprintf(`{"type":"message","id":"a2","parentId":"b1","timestamp":"2026-07-21T14:36:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t2","name":"write","arguments":{"path":%q,"content":"y"}}],"stopReason":"toolUse"}}`, fileB),
430
+ `{"type":"message","id":"r2","parentId":"a2","timestamp":"2026-07-21T14:36:02.000Z","message":{"role":"toolResult","toolCallId":"t2","toolName":"write","content":[{"type":"text","text":"ok"}],"isError":false}}`,
431
+ )
432
+
433
+ trace, err := (Adapter{}).Parse(path)
434
+ if err != nil {
435
+ t.Fatal(err)
436
+ }
437
+ meta, err := (Adapter{}).Summarize(path)
438
+ if err != nil {
439
+ t.Fatal(err)
440
+ }
441
+ // The report badge compares these against the trace; counting abandoned
442
+ // branches would mark every fresh report on a branched session stale.
443
+ if meta.EventCount != trace.Session.EventCount {
444
+ t.Fatalf("summary events = %d, trace events = %d", meta.EventCount, trace.Session.EventCount)
445
+ }
446
+ if meta.UserTurns != trace.Stats.UserTurns {
447
+ t.Fatalf("summary turns = %d, trace turns = %d", meta.UserTurns, trace.Stats.UserTurns)
448
+ }
449
+ if meta.Title != trace.Session.Title {
450
+ t.Fatalf("summary title = %q, trace title = %q", meta.Title, trace.Session.Title)
451
+ }
452
+ if meta.EventCount != 1 || meta.UserTurns != 1 {
453
+ t.Fatalf("trunk projection: events = %d, turns = %d", meta.EventCount, meta.UserTurns)
454
+ }
455
+ }
456
+
457
+ func TestSessionTitleClearedByEmptySessionInfo(t *testing.T) {
458
+ root := t.TempDir()
459
+ path := writeSession(t,
460
+ header(root),
461
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"rename the loader"}],"timestamp":1}}`,
462
+ `{"type":"session_info","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:23.000Z","name":"Named"}`,
463
+ `{"type":"session_info","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:24.000Z","name":""}`,
464
+ )
465
+
466
+ meta, err := (Adapter{}).Summarize(path)
467
+ if err != nil {
468
+ t.Fatal(err)
469
+ }
470
+ // An empty session_info name explicitly clears the title in pi; the
471
+ // fallback chain then names the session from the first user message.
472
+ if meta.Title != "rename the loader" {
473
+ t.Fatalf("title = %q", meta.Title)
474
+ }
475
+ trace, err := (Adapter{}).Parse(path)
476
+ if err != nil {
477
+ t.Fatal(err)
478
+ }
479
+ if trace.Session.Title != meta.Title {
480
+ t.Fatalf("trace title = %q, summary title = %q", trace.Session.Title, meta.Title)
481
+ }
482
+ }
483
+
484
+ func TestReadSessionMatchesPiLineSkippingContract(t *testing.T) {
485
+ root := t.TempDir()
486
+ valid := header(root) + "\n" +
487
+ `{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1}}`
488
+
489
+ // pi skips blank and malformed lines without spending the header slot:
490
+ // the first line that parses as JSON must be the header.
491
+ accepted := map[string]string{
492
+ "malformed prefix": "not-json\n" + valid,
493
+ "whitespace prefix": " \n\t\n" + valid,
494
+ "empty string id": `{"type":"session","version":3,"id":"","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}`,
495
+ }
496
+ for name, content := range accepted {
497
+ path := writeSession(t, content)
498
+ if _, err := (Adapter{}).Parse(path); err != nil {
499
+ t.Fatalf("%s: rejected: %v", name, err)
500
+ }
501
+ if _, err := (Adapter{}).Summarize(path); err != nil {
502
+ t.Fatalf("%s: summarize rejected: %v", name, err)
503
+ }
504
+ }
505
+
506
+ rejected := map[string]string{
507
+ "entry before header": `{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":"hi"}}` + "\n" + valid,
508
+ "non-string id": `{"type":"session","version":3,"id":123,"timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}` + "\n" + valid,
509
+ "null id": `{"type":"session","version":3,"id":null,"timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}`,
510
+ }
511
+ for name, content := range rejected {
512
+ path := writeSession(t, content)
513
+ if _, err := (Adapter{}).Parse(path); err == nil || !strings.Contains(err.Error(), "not a pi session") {
514
+ t.Fatalf("%s: err = %v, want rejection", name, err)
515
+ }
516
+ }
517
+ }