@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,920 @@
1
+ package codex
2
+
3
+ import (
4
+ "encoding/json"
5
+ "math"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/hybridlabor-api/bdb-synapse/internal/judge"
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
12
+ )
13
+
14
+ func TestParseCodexSession(t *testing.T) {
15
+ root := t.TempDir()
16
+ if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Demo\n"), 0o644); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ dir := t.TempDir()
20
+ session := filepath.Join(dir, "rollout-2026-07-09T00-00-00-codex-demo.jsonl")
21
+ index := filepath.Join(dir, "session_index.jsonl")
22
+ writeJSONL(t, index, map[string]any{
23
+ "id": "codex-demo",
24
+ "thread_name": "Codex demo trace",
25
+ "updated_at": "2026-07-09T00:00:09Z",
26
+ })
27
+ writeJSONL(t, session,
28
+ map[string]any{
29
+ "timestamp": "2026-07-09T00:00:00Z",
30
+ "type": "session_meta",
31
+ "payload": map[string]any{
32
+ "id": "codex-demo",
33
+ "session_id": "codex-demo",
34
+ "timestamp": "2026-07-09T00:00:00Z",
35
+ "cwd": filepath.ToSlash(root),
36
+ "git": map[string]any{
37
+ "branch": "main",
38
+ "commit_hash": "abc123",
39
+ },
40
+ },
41
+ },
42
+ map[string]any{
43
+ "timestamp": "2026-07-09T00:00:01Z",
44
+ "type": "turn_context",
45
+ "payload": map[string]any{
46
+ "cwd": filepath.ToSlash(root),
47
+ "model": "gpt-5.5",
48
+ },
49
+ },
50
+ map[string]any{
51
+ "timestamp": "2026-07-09T00:00:02Z",
52
+ "type": "response_item",
53
+ "payload": map[string]any{
54
+ "type": "message",
55
+ "role": "user",
56
+ "content": []any{map[string]any{"type": "input_text", "text": "inspect"}},
57
+ },
58
+ },
59
+ call("2026-07-09T00:00:03Z", "fc-read", "call-read", "exec_command", map[string]any{
60
+ "cmd": "sed -n '1,40p' README.md",
61
+ "workdir": filepath.ToSlash(root),
62
+ }),
63
+ output("2026-07-09T00:00:04Z", "call-read", "Chunk ID: read\nProcess exited with code 0\nOutput:\n# Demo\n"),
64
+ callRaw("2026-07-09T00:00:05Z", "fc-edit", "call-edit", "apply_patch", "*** Begin Patch\n*** Update File: README.md\n@@\n-# Demo\n+# Demo updated\n*** End Patch\n"),
65
+ output("2026-07-09T00:00:06Z", "call-edit", "Success. Updated the following files:\nM README.md\n"),
66
+ call("2026-07-09T00:00:07Z", "fc-test", "call-test", "exec_command", map[string]any{
67
+ "cmd": "go test ./...",
68
+ "workdir": filepath.ToSlash(root),
69
+ }),
70
+ output("2026-07-09T00:00:08Z", "call-test", "Chunk ID: test\nProcess exited with code 1\nOutput:\nFAIL ./...\n"),
71
+ )
72
+
73
+ adapter := Adapter{Dir: dir, IndexPath: index}
74
+ meta, err := adapter.Summarize(session)
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ if meta.ID != "codex-demo" || meta.Harness != "codex" || meta.Title != "Codex demo trace" {
79
+ t.Fatalf("meta = %#v", meta)
80
+ }
81
+ if meta.Model != "gpt-5.5" || meta.GitBranch != "main" || meta.EventCount != 3 {
82
+ t.Fatalf("meta = %#v", meta)
83
+ }
84
+
85
+ trace, err := adapter.Parse(session)
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if trace.Session.ID != "codex-demo" || trace.Session.Harness != "codex" || trace.Session.Commit != "abc123" {
90
+ t.Fatalf("session = %#v", trace.Session)
91
+ }
92
+ if trace.Session.Title != "Codex demo trace" || trace.Session.Model != "gpt-5.5" {
93
+ t.Fatalf("session = %#v", trace.Session)
94
+ }
95
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "user-message" || trace.Marks[0].Note != "inspect" {
96
+ t.Fatalf("marks = %#v", trace.Marks)
97
+ }
98
+ if len(trace.Events) != 3 {
99
+ t.Fatalf("events = %d", len(trace.Events))
100
+ }
101
+ if got := trace.Events[0]; got.Tool != "exec_command" || got.Action != "read" || len(got.Targets) != 1 || got.Targets[0].Path != "README.md" || got.Targets[0].Touch != "read" {
102
+ t.Fatalf("read event = %#v", got)
103
+ }
104
+ if got := trace.Events[1]; got.Tool != "apply_patch" || got.Action != "edit" || got.Targets[0].Touch != "edit" {
105
+ t.Fatalf("edit event = %#v", got)
106
+ }
107
+ if got := trace.Events[2]; got.Action != "verify" || !got.IsError {
108
+ t.Fatalf("verify event = %#v", got)
109
+ }
110
+ if trace.Stats.Edited != 1 || trace.Stats.EventsBeforeFirstEdit != 1 || math.Abs(trace.Stats.ErrorRate-1.0/3.0) > 0.0001 {
111
+ t.Fatalf("stats = %#v", trace.Stats)
112
+ }
113
+ want := model.Observability{Reads: model.ObservabilityEstimated, Errors: model.ObservabilityEstimated}
114
+ if trace.Stats.Observability != want {
115
+ t.Fatalf("observability = %#v", trace.Stats.Observability)
116
+ }
117
+ }
118
+
119
+ func TestParseCodexContextCompactedBecomesCompactionMark(t *testing.T) {
120
+ dir := t.TempDir()
121
+ session := filepath.Join(dir, "rollout-2026-07-09T00-00-00-codex-compact.jsonl")
122
+ writeJSONL(t, session,
123
+ map[string]any{
124
+ "timestamp": "2026-07-09T00:00:00Z",
125
+ "type": "session_meta",
126
+ "payload": map[string]any{"id": "codex-compact", "timestamp": "2026-07-09T00:00:00Z"},
127
+ },
128
+ call("2026-07-09T00:00:01Z", "fc-1", "call-1", "exec_command", map[string]any{"cmd": "true"}),
129
+ output("2026-07-09T00:00:02Z", "call-1", "Process exited with code 0"),
130
+ map[string]any{
131
+ "timestamp": "2026-07-09T00:00:03Z",
132
+ "type": "event_msg",
133
+ "payload": map[string]any{"type": "context_compacted"},
134
+ },
135
+ )
136
+
137
+ trace, err := Adapter{Dir: dir}.Parse(session)
138
+ if err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "compaction" || trace.Marks[0].Seq != 1 {
142
+ t.Fatalf("marks = %#v", trace.Marks)
143
+ }
144
+ if trace.Stats.Compactions != 1 {
145
+ t.Fatalf("compactions = %d", trace.Stats.Compactions)
146
+ }
147
+ }
148
+
149
+ func TestParseCodexSpawnAgentBecomesSubagentMark(t *testing.T) {
150
+ dir := t.TempDir()
151
+ session := filepath.Join(dir, "rollout-2026-07-14T00-00-00-codex-subagent.jsonl")
152
+ writeJSONL(t, session,
153
+ map[string]any{
154
+ "timestamp": "2026-07-14T00:00:00Z",
155
+ "type": "session_meta",
156
+ "payload": map[string]any{"id": "codex-subagent", "timestamp": "2026-07-14T00:00:00Z"},
157
+ },
158
+ call("2026-07-14T00:00:01Z", "fc-1", "call-1", "exec_command", map[string]any{"cmd": "true"}),
159
+ output("2026-07-14T00:00:02Z", "call-1", "Process exited with code 0"),
160
+ call("2026-07-14T00:00:03Z", "fc-2", "call-2", "spawn_agent", map[string]any{
161
+ "task_name": "qa",
162
+ "message": "Review the change",
163
+ }),
164
+ output("2026-07-14T00:00:04Z", "call-2", `{"agent_id":"agent-1"}`),
165
+ )
166
+
167
+ trace, err := Adapter{Dir: dir}.Parse(session)
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "subagent" || trace.Marks[0].Seq != 1 || trace.Marks[0].Note != "spawn_agent" {
172
+ t.Fatalf("marks = %#v", trace.Marks)
173
+ }
174
+ if trace.Stats.Subagents != 1 {
175
+ t.Fatalf("subagents = %d", trace.Stats.Subagents)
176
+ }
177
+ }
178
+
179
+ func TestParseCodexCustomCallsCanonicalizesOrderAndPatchResults(t *testing.T) {
180
+ root := t.TempDir()
181
+ readme := filepath.Join(root, "README.md")
182
+ added := filepath.Join(root, "added.go")
183
+ if err := os.WriteFile(readme, []byte("# Demo\n"), 0o644); err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ session := filepath.Join(t.TempDir(), "custom.jsonl")
187
+ failed := false
188
+ succeeded := true
189
+ writeJSONL(t, session,
190
+ map[string]any{
191
+ "timestamp": "2026-07-10T00:00:00Z",
192
+ "type": "session_meta",
193
+ "payload": map[string]any{
194
+ "id": "custom-demo",
195
+ "cwd": filepath.ToSlash(root),
196
+ },
197
+ },
198
+ userMessage("2026-07-10T00:00:01Z", "before calls"),
199
+ customCall("2026-07-10T00:00:02Z", "", "", "apply_patch", "*** Begin Patch\n"),
200
+ customCall("2026-07-10T00:00:03Z", "ctc-patch", "call-patch", "apply_patch", "*** Begin Patch\n*** Update File: README.md\n*** End Patch\n"),
201
+ userMessage("2026-07-10T00:00:04Z", "between call and output"),
202
+ customOutput("2026-07-10T00:00:05Z", "call-late", "orphan output"),
203
+ customCall("2026-07-10T00:00:06Z", "ctc-image", "call-image", "view_image", map[string]any{
204
+ "path": filepath.ToSlash(readme),
205
+ }),
206
+ customOutput("2026-07-10T00:00:07Z", "call-image", []any{map[string]any{"text": "first image output"}}),
207
+ customOutput("2026-07-10T00:00:08Z", "call-image", "Process exited with code 1"),
208
+ customOutput("2026-07-10T00:00:09Z", "call-patch", "first patch output"),
209
+ customOutput("2026-07-10T00:00:10Z", "call-patch", "second patch output"),
210
+ patchApplyEnd("2026-07-10T00:00:11Z", "call-patch", &failed, map[string]string{
211
+ filepath.ToSlash(readme): "update",
212
+ filepath.ToSlash(added): "add",
213
+ }),
214
+ patchApplyEnd("2026-07-10T00:00:12Z", "call-patch", &succeeded, map[string]string{
215
+ filepath.Join(root, "ignored.go"): "add",
216
+ }),
217
+ patchApplyEnd("2026-07-10T00:00:13Z", "orphan-patch", &failed, map[string]string{
218
+ filepath.Join(root, "orphan.go"): "add",
219
+ }),
220
+ call("2026-07-10T00:00:14Z", "fc-late", "call-late", "exec_command", map[string]any{
221
+ "cmd": "go test ./...",
222
+ "workdir": filepath.ToSlash(root),
223
+ }),
224
+ userMessage("2026-07-10T00:00:15Z", "after calls"),
225
+ customCall("2026-07-10T00:00:16Z", "duplicate", "call-patch", "view_image", map[string]any{
226
+ "path": filepath.Join(root, "ignored.png"),
227
+ }),
228
+ )
229
+
230
+ a := Adapter{}
231
+ meta, err := a.Summarize(session)
232
+ if err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ if meta.EventCount != 3 {
236
+ t.Fatalf("event count = %d, want 3", meta.EventCount)
237
+ }
238
+
239
+ trace, err := a.Parse(session)
240
+ if err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ if trace.Session.EventCount != meta.EventCount || len(trace.Events) != 3 {
244
+ t.Fatalf("meta events = %d, trace session events = %d, events = %d", meta.EventCount, trace.Session.EventCount, len(trace.Events))
245
+ }
246
+ if len(trace.Marks) != 3 || trace.Marks[0].Seq != 0 || trace.Marks[1].Seq != 1 || trace.Marks[2].Seq != 3 {
247
+ t.Fatalf("marks = %#v", trace.Marks)
248
+ }
249
+ patchEvent := trace.Events[0]
250
+ if patchEvent.Tool != "apply_patch" || patchEvent.Action != "edit" || !patchEvent.IsError || patchEvent.ResultBytes != len("first patch output") {
251
+ t.Fatalf("patch event = %#v", patchEvent)
252
+ }
253
+ if len(patchEvent.Targets) != 2 || patchEvent.Targets[0].Path != "README.md" || patchEvent.Targets[1].Path != "added.go" {
254
+ t.Fatalf("patch targets = %#v", patchEvent.Targets)
255
+ }
256
+ imageEvent := trace.Events[1]
257
+ if imageEvent.Tool != "view_image" || imageEvent.IsError || imageEvent.ResultBytes != len("first image output") {
258
+ t.Fatalf("image event = %#v", imageEvent)
259
+ }
260
+ if len(imageEvent.Targets) != 1 || imageEvent.Targets[0].Path != "README.md" {
261
+ t.Fatalf("image targets = %#v", imageEvent.Targets)
262
+ }
263
+ lateEvent := trace.Events[2]
264
+ if lateEvent.Tool != "exec_command" || lateEvent.Action != "verify" || lateEvent.ResultBytes != 0 || lateEvent.IsError {
265
+ t.Fatalf("late event = %#v", lateEvent)
266
+ }
267
+ }
268
+
269
+ func TestPatchApplyEndSuccessOverridesTextualFailure(t *testing.T) {
270
+ root := t.TempDir()
271
+ session := filepath.Join(t.TempDir(), "patch-success.jsonl")
272
+ succeeded := true
273
+ writeJSONL(t, session,
274
+ map[string]any{
275
+ "timestamp": "2026-07-10T00:00:00Z",
276
+ "type": "session_meta",
277
+ "payload": map[string]any{
278
+ "id": "patch-success",
279
+ "cwd": filepath.ToSlash(root),
280
+ },
281
+ },
282
+ customCall("2026-07-10T00:00:01Z", "ctc", "call-patch", "apply_patch", "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n"),
283
+ customOutput("2026-07-10T00:00:02Z", "call-patch", "Process exited with code 1"),
284
+ patchApplyEnd("2026-07-10T00:00:03Z", "call-patch", &succeeded, map[string]string{
285
+ filepath.Join(root, "added.go"): "add",
286
+ }),
287
+ )
288
+
289
+ trace, err := (Adapter{}).Parse(session)
290
+ if err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ if len(trace.Events) != 1 || trace.Events[0].IsError {
294
+ t.Fatalf("events = %#v", trace.Events)
295
+ }
296
+ }
297
+
298
+ func TestParseCodexCustomExec(t *testing.T) {
299
+ root := t.TempDir()
300
+ if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Demo\n"), 0o644); err != nil {
301
+ t.Fatal(err)
302
+ }
303
+ session := filepath.Join(t.TempDir(), "custom-exec.jsonl")
304
+ script := `const r = await tools.exec_command({"cmd":"sed -n '1,20p' README.md","workdir":` + mustJSON(t, filepath.ToSlash(root)) + `}); text(r.output);`
305
+ writeJSONL(t, session,
306
+ map[string]any{
307
+ "timestamp": "2026-07-10T00:00:00Z",
308
+ "type": "session_meta",
309
+ "payload": map[string]any{
310
+ "id": "custom-exec",
311
+ "cwd": filepath.ToSlash(root),
312
+ },
313
+ },
314
+ customCall("2026-07-10T00:00:01Z", "ctc", "call-exec", "exec", script),
315
+ customOutput("2026-07-10T00:00:02Z", "call-exec", []any{map[string]any{"type": "input_text", "text": "# Demo\n"}}),
316
+ )
317
+
318
+ a := Adapter{}
319
+ meta, err := a.Summarize(session)
320
+ if err != nil {
321
+ t.Fatal(err)
322
+ }
323
+ trace, err := a.Parse(session)
324
+ if err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ if meta.EventCount != 1 || len(trace.Events) != 1 {
328
+ t.Fatalf("meta=%d events=%d", meta.EventCount, len(trace.Events))
329
+ }
330
+ event := trace.Events[0]
331
+ if event.Tool != "exec" || event.Action != "read" || len(event.Targets) != 1 || event.Targets[0].Path != "README.md" || event.Targets[0].Touch != "read" {
332
+ t.Fatalf("event = %#v", event)
333
+ }
334
+ if want := "sed -n '1,20p' README.md -> 1 targets, 0 outside"; event.Summary != want {
335
+ t.Fatalf("summary = %q, want %q", event.Summary, want)
336
+ }
337
+ }
338
+
339
+ func TestCallIDFallsBackToResponseID(t *testing.T) {
340
+ root := t.TempDir()
341
+ path := filepath.Join(root, "README.md")
342
+ if err := os.WriteFile(path, []byte("# Demo\n"), 0o644); err != nil {
343
+ t.Fatal(err)
344
+ }
345
+ session := filepath.Join(t.TempDir(), "fallback-id.jsonl")
346
+ writeJSONL(t, session,
347
+ map[string]any{
348
+ "timestamp": "2026-07-10T00:00:00Z",
349
+ "type": "session_meta",
350
+ "payload": map[string]any{
351
+ "id": "fallback-session",
352
+ "cwd": filepath.ToSlash(root),
353
+ },
354
+ },
355
+ customCall("2026-07-10T00:00:01Z", "fallback-call", "", "view_image", map[string]any{"path": path}),
356
+ customOutput("2026-07-10T00:00:02Z", "fallback-call", "ok"),
357
+ )
358
+
359
+ a := Adapter{}
360
+ meta, err := a.Summarize(session)
361
+ if err != nil {
362
+ t.Fatal(err)
363
+ }
364
+ trace, err := a.Parse(session)
365
+ if err != nil {
366
+ t.Fatal(err)
367
+ }
368
+ if meta.EventCount != 1 || len(trace.Events) != 1 || trace.Events[0].ResultBytes != 2 {
369
+ t.Fatalf("meta=%#v events=%#v", meta, trace.Events)
370
+ }
371
+ }
372
+
373
+ func TestParseCodexJSRepl(t *testing.T) {
374
+ root := t.TempDir()
375
+ path := filepath.Join(root, "packages", "db", "src", "index.ts")
376
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
377
+ t.Fatal(err)
378
+ }
379
+ if err := os.WriteFile(path, []byte("export const db = {}\n"), 0o644); err != nil {
380
+ t.Fatal(err)
381
+ }
382
+ session := filepath.Join(t.TempDir(), "js-repl.jsonl")
383
+ writeJSONL(t, session,
384
+ map[string]any{
385
+ "timestamp": "2026-07-10T00:00:00Z",
386
+ "type": "session_meta",
387
+ "payload": map[string]any{
388
+ "id": "js-repl",
389
+ "cwd": filepath.ToSlash(root),
390
+ },
391
+ },
392
+ customCall("2026-07-10T00:00:01Z", "ctc", "call-js", "js_repl", `await import("./packages/db/src/index.ts")`),
393
+ customOutput("2026-07-10T00:00:02Z", "call-js", "loaded"),
394
+ )
395
+
396
+ trace, err := (Adapter{}).Parse(session)
397
+ if err != nil {
398
+ t.Fatal(err)
399
+ }
400
+ if len(trace.Events) != 1 {
401
+ t.Fatalf("events = %#v", trace.Events)
402
+ }
403
+ event := trace.Events[0]
404
+ if event.Tool != "js_repl" || event.Action != "exec" || len(event.Targets) != 1 || event.Targets[0].Path != "packages/db/src/index.ts" {
405
+ t.Fatalf("event = %#v", event)
406
+ }
407
+ }
408
+
409
+ func TestCommandOutputStatus(t *testing.T) {
410
+ tests := []struct {
411
+ output string
412
+ wantFailed bool
413
+ wantKnown bool
414
+ }{
415
+ {output: "Process exited with code 1", wantFailed: true, wantKnown: true},
416
+ {output: "Exit code: 2", wantFailed: true, wantKnown: true},
417
+ {output: `{"output":"ok","metadata":{"exit_code":0}}`, wantKnown: true},
418
+ {output: `{"output":"failed","metadata":{"exit_code":1}}`, wantFailed: true, wantKnown: true},
419
+ {output: `{"output":"failed","exit_code":3}`, wantFailed: true, wantKnown: true},
420
+ {output: `{"exit_code":3}`},
421
+ {output: `{"output":"failed","Exit_Code":3}`},
422
+ {output: `{"output":"failed","metadata":{"Exit_Code":1}}`},
423
+ {output: `{"output":"Exit code: 1","metadata":{"exit_code":0}}`, wantKnown: true},
424
+ {output: "Script completed\nWall time 0.1 seconds\nOutput:\nExit code: 1", wantKnown: true},
425
+ {output: "Script running with cell ID 28\nExit code: 1"},
426
+ {output: "Script failed\nExit code: 0", wantFailed: true, wantKnown: true},
427
+ {output: "plain output"},
428
+ {output: `{"message":"Wait timed out after 20000ms","timed_out":true}`, wantFailed: true, wantKnown: true},
429
+ {output: `{"message":"Wait timed out after 20000ms","Timed_Out":true}`},
430
+ {output: `{"message":"still running","timed_out":false}`},
431
+ {output: "apply_patch verification failed: Failed to find expected lines in a.go"},
432
+ {output: "Wall time: 8.9 seconds\naborted by user", wantFailed: true, wantKnown: true},
433
+ {output: "Wall time: 8.9 seconds\nOutput:\naborted by user"},
434
+ {output: "Script completedly\nWall time 0.1 seconds"},
435
+ }
436
+ for _, tt := range tests {
437
+ failed, known := commandOutputStatus(tt.output)
438
+ if failed != tt.wantFailed || known != tt.wantKnown {
439
+ t.Errorf("commandOutputStatus(%q) = (%v, %v), want (%v, %v)", tt.output, failed, known, tt.wantFailed, tt.wantKnown)
440
+ }
441
+ }
442
+ }
443
+
444
+ func TestParseCodexScopesOutcomeInferenceAndMatchesExactKeys(t *testing.T) {
445
+ session := filepath.Join(t.TempDir(), "outcome-scope.jsonl")
446
+ writeJSONL(t, session,
447
+ map[string]any{
448
+ "timestamp": "2026-07-10T00:00:00Z",
449
+ "type": "session_meta",
450
+ "payload": map[string]any{"id": "outcome-scope", "cwd": filepath.ToSlash(t.TempDir())},
451
+ },
452
+ customCall("2026-07-10T00:00:01Z", "ctc-script", "call-script", "view_image", map[string]any{"path": "image.png"}),
453
+ customOutput("2026-07-10T00:00:02Z", "call-script", "Script completed\nWall time 0.1 seconds"),
454
+ customCall("2026-07-10T00:00:03Z", "ctc-json", "call-json", "view_image", map[string]any{"path": "image.png"}),
455
+ customOutput("2026-07-10T00:00:04Z", "call-json", `{"output":"failed","exit_code":1}`),
456
+ call("2026-07-10T00:00:05Z", "fc-wrong", "call-wrong", "exec_command", map[string]any{"cmd": "false"}),
457
+ output("2026-07-10T00:00:06Z", "call-wrong", `{"output":"failed","Exit_Code":1}`),
458
+ customCall("2026-07-10T00:00:07Z", "ctc-exact", "call-exact", "exec", `text(await tools.exec_command({cmd: "false"}))`),
459
+ customOutput("2026-07-10T00:00:08Z", "call-exact", `{"output":"failed","exit_code":1}`),
460
+ customCall("2026-07-10T00:00:09Z", "ctc-patch", "call-patch", "apply_patch", "*** Begin Patch\n*** Add File: added.go\n*** End Patch\n"),
461
+ customOutput("2026-07-10T00:00:10Z", "call-patch", "plain output"),
462
+ map[string]any{
463
+ "timestamp": "2026-07-10T00:00:11Z",
464
+ "type": "event_msg",
465
+ "payload": map[string]any{
466
+ "type": "patch_apply_end",
467
+ "call_id": "call-patch",
468
+ "Success": true,
469
+ },
470
+ },
471
+ customCall("2026-07-10T00:00:12Z", "ctc-patch-fail", "call-patch-fail", "apply_patch", "*** Begin Patch\n*** Update File: missing.go\n*** End Patch\n"),
472
+ customOutput("2026-07-10T00:00:13Z", "call-patch-fail", "apply_patch verification failed: missing.go"),
473
+ )
474
+
475
+ trace, err := (Adapter{}).Parse(session)
476
+ if err != nil {
477
+ t.Fatal(err)
478
+ }
479
+ if len(trace.Events) != 6 {
480
+ t.Fatalf("events = %#v", trace.Events)
481
+ }
482
+ for _, index := range []int{0, 1, 2, 4} {
483
+ if got := trace.Events[index]; got.OutcomeKnown || got.IsError {
484
+ t.Fatalf("event %d promoted to known outcome: %#v", index, got)
485
+ }
486
+ }
487
+ if got := trace.Events[3]; !got.OutcomeKnown || !got.IsError {
488
+ t.Fatalf("exact command envelope = %#v", got)
489
+ }
490
+ if got := trace.Events[5]; !got.OutcomeKnown || !got.IsError {
491
+ t.Fatalf("apply_patch failure = %#v", got)
492
+ }
493
+ }
494
+
495
+ func TestListSessionsSkipsNonCodexJSONL(t *testing.T) {
496
+ dir := t.TempDir()
497
+ writeJSONL(t, filepath.Join(dir, "not-codex.jsonl"), map[string]any{
498
+ "type": "assistant",
499
+ "message": map[string]any{
500
+ "role": "assistant",
501
+ "content": "hello",
502
+ },
503
+ })
504
+ writeJSONL(t, filepath.Join(dir, "codex.jsonl"), map[string]any{
505
+ "timestamp": "2026-07-09T00:00:00Z",
506
+ "type": "session_meta",
507
+ "payload": map[string]any{
508
+ "id": "codex-list",
509
+ "cwd": "/tmp",
510
+ },
511
+ })
512
+
513
+ sessions, err := (Adapter{Dir: dir}).ListSessions()
514
+ if err != nil {
515
+ t.Fatal(err)
516
+ }
517
+ if len(sessions) != 1 || sessions[0].ID != "codex-list" {
518
+ t.Fatalf("sessions = %#v", sessions)
519
+ }
520
+ }
521
+
522
+ func TestListSessionsSkipsCodexSubagents(t *testing.T) {
523
+ dir := t.TempDir()
524
+ mainSession := filepath.Join(dir, "main.jsonl")
525
+ legacySession := filepath.Join(dir, "legacy.jsonl")
526
+ subagentSession := filepath.Join(dir, "subagent.jsonl")
527
+ transitionalSubagentSession := filepath.Join(dir, "transitional-subagent.jsonl")
528
+ writeJSONL(t, mainSession,
529
+ map[string]any{
530
+ "timestamp": "2026-07-10T00:00:00Z",
531
+ "type": "session_meta",
532
+ "payload": map[string]any{
533
+ "id": "main-thread",
534
+ "cwd": "/tmp",
535
+ "thread_source": "user",
536
+ "source": "vscode",
537
+ "forked_from_id": "user-owned-parent",
538
+ },
539
+ },
540
+ // Only the first valid session_meta controls visibility. Later context
541
+ // records must not turn a main thread into an auxiliary session.
542
+ map[string]any{
543
+ "timestamp": "2026-07-10T00:00:00.500Z",
544
+ "type": "session_meta",
545
+ "payload": map[string]any{
546
+ "id": "replayed-child-context",
547
+ "cwd": "/tmp",
548
+ "source": map[string]any{
549
+ "subagent": "replayed-child-context",
550
+ },
551
+ },
552
+ },
553
+ )
554
+ writeJSONL(t, subagentSession,
555
+ map[string]any{
556
+ "timestamp": "2026-07-10T00:00:01Z",
557
+ "type": "session_meta",
558
+ "payload": map[string]any{
559
+ "id": "child-thread",
560
+ "cwd": "/tmp",
561
+ "source": map[string]any{
562
+ "subagent": map[string]any{
563
+ "thread_spawn": map[string]any{"parent_thread_id": "main-thread"},
564
+ },
565
+ },
566
+ },
567
+ },
568
+ // Forked context can replay parent metadata. The first record remains
569
+ // authoritative for deciding whether this file belongs in the list.
570
+ map[string]any{
571
+ "timestamp": "2026-07-10T00:00:02Z",
572
+ "type": "session_meta",
573
+ "payload": map[string]any{
574
+ "id": "main-thread",
575
+ "cwd": "/tmp",
576
+ "source": "vscode",
577
+ },
578
+ },
579
+ call("2026-07-10T00:00:03Z", "fc-child", "call-child", "exec_command", map[string]any{
580
+ "cmd": "pwd",
581
+ "workdir": "/tmp",
582
+ }),
583
+ output("2026-07-10T00:00:04Z", "call-child", "Chunk ID: child\nProcess exited with code 0\nOutput:\n/tmp\n"),
584
+ )
585
+ writeJSONL(t, transitionalSubagentSession, map[string]any{
586
+ "timestamp": "2026-07-10T00:00:03Z",
587
+ "type": "session_meta",
588
+ "payload": map[string]any{
589
+ "id": "transitional-child",
590
+ "cwd": "/tmp",
591
+ "thread_source": "subagent",
592
+ "source": "vscode",
593
+ },
594
+ })
595
+ writeJSONL(t, legacySession, map[string]any{
596
+ "id": "legacy-main",
597
+ "timestamp": "2025-06-13T14:42:30.751Z",
598
+ })
599
+
600
+ adapter := Adapter{Dir: dir}
601
+ mainMeta, err := adapter.Summarize(mainSession)
602
+ if err != nil || mainMeta.Auxiliary {
603
+ t.Fatalf("main meta = %#v, err = %v", mainMeta, err)
604
+ }
605
+ subagentMeta, err := adapter.Summarize(subagentSession)
606
+ if err != nil || !subagentMeta.Auxiliary || subagentMeta.ID != "child-thread" || subagentMeta.EventCount != 1 {
607
+ t.Fatalf("subagent meta = %#v, err = %v", subagentMeta, err)
608
+ }
609
+ subagentTrace, err := adapter.Parse(subagentSession)
610
+ if err != nil {
611
+ t.Fatal(err)
612
+ }
613
+ if subagentTrace.Session.ID != subagentMeta.ID || subagentTrace.Session.Title != subagentMeta.Title ||
614
+ subagentTrace.Session.EndedAt != subagentMeta.EndedAt || subagentTrace.Session.EventCount != subagentMeta.EventCount {
615
+ t.Fatalf("subagent summary/trace mismatch: meta=%#v trace=%#v", subagentMeta, subagentTrace.Session)
616
+ }
617
+ transitionalMeta, err := adapter.Summarize(transitionalSubagentSession)
618
+ if err != nil || !transitionalMeta.Auxiliary {
619
+ t.Fatalf("transitional subagent meta = %#v, err = %v", transitionalMeta, err)
620
+ }
621
+ sessions, err := adapter.ListSessions()
622
+ if err != nil {
623
+ t.Fatal(err)
624
+ }
625
+ if len(sessions) != 2 {
626
+ t.Fatalf("sessions = %#v", sessions)
627
+ }
628
+ visible := map[string]string{}
629
+ for _, session := range sessions {
630
+ visible[session.ID] = session.Path
631
+ }
632
+ if visible["main-thread"] != mainSession || visible["legacy-main"] != legacySession {
633
+ t.Fatalf("visible sessions = %#v", visible)
634
+ }
635
+ }
636
+
637
+ func TestSummarizePopulatesSubagentMetadata(t *testing.T) {
638
+ session := filepath.Join(t.TempDir(), "subagent.jsonl")
639
+ writeJSONL(t, session, map[string]any{
640
+ "timestamp": "2026-07-10T00:00:01Z",
641
+ "type": "session_meta",
642
+ "payload": map[string]any{
643
+ "id": "child-thread",
644
+ "session_id": "root-thread",
645
+ "thread_source": "subagent",
646
+ "source": map[string]any{
647
+ "subagent": map[string]any{
648
+ "thread_spawn": map[string]any{
649
+ "parent_thread_id": "parent-thread",
650
+ "depth": 2,
651
+ "agent_nickname": "Tesla",
652
+ "agent_role": "explorer",
653
+ },
654
+ },
655
+ },
656
+ },
657
+ })
658
+
659
+ meta, err := (Adapter{}).Summarize(session)
660
+ if err != nil {
661
+ t.Fatal(err)
662
+ }
663
+ if !meta.Auxiliary || meta.Agent == nil {
664
+ t.Fatalf("meta = %#v", meta)
665
+ }
666
+ want := model.AgentSessionMeta{
667
+ SourceID: "child-thread",
668
+ RootSessionID: "root-thread",
669
+ ParentSessionID: "parent-thread",
670
+ Depth: 2,
671
+ Label: "Tesla",
672
+ Role: "explorer",
673
+ }
674
+ if *meta.Agent != want {
675
+ t.Fatalf("agent meta = %#v, want %#v", *meta.Agent, want)
676
+ }
677
+ }
678
+
679
+ func TestSessionMetaPayloadDetectsSubagentFormats(t *testing.T) {
680
+ tests := []struct {
681
+ name string
682
+ payload sessionMetaPayload
683
+ want bool
684
+ }{
685
+ {name: "thread source", payload: sessionMetaPayload{ThreadSource: "subagent"}, want: true},
686
+ {name: "legacy object", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":{"thread_spawn":{}}}`)}, want: true},
687
+ {name: "legacy scalar", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":"memory_consolidation"}`)}, want: true},
688
+ {name: "null subagent", payload: sessionMetaPayload{Source: json.RawMessage(`{"subagent":null}`)}, want: false},
689
+ {name: "top level source", payload: sessionMetaPayload{Source: json.RawMessage(`"vscode"`)}, want: false},
690
+ {name: "unknown object", payload: sessionMetaPayload{Source: json.RawMessage(`{"other":"user"}`)}, want: false},
691
+ {name: "malformed source", payload: sessionMetaPayload{Source: json.RawMessage(`{`)}, want: false},
692
+ }
693
+ for _, tt := range tests {
694
+ t.Run(tt.name, func(t *testing.T) {
695
+ if got := tt.payload.isSubagent(); got != tt.want {
696
+ t.Fatalf("isSubagent() = %v, want %v", got, tt.want)
697
+ }
698
+ })
699
+ }
700
+ }
701
+
702
+ func call(ts, id, callID, name string, args map[string]any) map[string]any {
703
+ b, _ := json.Marshal(args)
704
+ return callRaw(ts, id, callID, name, string(b))
705
+ }
706
+
707
+ func callRaw(ts, id, callID, name, args string) map[string]any {
708
+ return map[string]any{
709
+ "timestamp": ts,
710
+ "type": "response_item",
711
+ "payload": map[string]any{
712
+ "type": "function_call",
713
+ "id": id,
714
+ "name": name,
715
+ "arguments": args,
716
+ "call_id": callID,
717
+ },
718
+ }
719
+ }
720
+
721
+ func output(ts, callID, text string) map[string]any {
722
+ return map[string]any{
723
+ "timestamp": ts,
724
+ "type": "response_item",
725
+ "payload": map[string]any{
726
+ "type": "function_call_output",
727
+ "call_id": callID,
728
+ "output": text,
729
+ },
730
+ }
731
+ }
732
+
733
+ func customCall(ts, id, callID, name string, input any) map[string]any {
734
+ return map[string]any{
735
+ "timestamp": ts,
736
+ "type": "response_item",
737
+ "payload": map[string]any{
738
+ "type": "custom_tool_call",
739
+ "id": id,
740
+ "name": name,
741
+ "input": input,
742
+ "call_id": callID,
743
+ },
744
+ }
745
+ }
746
+
747
+ func customOutput(ts, callID string, value any) map[string]any {
748
+ return map[string]any{
749
+ "timestamp": ts,
750
+ "type": "response_item",
751
+ "payload": map[string]any{
752
+ "type": "custom_tool_call_output",
753
+ "call_id": callID,
754
+ "output": value,
755
+ },
756
+ }
757
+ }
758
+
759
+ func userMessage(ts, text string) map[string]any {
760
+ return map[string]any{
761
+ "timestamp": ts,
762
+ "type": "response_item",
763
+ "payload": map[string]any{
764
+ "type": "message",
765
+ "role": "user",
766
+ "content": []any{map[string]any{"type": "input_text", "text": text}},
767
+ },
768
+ }
769
+ }
770
+
771
+ func patchApplyEnd(ts, callID string, success *bool, changes map[string]string) map[string]any {
772
+ payloadChanges := make(map[string]any, len(changes))
773
+ for path, changeType := range changes {
774
+ payloadChanges[path] = map[string]any{"type": changeType}
775
+ }
776
+ return map[string]any{
777
+ "timestamp": ts,
778
+ "type": "event_msg",
779
+ "payload": map[string]any{
780
+ "type": "patch_apply_end",
781
+ "call_id": callID,
782
+ "success": success,
783
+ "changes": payloadChanges,
784
+ },
785
+ }
786
+ }
787
+
788
+ func mustJSON(t *testing.T, value any) string {
789
+ t.Helper()
790
+ encoded, err := json.Marshal(value)
791
+ if err != nil {
792
+ t.Fatal(err)
793
+ }
794
+ return string(encoded)
795
+ }
796
+
797
+ func writeJSONL(t *testing.T, path string, values ...any) {
798
+ t.Helper()
799
+ content := ""
800
+ for _, value := range values {
801
+ b, err := json.Marshal(value)
802
+ if err != nil {
803
+ t.Fatal(err)
804
+ }
805
+ content += string(b) + "\n"
806
+ }
807
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
808
+ t.Fatal(err)
809
+ }
810
+ }
811
+
812
+ func TestSummarizeMarksJudgeWorkdirAuxiliary(t *testing.T) {
813
+ workdir := judge.WorkDir()
814
+ if workdir == "" {
815
+ t.Skip("no home directory available")
816
+ }
817
+ dir := t.TempDir()
818
+ session := filepath.Join(dir, "rollout-2026-07-14T00-00-00-judge-run.jsonl")
819
+ writeJSONL(t, session,
820
+ map[string]any{
821
+ "timestamp": "2026-07-14T00:00:00Z",
822
+ "type": "session_meta",
823
+ "payload": map[string]any{
824
+ "id": "judge-run",
825
+ "session_id": "judge-run",
826
+ "timestamp": "2026-07-14T00:00:00Z",
827
+ "cwd": workdir,
828
+ },
829
+ },
830
+ )
831
+ adapter := Adapter{Dir: dir}
832
+ meta, err := adapter.Summarize(session)
833
+ if err != nil {
834
+ t.Fatal(err)
835
+ }
836
+ if !meta.Auxiliary {
837
+ t.Fatalf("session recorded in judge workdir %s should be auxiliary", workdir)
838
+ }
839
+ metas, err := adapter.ListSessions()
840
+ if err != nil {
841
+ t.Fatal(err)
842
+ }
843
+ for _, m := range metas {
844
+ if m.ID == "judge-run" {
845
+ t.Fatal("judge session leaked into ListSessions")
846
+ }
847
+ }
848
+ }
849
+
850
+ func TestParseSkipsInjectedUserMessages(t *testing.T) {
851
+ dir := t.TempDir()
852
+ session := filepath.Join(dir, "rollout-2026-07-14T00-00-01-injected.jsonl")
853
+ userMessage := func(text string) map[string]any {
854
+ return map[string]any{
855
+ "timestamp": "2026-07-14T00:00:01Z",
856
+ "type": "response_item",
857
+ "payload": map[string]any{
858
+ "type": "message",
859
+ "role": "user",
860
+ "content": []any{map[string]any{"type": "input_text", "text": text}},
861
+ },
862
+ }
863
+ }
864
+ writeJSONL(t, session,
865
+ map[string]any{
866
+ "timestamp": "2026-07-14T00:00:00Z",
867
+ "type": "session_meta",
868
+ "payload": map[string]any{"id": "injected", "session_id": "injected", "timestamp": "2026-07-14T00:00:00Z", "cwd": "/repo"},
869
+ },
870
+ userMessage("# AGENTS.md instructions for /repo\n\nproject rules"),
871
+ userMessage("<environment_context>shell: zsh</environment_context>"),
872
+ userMessage("the real task"),
873
+ )
874
+ trace, err := (Adapter{Dir: dir}).Parse(session)
875
+ if err != nil {
876
+ t.Fatal(err)
877
+ }
878
+ var notes []string
879
+ for _, mark := range trace.Marks {
880
+ if mark.Type == "user-message" {
881
+ notes = append(notes, mark.Note)
882
+ }
883
+ }
884
+ // Injected AGENTS.md and markup wrappers stay out of marks — they would
885
+ // inflate userTurns and pose as the task in judge input.
886
+ if len(notes) != 1 || notes[0] != "the real task" {
887
+ t.Fatalf("user-message notes = %#v", notes)
888
+ }
889
+ if trace.Stats.UserTurns != 1 {
890
+ t.Fatalf("userTurns = %d, want 1", trace.Stats.UserTurns)
891
+ }
892
+ // Summarize must count the same turns as the parse, or the rail badge's
893
+ // staleness check would disagree with the report.
894
+ meta, err := (Adapter{Dir: dir}).Summarize(session)
895
+ if err != nil {
896
+ t.Fatal(err)
897
+ }
898
+ if meta.UserTurns != 1 {
899
+ t.Fatalf("summarized userTurns = %d, want 1", meta.UserTurns)
900
+ }
901
+ }
902
+
903
+ func TestParseRejectsRolelessMessageLines(t *testing.T) {
904
+ // pi sessions also write {"type":"message"} lines but nest the payload
905
+ // under "message" with no top-level role; those files are not Codex's.
906
+ dir := t.TempDir()
907
+ path := filepath.Join(dir, "session.jsonl")
908
+ lines := `{"type":"session","version":3,"id":"s1","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp"}
909
+ {"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":"hi"}}
910
+ `
911
+ if err := os.WriteFile(path, []byte(lines), 0o644); err != nil {
912
+ t.Fatal(err)
913
+ }
914
+ if _, err := (Adapter{}).Parse(path); err == nil {
915
+ t.Fatal("Parse claimed a roleless message file")
916
+ }
917
+ if _, err := (Adapter{}).Summarize(path); err == nil {
918
+ t.Fatal("Summarize claimed a roleless message file")
919
+ }
920
+ }