@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,415 @@
1
+ package claudecode
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "sort"
9
+ "strings"
10
+
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter"
12
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
13
+ )
14
+
15
+ type Adapter struct {
16
+ Dir string
17
+ }
18
+
19
+ func DefaultDir() string {
20
+ home, err := os.UserHomeDir()
21
+ if err != nil {
22
+ return ""
23
+ }
24
+ return filepath.Join(home, ".claude", "projects")
25
+ }
26
+
27
+ func (a Adapter) Harness() string {
28
+ return "claude-code"
29
+ }
30
+
31
+ func (a Adapter) SessionDir() string {
32
+ if a.Dir != "" {
33
+ return a.Dir
34
+ }
35
+ return DefaultDir()
36
+ }
37
+
38
+ func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
39
+ dir := a.SessionDir()
40
+ if info, err := os.Stat(dir); err != nil || !info.IsDir() {
41
+ return nil, nil
42
+ }
43
+ var metas []model.SessionMeta
44
+ err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
45
+ if walkErr != nil {
46
+ return nil
47
+ }
48
+ if entry.IsDir() {
49
+ return nil
50
+ }
51
+ if filepath.Ext(path) != ".jsonl" || strings.HasPrefix(filepath.Base(path), "agent-") {
52
+ return nil
53
+ }
54
+ meta, err := a.Summarize(path)
55
+ if err == nil {
56
+ metas = append(metas, meta)
57
+ }
58
+ return nil
59
+ })
60
+ if err != nil {
61
+ return nil, err
62
+ }
63
+ sort.Slice(metas, func(i, j int) bool {
64
+ return metas[i].EndedAt > metas[j].EndedAt
65
+ })
66
+ return metas, nil
67
+ }
68
+
69
+ // SummaryInputs reports the sidecar files Summarize reads for path: child
70
+ // agent sessions carry an agent-*.meta.json describing the spawning tool
71
+ // call, so a sidecar change must invalidate a cached summary.
72
+ func (a Adapter) SummaryInputs(path string) []string {
73
+ if !strings.HasPrefix(filepath.Base(path), "agent-") {
74
+ return nil
75
+ }
76
+ return []string{strings.TrimSuffix(path, ".jsonl") + ".meta.json"}
77
+ }
78
+
79
+ func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
80
+ f, err := os.Open(path)
81
+ if err != nil {
82
+ return model.SessionMeta{}, err
83
+ }
84
+ defer f.Close()
85
+
86
+ id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
87
+ meta := model.SessionMeta{Key: adapter.SessionKey(a.Harness(), path), ID: id, Harness: a.Harness(), Path: path}
88
+ recognized := false
89
+ err = adapter.ReadJSONLines(f, func(data []byte) {
90
+ var line rawLine
91
+ if json.Unmarshal(data, &line) != nil {
92
+ return
93
+ }
94
+ if isClaudeLine(line) {
95
+ recognized = true
96
+ }
97
+ if line.IsSidechain {
98
+ meta.Auxiliary = true
99
+ if meta.Agent == nil {
100
+ meta.Agent = &model.AgentSessionMeta{}
101
+ }
102
+ if line.AgentID != "" {
103
+ meta.ID = line.AgentID
104
+ meta.Agent.SourceID = line.AgentID
105
+ }
106
+ if line.SessionID != "" {
107
+ meta.Agent.RootSessionID = line.SessionID
108
+ }
109
+ } else if line.SessionID != "" && !meta.Auxiliary {
110
+ meta.ID = line.SessionID
111
+ }
112
+ if line.Timestamp != "" {
113
+ if meta.StartedAt == "" {
114
+ meta.StartedAt = line.Timestamp
115
+ }
116
+ meta.EndedAt = line.Timestamp
117
+ }
118
+ if line.Type == "ai-title" && line.AITitle != "" {
119
+ meta.Title = line.AITitle
120
+ }
121
+ if line.Cwd != "" && meta.Cwd == "" {
122
+ meta.Cwd = line.Cwd
123
+ }
124
+ if line.GitBranch != "" && meta.GitBranch == "" {
125
+ meta.GitBranch = line.GitBranch
126
+ }
127
+ if len(line.Message) > 0 {
128
+ var msg message
129
+ if json.Unmarshal(line.Message, &msg) == nil {
130
+ if msg.Model != "" && meta.Model == "" {
131
+ meta.Model = msg.Model
132
+ }
133
+ meta.EventCount += countToolUses(msg.Content)
134
+ // mirrors Parse's user-message mark filter so the badge's
135
+ // staleness check counts the same turns the report will
136
+ if line.Type == "user" && hasUserMessage(msg.Content) && !adapter.InjectedUserMessage(userMessageText(msg.Content)) {
137
+ meta.UserTurns++
138
+ }
139
+ }
140
+ }
141
+ })
142
+ if meta.Auxiliary {
143
+ var childMeta struct {
144
+ AgentType string `json:"agentType"`
145
+ Description string `json:"description"`
146
+ ToolUseID string `json:"toolUseId"`
147
+ SpawnDepth int `json:"spawnDepth"`
148
+ }
149
+ data, readErr := os.ReadFile(strings.TrimSuffix(path, ".jsonl") + ".meta.json")
150
+ if readErr == nil && json.Unmarshal(data, &childMeta) == nil {
151
+ meta.Agent.Depth = childMeta.SpawnDepth
152
+ meta.Agent.Label = childMeta.Description
153
+ meta.Agent.Role = childMeta.AgentType
154
+ meta.Agent.LaunchCallID = childMeta.ToolUseID
155
+ }
156
+ }
157
+ if meta.Title == "" {
158
+ meta.Title = filepath.Base(path)
159
+ }
160
+ if !recognized {
161
+ return model.SessionMeta{}, fmt.Errorf("not a Claude Code session: %s", path)
162
+ }
163
+ return meta, err
164
+ }
165
+
166
+ func (a Adapter) Parse(path string) (*model.Trace, error) {
167
+ f, err := os.Open(path)
168
+ if err != nil {
169
+ return nil, err
170
+ }
171
+ defer f.Close()
172
+
173
+ id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
174
+ trace := &model.Trace{
175
+ Version: 1,
176
+ Session: model.TraceSession{
177
+ ID: id,
178
+ Harness: a.Harness(),
179
+ Path: path,
180
+ },
181
+ Events: []model.Event{},
182
+ Marks: []model.Mark{},
183
+ }
184
+
185
+ recognized := false
186
+ pending := map[string]adapter.ToolCall{}
187
+ pendingOrder := []string{}
188
+ err = adapter.ReadJSONLines(f, func(data []byte) {
189
+ var line rawLine
190
+ if json.Unmarshal(data, &line) != nil {
191
+ return
192
+ }
193
+ if isClaudeLine(line) {
194
+ recognized = true
195
+ }
196
+ applyLineMeta(trace, line)
197
+ if line.Type == "ai-title" && line.AITitle != "" {
198
+ trace.Session.Title = line.AITitle
199
+ return
200
+ }
201
+ if isCompaction(line) {
202
+ trace.Marks = append(trace.Marks, model.Mark{Seq: len(trace.Events), Type: "compaction"})
203
+ }
204
+ if len(line.Message) == 0 {
205
+ return
206
+ }
207
+
208
+ var msg message
209
+ if json.Unmarshal(line.Message, &msg) != nil {
210
+ return
211
+ }
212
+ if line.Type == "user" && hasUserMessage(msg.Content) {
213
+ if text := userMessageText(msg.Content); !adapter.InjectedUserMessage(text) {
214
+ trace.Marks = append(trace.Marks, model.Mark{
215
+ Seq: len(trace.Events),
216
+ Type: "user-message",
217
+ Note: adapter.UserMessageNote(text),
218
+ })
219
+ }
220
+ }
221
+ if msg.Model != "" && trace.Session.Model == "" {
222
+ trace.Session.Model = msg.Model
223
+ }
224
+ for _, item := range msg.Content.Items {
225
+ switch item.Type {
226
+ case "tool_use":
227
+ call := adapter.ToolCall{
228
+ ID: item.ID,
229
+ Name: item.Name,
230
+ Input: item.Input,
231
+ Timestamp: line.Timestamp,
232
+ }
233
+ if call.Name == "Task" || call.Name == "Agent" {
234
+ trace.Marks = append(trace.Marks, model.Mark{Seq: len(trace.Events), Type: "subagent", Note: call.Name})
235
+ }
236
+ if _, exists := pending[call.ID]; !exists {
237
+ pendingOrder = append(pendingOrder, call.ID)
238
+ }
239
+ pending[call.ID] = call
240
+ case "tool_result":
241
+ call, ok := pending[item.ToolUseID]
242
+ if !ok {
243
+ continue
244
+ }
245
+ delete(pending, item.ToolUseID)
246
+ event := buildEvent(trace, call, item, true)
247
+ trace.Events = append(trace.Events, event)
248
+ }
249
+ }
250
+ })
251
+ for _, id := range pendingOrder {
252
+ if call, ok := pending[id]; ok {
253
+ trace.Events = append(trace.Events, buildEvent(trace, call, contentItem{}, false))
254
+ }
255
+ }
256
+ sort.Slice(trace.Events, func(i, j int) bool {
257
+ return trace.Events[i].Seq < trace.Events[j].Seq
258
+ })
259
+ for i := range trace.Events {
260
+ trace.Events[i].Seq = i
261
+ }
262
+ trace.Session.EventCount = len(trace.Events)
263
+ // Claude Code tool results carry an is_error flag set by the harness.
264
+ trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact)
265
+ if !recognized {
266
+ return nil, fmt.Errorf("not a Claude Code session: %s", path)
267
+ }
268
+ return trace, err
269
+ }
270
+
271
+ type rawLine struct {
272
+ Type string `json:"type"`
273
+ Timestamp string `json:"timestamp"`
274
+ SessionID string `json:"sessionId"`
275
+ AgentID string `json:"agentId"`
276
+ IsSidechain bool `json:"isSidechain"`
277
+ Cwd string `json:"cwd"`
278
+ GitBranch string `json:"gitBranch"`
279
+ Message json.RawMessage `json:"message"`
280
+ AITitle string `json:"aiTitle"`
281
+ Content string `json:"content"`
282
+ Subtype string `json:"subtype"`
283
+ }
284
+
285
+ type message struct {
286
+ Role string `json:"role"`
287
+ Model string `json:"model"`
288
+ Content contentList `json:"content"`
289
+ }
290
+
291
+ type contentList struct {
292
+ Items []contentItem
293
+ }
294
+
295
+ func (c *contentList) UnmarshalJSON(data []byte) error {
296
+ if len(data) == 0 || string(data) == "null" {
297
+ return nil
298
+ }
299
+ if data[0] == '"' {
300
+ var s string
301
+ if err := json.Unmarshal(data, &s); err != nil {
302
+ return err
303
+ }
304
+ c.Items = []contentItem{{Type: "text", Text: s}}
305
+ return nil
306
+ }
307
+ var items []contentItem
308
+ if err := json.Unmarshal(data, &items); err != nil {
309
+ return err
310
+ }
311
+ var rawItems []map[string]json.RawMessage
312
+ if err := json.Unmarshal(data, &rawItems); err != nil {
313
+ return err
314
+ }
315
+ for i := range items {
316
+ items[i].IsError = nil
317
+ raw, ok := rawItems[i]["is_error"]
318
+ if !ok || strings.TrimSpace(string(raw)) == "null" {
319
+ continue
320
+ }
321
+ var value bool
322
+ if err := json.Unmarshal(raw, &value); err != nil {
323
+ return err
324
+ }
325
+ items[i].IsError = &value
326
+ }
327
+ c.Items = items
328
+ return nil
329
+ }
330
+
331
+ type contentItem struct {
332
+ Type string `json:"type"`
333
+ ID string `json:"id"`
334
+ Name string `json:"name"`
335
+ Input map[string]any `json:"input"`
336
+ ToolUseID string `json:"tool_use_id"`
337
+ Content any `json:"content"`
338
+ IsError *bool `json:"is_error"`
339
+ Text string `json:"text"`
340
+ }
341
+
342
+ func countToolUses(content contentList) int {
343
+ count := 0
344
+ for _, item := range content.Items {
345
+ if item.Type == "tool_use" {
346
+ count++
347
+ }
348
+ }
349
+ return count
350
+ }
351
+
352
+ func userMessageText(content contentList) string {
353
+ var parts []string
354
+ for _, item := range content.Items {
355
+ if item.Type == "text" && strings.TrimSpace(item.Text) != "" {
356
+ parts = append(parts, strings.TrimSpace(item.Text))
357
+ }
358
+ }
359
+ return strings.Join(parts, "\n")
360
+ }
361
+
362
+ func hasUserMessage(content contentList) bool {
363
+ if len(content.Items) == 0 {
364
+ return false
365
+ }
366
+ for _, item := range content.Items {
367
+ if item.Type == "tool_result" {
368
+ return false
369
+ }
370
+ if item.Type == "text" && strings.TrimSpace(item.Text) != "" {
371
+ return true
372
+ }
373
+ }
374
+ return true
375
+ }
376
+
377
+ func applyLineMeta(trace *model.Trace, line rawLine) {
378
+ if line.SessionID != "" {
379
+ trace.Session.ID = line.SessionID
380
+ }
381
+ if line.Cwd != "" && trace.Session.Cwd == "" {
382
+ trace.Session.Cwd = line.Cwd
383
+ }
384
+ if line.Timestamp != "" {
385
+ if trace.Session.StartedAt == "" {
386
+ trace.Session.StartedAt = line.Timestamp
387
+ }
388
+ trace.Session.EndedAt = line.Timestamp
389
+ }
390
+ }
391
+
392
+ func isCompaction(line rawLine) bool {
393
+ return line.Type == "system" && strings.Contains(strings.ToLower(line.Subtype), "compact")
394
+ }
395
+
396
+ func isClaudeLine(line rawLine) bool {
397
+ if line.SessionID != "" {
398
+ return true
399
+ }
400
+ switch line.Type {
401
+ case "user", "assistant", "system", "ai-title":
402
+ return line.Timestamp != "" || len(line.Message) > 0
403
+ default:
404
+ return false
405
+ }
406
+ }
407
+
408
+ func buildEvent(trace *model.Trace, call adapter.ToolCall, result contentItem, resultObserved bool) model.Event {
409
+ isError := result.IsError != nil && *result.IsError
410
+ return adapter.BuildEvent(trace, call, adapter.ToolResult{
411
+ Content: adapter.ContentToString(result.Content),
412
+ IsError: isError,
413
+ OutcomeKnown: resultObserved,
414
+ })
415
+ }
@@ -0,0 +1,260 @@
1
+ package claudecode
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestParseSession(t *testing.T) {
11
+ trace, err := (Adapter{}).Parse(filepath.Join("..", "..", "..", "testdata", "claude-session.jsonl"))
12
+ if err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ if trace.Session.ID != "demo-session" {
16
+ t.Fatalf("session id = %q", trace.Session.ID)
17
+ }
18
+ if trace.Session.Title != "Demo trace" {
19
+ t.Fatalf("title = %q", trace.Session.Title)
20
+ }
21
+ if trace.Session.Model != "claude-test" {
22
+ t.Fatalf("model = %q", trace.Session.Model)
23
+ }
24
+ if len(trace.Events) != 2 {
25
+ t.Fatalf("events = %d", len(trace.Events))
26
+ }
27
+ read := trace.Events[0]
28
+ if read.Action != "read" || len(read.Targets) != 1 || read.Targets[0].Path != "src/main.go" {
29
+ t.Fatalf("bad read event: %#v", read)
30
+ }
31
+ if got := read.Targets[0].Lines[0]; got != [2]int{10, 14} {
32
+ t.Fatalf("line range = %#v", got)
33
+ }
34
+ edit := trace.Events[1]
35
+ if edit.Action != "edit" || edit.Targets[0].Touch != "edit" {
36
+ t.Fatalf("bad edit event: %#v", edit)
37
+ }
38
+ if trace.Stats.Edited != 1 || trace.Stats.Fovea != 1 {
39
+ t.Fatalf("stats = %#v", trace.Stats)
40
+ }
41
+ }
42
+
43
+ func TestParseSkipsBashNoiseAndKeepsExistingWeakPaths(t *testing.T) {
44
+ root := t.TempDir()
45
+ if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("package main\n"), 0o644); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ session := filepath.Join(t.TempDir(), "session.jsonl")
52
+ writeSession(t, session,
53
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"user","content":"x"}}`,
54
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"assistant","content":[{"type":"tool_use","id":"bash","name":"Bash","input":{"command":"printf 'please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n'"}}]}}`,
55
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":`+quote(root)+`,"sessionId":"noise","message":{"role":"user","content":[{"tool_use_id":"bash","type":"tool_result","content":"please check the main.go file\nupdated to v1.5\n+ cs.fontSize = 27.2px\nsrc/main.go\n","is_error":false}]}}`,
56
+ )
57
+ trace, err := (Adapter{}).Parse(session)
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if len(trace.Events) != 1 {
62
+ t.Fatalf("events = %d", len(trace.Events))
63
+ }
64
+ targets := trace.Events[0].Targets
65
+ if len(targets) != 1 {
66
+ t.Fatalf("targets = %#v", targets)
67
+ }
68
+ if targets[0].Path != "src/main.go" || !targets[0].Weak {
69
+ t.Fatalf("target = %#v", targets[0])
70
+ }
71
+ }
72
+
73
+ func TestParsePendingToolUsesRemainDeterministic(t *testing.T) {
74
+ session := filepath.Join(t.TempDir(), "pending.jsonl")
75
+ writeSession(t, session,
76
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"pending","message":{"role":"user","content":"x"}}`,
77
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"pending","message":{"role":"assistant","content":[{"type":"tool_use","id":"a","name":"Read","input":{"file_path":"/tmp/a.go"}},{"type":"tool_use","id":"b","name":"Read","input":{"file_path":"/tmp/b.go"}},{"type":"tool_use","id":"c","name":"Read","input":{"file_path":"/tmp/c.go"}}]}}`,
78
+ )
79
+ for i := 0; i < 20; i++ {
80
+ trace, err := (Adapter{}).Parse(session)
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ got := []string{trace.Events[0].Targets[0].Path, trace.Events[1].Targets[0].Path, trace.Events[2].Targets[0].Path}
85
+ want := []string{"a.go", "b.go", "c.go"}
86
+ for j := range want {
87
+ if got[j] != want[j] {
88
+ t.Fatalf("run %d order = %#v", i, got)
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ func TestParsePreservesToolOutcomeCertainty(t *testing.T) {
95
+ session := filepath.Join(t.TempDir(), "outcome-certainty.jsonl")
96
+ writeSession(t, session,
97
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"user","content":"x"}}`,
98
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"assistant","content":[{"type":"tool_use","id":"explicit-success","name":"Bash","input":{"command":"go test ./..."}},{"type":"tool_use","id":"failure","name":"Bash","input":{"command":"make test"}},{"type":"tool_use","id":"implicit-success","name":"Bash","input":{"command":"npm test"}},{"type":"tool_use","id":"wrong-case","name":"Bash","input":{"command":"go test ./internal/..."}},{"type":"tool_use","id":"pending","name":"Bash","input":{"command":"go vet ./..."}}]}}`,
99
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":"/tmp","sessionId":"outcome-certainty","message":{"role":"user","content":[{"tool_use_id":"explicit-success","type":"tool_result","content":"ok","is_error":false},{"tool_use_id":"failure","type":"tool_result","content":"failed","is_error":true},{"tool_use_id":"implicit-success","type":"tool_result","content":"ok"},{"tool_use_id":"wrong-case","type":"tool_result","content":"ok","IS_ERROR":true}]}}`,
100
+ )
101
+
102
+ trace, err := (Adapter{}).Parse(session)
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ if len(trace.Events) != 5 {
107
+ t.Fatalf("events = %#v", trace.Events)
108
+ }
109
+ if got := trace.Events[0]; !got.OutcomeKnown || got.IsError {
110
+ t.Fatalf("success event = %#v", got)
111
+ }
112
+ if got := trace.Events[1]; !got.OutcomeKnown || !got.IsError {
113
+ t.Fatalf("failure event = %#v", got)
114
+ }
115
+ if got := trace.Events[2]; !got.OutcomeKnown || got.IsError {
116
+ t.Fatalf("implicit success event = %#v", got)
117
+ }
118
+ if got := trace.Events[3]; !got.OutcomeKnown || got.IsError {
119
+ t.Fatalf("wrong-case error key event = %#v", got)
120
+ }
121
+ if got := trace.Events[4]; got.OutcomeKnown || got.IsError {
122
+ t.Fatalf("pending event = %#v", got)
123
+ }
124
+ if trace.Stats.Observability.Errors != "estimated" {
125
+ t.Fatalf("observability = %#v", trace.Stats.Observability)
126
+ }
127
+ }
128
+
129
+ func TestCompactionUsesSubtypeOnly(t *testing.T) {
130
+ session := filepath.Join(t.TempDir(), "compact.jsonl")
131
+ writeSession(t, session,
132
+ `{"type":"system","subtype":"local_command","content":"compact but not a boundary","timestamp":"2026-07-09T00:00:00Z","sessionId":"compact"}`,
133
+ `{"type":"system","subtype":"compact_boundary","content":"","timestamp":"2026-07-09T00:00:01Z","sessionId":"compact"}`,
134
+ )
135
+ trace, err := (Adapter{}).Parse(session)
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if len(trace.Marks) != 1 || trace.Marks[0].Type != "compaction" {
140
+ t.Fatalf("marks = %#v", trace.Marks)
141
+ }
142
+ }
143
+
144
+ func TestUserMessageMarkCarriesNote(t *testing.T) {
145
+ long := strings.Repeat("字", 2100)
146
+ session := filepath.Join(t.TempDir(), "notes.jsonl")
147
+ writeSession(t, session,
148
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":"fix the login bug"}}`,
149
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","cwd":"/tmp","sessionId":"notes","message":{"role":"assistant","content":[{"type":"tool_use","id":"a","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
150
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":[{"tool_use_id":"a","type":"tool_result","content":"ok","is_error":false}]}}`,
151
+ `{"type":"user","timestamp":"2026-07-09T00:00:03Z","cwd":"/tmp","sessionId":"notes","message":{"role":"user","content":"`+long+`"}}`,
152
+ )
153
+ trace, err := (Adapter{}).Parse(session)
154
+ if err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if len(trace.Marks) != 2 {
158
+ t.Fatalf("marks = %#v", trace.Marks)
159
+ }
160
+ if trace.Marks[0].Note != "fix the login bug" {
161
+ t.Fatalf("note = %q", trace.Marks[0].Note)
162
+ }
163
+ // The ellipsis fits inside the 2000-rune budget the schema promises.
164
+ truncated := []rune(trace.Marks[1].Note)
165
+ if len(truncated) != 2000 || truncated[len(truncated)-1] != '…' {
166
+ t.Fatalf("truncated note runes = %d, last = %q", len(truncated), truncated[len(truncated)-1])
167
+ }
168
+ // Summarize counts the same turns Parse marks — tool results and other
169
+ // non-message user lines stay out of both.
170
+ meta, err := (Adapter{}).Summarize(session)
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ if meta.UserTurns != 2 {
175
+ t.Fatalf("summarized userTurns = %d, want 2", meta.UserTurns)
176
+ }
177
+ }
178
+
179
+ func TestSummarizeRecognizesUnknownClaudeLineWithSessionID(t *testing.T) {
180
+ session := filepath.Join(t.TempDir(), "metadata.jsonl")
181
+ writeSession(t, session,
182
+ `{"type":"metadata","timestamp":"2026-07-09T00:00:00Z","sessionId":"metadata-session","cwd":"/tmp"}`,
183
+ )
184
+ meta, err := (Adapter{}).Summarize(session)
185
+ if err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ if meta.ID != "metadata-session" || meta.Cwd != "/tmp" {
189
+ t.Fatalf("meta = %#v", meta)
190
+ }
191
+ }
192
+
193
+ func TestSummarizePopulatesSidechainMetadata(t *testing.T) {
194
+ rootDir := filepath.Join(t.TempDir(), "root-session", "subagents")
195
+ if err := os.MkdirAll(rootDir, 0o755); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ session := filepath.Join(rootDir, "agent-child.jsonl")
199
+ writeSession(t, session,
200
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"root-session","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
201
+ )
202
+ if err := os.WriteFile(strings.TrimSuffix(session, ".jsonl")+".meta.json", []byte(`{"agentType":"Explore","description":"Inspect child","toolUseId":"call-child","spawnDepth":2}`), 0o644); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+
206
+ meta, err := (Adapter{}).Summarize(session)
207
+ if err != nil {
208
+ t.Fatal(err)
209
+ }
210
+ if meta.ID != "child" {
211
+ t.Fatalf("id = %q, want child", meta.ID)
212
+ }
213
+ if !meta.Auxiliary || meta.Agent == nil {
214
+ t.Fatalf("meta = %#v", meta)
215
+ }
216
+ if meta.Agent.SourceID != "child" || meta.Agent.RootSessionID != "root-session" || meta.Agent.Depth != 2 ||
217
+ meta.Agent.Role != "Explore" || meta.Agent.LaunchCallID != "call-child" {
218
+ t.Fatalf("agent meta = %#v", *meta.Agent)
219
+ }
220
+ }
221
+
222
+ func TestSummarizeHandlesLargeJSONLines(t *testing.T) {
223
+ dir := t.TempDir()
224
+ session := filepath.Join(dir, "large.jsonl")
225
+ largeText := strings.Repeat("x", 21*1024*1024)
226
+ writeSession(t, session,
227
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","cwd":"/tmp","sessionId":"large","message":{"role":"user","content":"`+largeText+`"}}`,
228
+ )
229
+
230
+ adapter := Adapter{Dir: dir}
231
+ meta, err := adapter.Summarize(session)
232
+ if err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ if meta.ID != "large" {
236
+ t.Fatalf("id = %q", meta.ID)
237
+ }
238
+ sessions, err := adapter.ListSessions()
239
+ if err != nil {
240
+ t.Fatal(err)
241
+ }
242
+ if len(sessions) != 1 || sessions[0].ID != "large" {
243
+ t.Fatalf("sessions = %#v", sessions)
244
+ }
245
+ }
246
+
247
+ func writeSession(t *testing.T, path string, lines ...string) {
248
+ t.Helper()
249
+ content := ""
250
+ for _, line := range lines {
251
+ content += line + "\n"
252
+ }
253
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
254
+ t.Fatal(err)
255
+ }
256
+ }
257
+
258
+ func quote(s string) string {
259
+ return `"` + filepath.ToSlash(s) + `"`
260
+ }