@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,1389 @@
1
+ package server
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "net/url"
8
+ "os"
9
+ "path/filepath"
10
+ "strconv"
11
+ "strings"
12
+ "sync/atomic"
13
+ "testing"
14
+ "time"
15
+
16
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter"
17
+ "github.com/hybridlabor-api/bdb-synapse/internal/citymap"
18
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
19
+ )
20
+
21
+ func TestTraceStillLoadsWhenSessionCwdIsMissing(t *testing.T) {
22
+ claudeDir := t.TempDir()
23
+ missingRoot := filepath.Join(t.TempDir(), "deleted-repo")
24
+ session := filepath.Join(claudeDir, "missingcwd.jsonl")
25
+ writeServerSession(t, session,
26
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"missingcwd","cwd":`+quoteJSON(missingRoot)+`,"message":{"role":"user","content":"hello"}}`,
27
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"missingcwd","cwd":`+quoteJSON(missingRoot)+`,"message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":`+quoteJSON(filepath.Join(missingRoot, "a.go"))+`}}]}}`,
28
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"missingcwd","cwd":`+quoteJSON(missingRoot)+`,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
29
+ )
30
+
31
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
32
+ traceResp := httptest.NewRecorder()
33
+ s.handleSessionResource(traceResp, httptest.NewRequest(http.MethodGet, "/api/sessions/missingcwd/trace", nil))
34
+ if traceResp.Code != http.StatusOK {
35
+ t.Fatalf("trace status = %d body=%s", traceResp.Code, traceResp.Body.String())
36
+ }
37
+ var trace model.Trace
38
+ if err := json.Unmarshal(traceResp.Body.Bytes(), &trace); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ if len(trace.Events) != 1 || trace.Stats.FilesInRepo != 0 {
42
+ t.Fatalf("trace = %#v", trace)
43
+ }
44
+
45
+ cityResp := httptest.NewRecorder()
46
+ s.handleSessionResource(cityResp, httptest.NewRequest(http.MethodGet, "/api/sessions/missingcwd/citymap", nil))
47
+ if cityResp.Code != http.StatusOK {
48
+ t.Fatalf("citymap status = %d body=%s", cityResp.Code, cityResp.Body.String())
49
+ }
50
+ var city model.CityMap
51
+ if err := json.Unmarshal(cityResp.Body.Bytes(), &city); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if len(city.Files) != 0 || city.Repo.Root == "" || city.Layout.Algorithm != "unavailable" {
55
+ t.Fatalf("city = %#v", city)
56
+ }
57
+
58
+ snapshotResp := httptest.NewRecorder()
59
+ s.handleSessionResource(snapshotResp, httptest.NewRequest(http.MethodGet, "/api/sessions/missingcwd/snapshot", nil))
60
+ if snapshotResp.Code != http.StatusOK {
61
+ t.Fatalf("snapshot status = %d body=%s", snapshotResp.Code, snapshotResp.Body.String())
62
+ }
63
+ var snapshot struct {
64
+ Trace model.Trace `json:"trace"`
65
+ City model.CityMap `json:"city"`
66
+ }
67
+ if err := json.Unmarshal(snapshotResp.Body.Bytes(), &snapshot); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ if len(snapshot.Trace.Events) != 1 || snapshot.City.Repo.Root != city.Repo.Root {
71
+ t.Fatalf("snapshot = %#v", snapshot)
72
+ }
73
+ }
74
+
75
+ func TestOpenSessionUsesUniqueKeyAndFindSessionAcceptsBasename(t *testing.T) {
76
+ claudeDir := t.TempDir()
77
+ session := filepath.Join(claudeDir, "renamed.jsonl")
78
+ writeServerSession(t, session,
79
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"internal-id","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
80
+ )
81
+
82
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), OpenSession: session})
83
+ wantKey := adapter.SessionKey("claude-code", session)
84
+ if got := s.openSessionKey(); got != wantKey {
85
+ t.Fatalf("openSessionKey = %q, want %q", got, wantKey)
86
+ }
87
+ meta, err := s.findSession("renamed")
88
+ if err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ if meta.ID != "internal-id" {
92
+ t.Fatalf("meta = %#v", meta)
93
+ }
94
+ }
95
+
96
+ func TestDuplicateSessionIDsUseDistinctKeysAndCaches(t *testing.T) {
97
+ claudeDir := t.TempDir()
98
+ codexDir := t.TempDir()
99
+ repoRoot := t.TempDir()
100
+ first := filepath.Join(codexDir, "first.jsonl")
101
+ second := filepath.Join(codexDir, "second.jsonl")
102
+ for i, path := range []string{first, second} {
103
+ writeServerJSONL(t, path, map[string]any{
104
+ "timestamp": "2026-07-09T00:00:0" + strconv.Itoa(i) + "Z",
105
+ "type": "session_meta",
106
+ "payload": map[string]any{
107
+ "id": "shared-id",
108
+ "cwd": filepath.ToSlash(repoRoot),
109
+ },
110
+ })
111
+ }
112
+
113
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
114
+ sessions, err := s.listSessions()
115
+ if err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ if len(sessions) != 2 {
119
+ t.Fatalf("sessions = %#v", sessions)
120
+ }
121
+ if sessions[0].Key == "" || sessions[1].Key == "" || sessions[0].Key == sessions[1].Key {
122
+ t.Fatalf("session keys are not unique: %#v", sessions)
123
+ }
124
+
125
+ for _, session := range sessions {
126
+ trace, _, err := s.traceAndMap(session.Key)
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if trace.Session.Path != session.Path {
131
+ t.Fatalf("key %q loaded %q, want %q", session.Key, trace.Session.Path, session.Path)
132
+ }
133
+ }
134
+ s.traceStore.mu.Lock()
135
+ cached := len(s.traceStore.snapshots)
136
+ s.traceStore.mu.Unlock()
137
+ if cached != 2 {
138
+ t.Fatalf("trace cache entries = %d, want 2", cached)
139
+ }
140
+ if _, err := s.findSession("shared-id"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
141
+ t.Fatalf("duplicate legacy ID error = %v", err)
142
+ }
143
+ }
144
+
145
+ func TestTraceCacheReloadsWhenActiveSessionGrows(t *testing.T) {
146
+ claudeDir := t.TempDir()
147
+ repoRoot := t.TempDir()
148
+ if err := os.WriteFile(filepath.Join(repoRoot, "a.go"), []byte("package demo\n"), 0o644); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ if err := os.WriteFile(filepath.Join(repoRoot, "b.go"), []byte("package demo\n"), 0o644); err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ session := filepath.Join(claudeDir, "growing.jsonl")
155
+ writeServerSession(t, session,
156
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"user","content":"hello"}}`,
157
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":`+quoteJSON(filepath.Join(repoRoot, "a.go"))+`}}]}}`,
158
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
159
+ )
160
+
161
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
162
+ firstTrace, firstCity, err := s.traceAndMap("growing")
163
+ if err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if len(firstTrace.Events) != 1 {
167
+ t.Fatalf("initial events = %d, want 1", len(firstTrace.Events))
168
+ }
169
+
170
+ appendServerSession(t, session,
171
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:03Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"assistant","content":[{"type":"tool_use","id":"r2","name":"Read","input":{"file_path":`+quoteJSON(filepath.Join(repoRoot, "b.go"))+`}}]}}`,
172
+ `{"type":"user","timestamp":"2026-07-09T00:00:04Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r2","content":"ok","is_error":false}]}}`,
173
+ )
174
+
175
+ secondTrace, secondCity, err := s.traceAndMap("growing")
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ if len(secondTrace.Events) != 2 {
180
+ t.Fatalf("events after append = %d, want 2", len(secondTrace.Events))
181
+ }
182
+ if secondTrace == firstTrace {
183
+ t.Fatal("trace cache was reused after the session file changed")
184
+ }
185
+ if secondCity == firstCity {
186
+ t.Fatal("city cache was reused after the session file changed")
187
+ }
188
+ }
189
+
190
+ func TestSessionsFreshBypassesListTTL(t *testing.T) {
191
+ claudeDir := t.TempDir()
192
+ writeServerSession(t, filepath.Join(claudeDir, "first.jsonl"),
193
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"first","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
194
+ )
195
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
196
+
197
+ initial := requestSessions(t, s, "/api/sessions")
198
+ if len(initial) != 1 {
199
+ t.Fatalf("initial sessions = %d, want 1", len(initial))
200
+ }
201
+ writeServerSession(t, filepath.Join(claudeDir, "second.jsonl"),
202
+ `{"type":"user","timestamp":"2026-07-09T00:00:01Z","sessionId":"second","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
203
+ )
204
+
205
+ cached := requestSessions(t, s, "/api/sessions")
206
+ if len(cached) != 1 {
207
+ t.Fatalf("cached sessions = %d, want 1", len(cached))
208
+ }
209
+ fresh := requestSessions(t, s, "/api/sessions?fresh=1")
210
+ if len(fresh) != 2 {
211
+ t.Fatalf("fresh sessions = %d, want 2", len(fresh))
212
+ }
213
+ }
214
+
215
+ func TestConcurrentFreshGenerationReusesCompletedScan(t *testing.T) {
216
+ claudeDir := t.TempDir()
217
+ writeServerSession(t, filepath.Join(claudeDir, "session.jsonl"),
218
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"session","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
219
+ )
220
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
221
+
222
+ // Two fresh callers that enter before either scan completes observe the
223
+ // same generation. The second must reuse the first completed scan.
224
+ observed := s.freshGen
225
+ if _, err := s.listSessionsObserved(true, observed); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ if _, err := s.listSessionsObserved(true, observed); err != nil {
229
+ t.Fatal(err)
230
+ }
231
+ if s.freshGen != observed+1 {
232
+ t.Fatalf("fresh generation = %d, want %d", s.freshGen, observed+1)
233
+ }
234
+ }
235
+
236
+ func TestInflightLoadsShareOrAdvanceFileSnapshots(t *testing.T) {
237
+ t.Run("same fingerprint shares one snapshot", func(t *testing.T) {
238
+ s, source, session := newBlockingServer(t)
239
+ owner := make(chan traceMapResult, 1)
240
+ waiter := make(chan traceMapResult, 1)
241
+ go func() {
242
+ trace, city, err := s.traceAndMap("blocking")
243
+ owner <- traceMapResult{trace: trace, city: city, err: err}
244
+ }()
245
+ <-source.started
246
+ go func() {
247
+ trace, city, err := s.traceAndMap("blocking")
248
+ waiter <- traceMapResult{trace: trace, city: city, err: err}
249
+ }()
250
+ close(source.release)
251
+
252
+ first, second := <-owner, <-waiter
253
+ if first.err != nil || second.err != nil {
254
+ t.Fatalf("owner error=%v waiter error=%v", first.err, second.err)
255
+ }
256
+ if first.trace != second.trace || first.city != second.city {
257
+ t.Fatal("same file version did not share one trace/city snapshot")
258
+ }
259
+ if got := source.parses.Load(); got != 1 {
260
+ t.Fatalf("parse count = %d, want 1", got)
261
+ }
262
+ _ = session
263
+ })
264
+
265
+ t.Run("new fingerprint reloads after older inflight", func(t *testing.T) {
266
+ s, source, session := newBlockingServer(t)
267
+ owner := make(chan traceMapResult, 1)
268
+ newer := make(chan traceMapResult, 1)
269
+ go func() {
270
+ trace, city, err := s.traceAndMap("blocking")
271
+ owner <- traceMapResult{trace: trace, city: city, err: err}
272
+ }()
273
+ <-source.started
274
+ appendServerSession(t, session, "v2")
275
+ go func() {
276
+ trace, city, err := s.traceAndMap("blocking")
277
+ newer <- traceMapResult{trace: trace, city: city, err: err}
278
+ }()
279
+ close(source.release)
280
+
281
+ first, second := <-owner, <-newer
282
+ if first.err != nil || second.err != nil {
283
+ t.Fatalf("owner error=%v newer error=%v", first.err, second.err)
284
+ }
285
+ if first.trace.Session.Title != "v1" || second.trace.Session.Title != "v1\nv2" {
286
+ t.Fatalf("titles = %q then %q", first.trace.Session.Title, second.trace.Session.Title)
287
+ }
288
+ if got := source.parses.Load(); got != 2 {
289
+ t.Fatalf("parse count = %d, want 2", got)
290
+ }
291
+ })
292
+ }
293
+
294
+ // A loader that panics mid-load must still close the inflight done channel
295
+ // and drop the inflight entry. Before the fix, net/http's per-connection
296
+ // recover swallowed such a panic while the entry stayed registered, so every
297
+ // later snapshot/report request for that session blocked forever on <-done
298
+ // until the server was restarted.
299
+ func TestInflightLoadSurvivesPanickingLoader(t *testing.T) {
300
+ s, source, _ := newBlockingServer(t)
301
+ source.panicMsg = "loader bug"
302
+
303
+ owner := make(chan traceMapResult, 1)
304
+ waiter := make(chan traceMapResult, 1)
305
+ go func() {
306
+ trace, city, err := s.traceAndMap("blocking")
307
+ owner <- traceMapResult{trace: trace, city: city, err: err}
308
+ }()
309
+ <-source.started
310
+ go func() {
311
+ trace, city, err := s.traceAndMap("blocking")
312
+ waiter <- traceMapResult{trace: trace, city: city, err: err}
313
+ }()
314
+ close(source.release)
315
+
316
+ first := awaitTraceMapResult(t, owner, "owner request")
317
+ if first.err == nil || !strings.Contains(first.err.Error(), "loader bug") {
318
+ t.Fatalf("owner error = %v, want the recovered panic", first.err)
319
+ }
320
+ // The waiter either shared the failed load or, arriving after cleanup,
321
+ // ran its own parse (which succeeds past the first). Either way it must
322
+ // return instead of blocking on a leaked done channel.
323
+ second := awaitTraceMapResult(t, waiter, "waiter request")
324
+ if second.err != nil && !strings.Contains(second.err.Error(), "loader bug") {
325
+ t.Fatalf("waiter error = %v, want nil or the recovered panic", second.err)
326
+ }
327
+
328
+ // The key must not stay wedged: a fresh request reloads and succeeds.
329
+ trace, city, err := s.traceAndMap("blocking")
330
+ if err != nil || trace == nil || city == nil {
331
+ t.Fatalf("request after recovered panic: trace=%v city=%v err=%v", trace, city, err)
332
+ }
333
+
334
+ s.traceStore.mu.Lock()
335
+ leaked := len(s.traceStore.inflight)
336
+ s.traceStore.mu.Unlock()
337
+ if leaked != 0 {
338
+ t.Fatalf("inflight entries leaked: %d", leaked)
339
+ }
340
+ }
341
+
342
+ func awaitTraceMapResult(t *testing.T, ch <-chan traceMapResult, what string) traceMapResult {
343
+ t.Helper()
344
+ select {
345
+ case result := <-ch:
346
+ return result
347
+ case <-time.After(10 * time.Second):
348
+ t.Fatalf("%s never returned; inflight done channel leaked", what)
349
+ return traceMapResult{}
350
+ }
351
+ }
352
+
353
+ func TestServerLoadsCodexSessions(t *testing.T) {
354
+ claudeDir := t.TempDir()
355
+ codexDir := t.TempDir()
356
+ root := t.TempDir()
357
+ if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Demo\n"), 0o644); err != nil {
358
+ t.Fatal(err)
359
+ }
360
+ session := filepath.Join(codexDir, "codex.jsonl")
361
+ writeServerJSONL(t, session,
362
+ map[string]any{
363
+ "timestamp": "2026-07-09T00:00:00Z",
364
+ "type": "session_meta",
365
+ "payload": map[string]any{
366
+ "id": "codex-server",
367
+ "cwd": filepath.ToSlash(root),
368
+ },
369
+ },
370
+ map[string]any{
371
+ "timestamp": "2026-07-09T00:00:01Z",
372
+ "type": "response_item",
373
+ "payload": map[string]any{
374
+ "type": "function_call",
375
+ "id": "fc",
376
+ "name": "exec_command",
377
+ "arguments": `{"cmd":"sed -n '1,20p' README.md","workdir":` + quoteJSON(root) + `}`,
378
+ "call_id": "call",
379
+ },
380
+ },
381
+ map[string]any{
382
+ "timestamp": "2026-07-09T00:00:02Z",
383
+ "type": "response_item",
384
+ "payload": map[string]any{
385
+ "type": "function_call_output",
386
+ "call_id": "call",
387
+ "output": "Chunk ID: x\nProcess exited with code 0\nOutput:\n# Demo\n",
388
+ },
389
+ },
390
+ )
391
+
392
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
393
+ sessionsResp := httptest.NewRecorder()
394
+ s.handleSessions(sessionsResp, httptest.NewRequest(http.MethodGet, "/api/sessions", nil))
395
+ if sessionsResp.Code != http.StatusOK {
396
+ t.Fatalf("sessions status = %d body=%s", sessionsResp.Code, sessionsResp.Body.String())
397
+ }
398
+ var sessions []model.SessionMeta
399
+ if err := json.Unmarshal(sessionsResp.Body.Bytes(), &sessions); err != nil {
400
+ t.Fatal(err)
401
+ }
402
+ if len(sessions) != 1 || sessions[0].ID != "codex-server" || sessions[0].Harness != "codex" {
403
+ t.Fatalf("sessions = %#v", sessions)
404
+ }
405
+
406
+ traceResp := httptest.NewRecorder()
407
+ s.handleSessionResource(traceResp, httptest.NewRequest(http.MethodGet, "/api/sessions/codex-server/trace", nil))
408
+ if traceResp.Code != http.StatusOK {
409
+ t.Fatalf("trace status = %d body=%s", traceResp.Code, traceResp.Body.String())
410
+ }
411
+ var trace model.Trace
412
+ if err := json.Unmarshal(traceResp.Body.Bytes(), &trace); err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ if trace.Session.Harness != "codex" || len(trace.Events) != 1 || trace.Events[0].Targets[0].Path != "README.md" {
416
+ t.Fatalf("trace = %#v", trace)
417
+ }
418
+ }
419
+
420
+ func TestCodexAgentAPIsDoNotDeriveChildAcrossDuplicateRootIDs(t *testing.T) {
421
+ claudeDir := t.TempDir()
422
+ codexDir := t.TempDir()
423
+ repoRoot := t.TempDir()
424
+ if err := os.WriteFile(filepath.Join(repoRoot, "main.go"), []byte("package demo\n"), 0o644); err != nil {
425
+ t.Fatal(err)
426
+ }
427
+ rootAPath := filepath.Join(codexDir, "root-a.jsonl")
428
+ rootBPath := filepath.Join(codexDir, "root-b.jsonl")
429
+ childBPath := filepath.Join(codexDir, "child-b.jsonl")
430
+ rootMeta := func() map[string]any {
431
+ return map[string]any{
432
+ "type": "session_meta",
433
+ "payload": map[string]any{
434
+ "id": "shared-root",
435
+ "cwd": filepath.ToSlash(repoRoot),
436
+ },
437
+ }
438
+ }
439
+ writeServerJSONL(t, rootAPath, rootMeta())
440
+ writeServerJSONL(t, rootBPath,
441
+ rootMeta(),
442
+ map[string]any{
443
+ "type": "response_item",
444
+ "payload": map[string]any{
445
+ "type": "function_call",
446
+ "id": "fc-child-b",
447
+ "name": "spawn_agent",
448
+ "arguments": `{"message":"owned by root B"}`,
449
+ "call_id": "call-child-b",
450
+ },
451
+ },
452
+ map[string]any{
453
+ "type": "response_item",
454
+ "payload": map[string]any{
455
+ "type": "function_call_output",
456
+ "call_id": "call-child-b",
457
+ "output": `{"agent_id":"child-b"}`,
458
+ },
459
+ },
460
+ )
461
+ writeServerJSONL(t, childBPath, map[string]any{
462
+ "type": "session_meta",
463
+ "payload": map[string]any{
464
+ "id": "child-b",
465
+ "session_id": "shared-root",
466
+ "parent_thread_id": "shared-root",
467
+ "cwd": filepath.ToSlash(repoRoot),
468
+ "thread_source": "subagent",
469
+ "source": map[string]any{
470
+ "subagent": map[string]any{
471
+ "thread_spawn": map[string]any{
472
+ "parent_thread_id": "shared-root",
473
+ "depth": 1,
474
+ "agent_nickname": "Root B Child",
475
+ },
476
+ },
477
+ },
478
+ },
479
+ })
480
+
481
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
482
+ sessions := requestSessions(t, s, "/api/sessions")
483
+ if len(sessions) != 2 {
484
+ t.Fatalf("visible sessions = %#v, want duplicate-ID roots only", sessions)
485
+ }
486
+ rootAKey := adapter.SessionKey("codex", rootAPath)
487
+ rootBKey := adapter.SessionKey("codex", rootBPath)
488
+ childBKey := adapter.SessionKey("codex", childBPath)
489
+ rootBNodeID := adapter.AgentNodeID("codex", rootBKey, "codex-agent:child-b")
490
+
491
+ rootAGraphResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/"+rootAKey+"/agents")
492
+ if rootAGraphResp.Code != http.StatusOK {
493
+ t.Fatalf("root A graph status=%d body=%q", rootAGraphResp.Code, rootAGraphResp.Body.String())
494
+ }
495
+ var rootAGraph model.AgentGraph
496
+ if err := json.Unmarshal(rootAGraphResp.Body.Bytes(), &rootAGraph); err != nil {
497
+ t.Fatal(err)
498
+ }
499
+ if len(rootAGraph.Agents) != 1 {
500
+ t.Fatalf("root A absorbed root B child: %#v", rootAGraph.Agents)
501
+ }
502
+
503
+ rootBGraphResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/"+rootBKey+"/agents")
504
+ if rootBGraphResp.Code != http.StatusOK {
505
+ t.Fatalf("root B graph status=%d body=%q", rootBGraphResp.Code, rootBGraphResp.Body.String())
506
+ }
507
+ var rootBGraph model.AgentGraph
508
+ if err := json.Unmarshal(rootBGraphResp.Body.Bytes(), &rootBGraph); err != nil {
509
+ t.Fatal(err)
510
+ }
511
+ if len(rootBGraph.Agents) != 2 || rootBGraph.Agents[1].ID != rootBNodeID || rootBGraph.Agents[1].LinkQuality != model.AgentLinkQualityExact {
512
+ t.Fatalf("root B exact child = %#v", rootBGraph.Agents)
513
+ }
514
+
515
+ for _, nodeID := range []string{
516
+ rootBNodeID,
517
+ adapter.AgentNodeID("codex", rootAKey, "session:"+childBKey),
518
+ } {
519
+ traceResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/"+rootAKey+"/agents/"+nodeID+"/trace")
520
+ if traceResp.Code != http.StatusNotFound || strings.TrimSpace(traceResp.Body.String()) != "agent not found" {
521
+ t.Fatalf("root A node %q status=%d body=%q", nodeID, traceResp.Code, traceResp.Body.String())
522
+ }
523
+ }
524
+ }
525
+
526
+ func TestServerRetainsClaudeSubagentInCatalog(t *testing.T) {
527
+ claudeDir := t.TempDir()
528
+ session := filepath.Join(claudeDir, "root-id.jsonl")
529
+ subagent := filepath.Join(claudeDir, "root-id", "subagents", "agent-child.jsonl")
530
+ if err := os.MkdirAll(filepath.Dir(subagent), 0o755); err != nil {
531
+ t.Fatal(err)
532
+ }
533
+ writeServerSession(t, session,
534
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"root-id","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
535
+ )
536
+ writeServerSession(t, subagent,
537
+ `{"type":"user","timestamp":"2026-07-09T00:00:01Z","sessionId":"root-id","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"internal"}}`,
538
+ )
539
+
540
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
541
+ sessions := requestSessions(t, s, "/api/sessions")
542
+ if len(sessions) != 1 || sessions[0].ID != "root-id" {
543
+ t.Fatalf("sessions = %#v", sessions)
544
+ }
545
+ childKey := adapter.SessionKey("claude-code", subagent)
546
+ child, ok := s.sessionCatalog[childKey]
547
+ if !ok || !child.Auxiliary || child.ID != "child" {
548
+ t.Fatalf("catalog child = %#v, ok = %v", child, ok)
549
+ }
550
+
551
+ if err := os.Remove(subagent); err != nil {
552
+ t.Fatal(err)
553
+ }
554
+ requestSessions(t, s, "/api/sessions?fresh=1")
555
+ if _, ok := s.sessionCatalog[childKey]; ok {
556
+ t.Fatalf("stale child retained in fresh catalog: %#v", s.sessionCatalog[childKey])
557
+ }
558
+ }
559
+
560
+ func TestAgentAPIsAreRootScoped(t *testing.T) {
561
+ s, source := newAgentAPIServer(t)
562
+
563
+ sessions := requestSessions(t, s, "/api/sessions")
564
+ if len(sessions) != 2 {
565
+ t.Fatalf("visible sessions = %#v, want two roots", sessions)
566
+ }
567
+ for _, session := range sessions {
568
+ if session.Auxiliary {
569
+ t.Fatalf("auxiliary session leaked into visible list: %#v", session)
570
+ }
571
+ }
572
+
573
+ graphResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents")
574
+ if graphResp.Code != http.StatusOK {
575
+ t.Fatalf("graph status = %d body=%q", graphResp.Code, graphResp.Body.String())
576
+ }
577
+ var graph model.AgentGraph
578
+ if err := json.Unmarshal(graphResp.Body.Bytes(), &graph); err != nil {
579
+ t.Fatal(err)
580
+ }
581
+ if graph.RootSessionKey != "root-a" || len(graph.Agents) != 5 || graph.Agents[0].ID != "main-a" {
582
+ t.Fatalf("graph = %#v", graph)
583
+ }
584
+
585
+ otherGraph := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-b/agents")
586
+ if otherGraph.Code != http.StatusOK {
587
+ t.Fatalf("other graph status = %d body=%q", otherGraph.Code, otherGraph.Body.String())
588
+ }
589
+
590
+ mainResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents/main-a/trace")
591
+ if mainResp.Code != http.StatusOK {
592
+ t.Fatalf("main trace status = %d body=%q", mainResp.Code, mainResp.Body.String())
593
+ }
594
+ var mainTrace model.Trace
595
+ if err := json.Unmarshal(mainResp.Body.Bytes(), &mainTrace); err != nil {
596
+ t.Fatal(err)
597
+ }
598
+ if mainTrace.Session.ID != "root-a" {
599
+ t.Fatalf("main trace session = %#v", mainTrace.Session)
600
+ }
601
+
602
+ childResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents/child-a/trace")
603
+ if childResp.Code != http.StatusOK {
604
+ t.Fatalf("child trace status = %d body=%q", childResp.Code, childResp.Body.String())
605
+ }
606
+ var childWire struct {
607
+ Events []struct {
608
+ Targets json.RawMessage `json:"targets"`
609
+ } `json:"events"`
610
+ }
611
+ if err := json.Unmarshal(childResp.Body.Bytes(), &childWire); err != nil {
612
+ t.Fatal(err)
613
+ }
614
+ if len(childWire.Events) != 2 || string(childWire.Events[1].Targets) != "[]" {
615
+ t.Fatalf("empty child targets serialized as null: %s", childResp.Body.String())
616
+ }
617
+ var childTrace model.Trace
618
+ if err := json.Unmarshal(childResp.Body.Bytes(), &childTrace); err != nil {
619
+ t.Fatal(err)
620
+ }
621
+ if childTrace.Session.ID != "child-a" || childTrace.Stats.FilesInRepo != 2 {
622
+ t.Fatalf("child trace = %#v", childTrace)
623
+ }
624
+ if len(childTrace.Events) != 2 || len(childTrace.Events[0].Targets) != 1 || childTrace.Events[0].Targets[0].FileID == nil {
625
+ t.Fatalf("child target was not assigned against root city: %#v", childTrace.Events)
626
+ }
627
+ _, rootCity, err := s.traceAndMap("root-a")
628
+ if err != nil {
629
+ t.Fatal(err)
630
+ }
631
+ wantFileID := 0
632
+ for _, file := range rootCity.Files {
633
+ if file.Path == "b.go" {
634
+ wantFileID = file.ID
635
+ }
636
+ }
637
+ if got := *childTrace.Events[0].Targets[0].FileID; wantFileID == 0 || got != wantFileID {
638
+ t.Fatalf("child file id = %d, want root city b.go id %d", got, wantFileID)
639
+ }
640
+
641
+ zeroResp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents/zero-a/trace")
642
+ if zeroResp.Code != http.StatusOK {
643
+ t.Fatalf("zero-event child status = %d body=%q", zeroResp.Code, zeroResp.Body.String())
644
+ }
645
+ var zeroWire struct {
646
+ Events json.RawMessage `json:"events"`
647
+ Marks json.RawMessage `json:"marks"`
648
+ }
649
+ if err := json.Unmarshal(zeroResp.Body.Bytes(), &zeroWire); err != nil {
650
+ t.Fatal(err)
651
+ }
652
+ if string(zeroWire.Events) != "[]" || string(zeroWire.Marks) != "[]" {
653
+ t.Fatalf("zero-event child slices serialized as null: %s", zeroResp.Body.String())
654
+ }
655
+
656
+ var childMeta model.SessionMeta
657
+ for _, meta := range source.metas {
658
+ if meta.ID == "child-a" {
659
+ childMeta = meta
660
+ break
661
+ }
662
+ }
663
+ cachedChild, childCity, err := s.traceAndMapMeta(childMeta)
664
+ if err != nil {
665
+ t.Fatal(err)
666
+ }
667
+ wantCachedFileID := 0
668
+ for _, file := range childCity.Files {
669
+ if file.Path == "b.go" {
670
+ wantCachedFileID = file.ID
671
+ }
672
+ }
673
+ if got := cachedChild.Events[0].Targets[0].FileID; got == nil || *got != wantCachedFileID {
674
+ t.Fatalf("cached child file id mutated by root projection: got=%v want=%d", got, wantCachedFileID)
675
+ }
676
+ if cachedChild.Events[1].Targets != nil || cachedChild.Events[1].Outside != nil {
677
+ t.Fatalf("cached child empty slices were mutated: %#v", cachedChild.Events[1])
678
+ }
679
+ projected := traceAgainstCity(cachedChild, rootCity)
680
+ if projected.Events[1].Targets == nil || projected.Events[1].Outside == nil {
681
+ t.Fatalf("projected child empty slices are nil: %#v", projected.Events[1])
682
+ }
683
+ projected.Events[0].Targets[0].Path = "changed.go"
684
+ if cachedChild.Events[0].Targets[0].Path != "b.go" {
685
+ t.Fatalf("projected child shares target storage with cache: %#v", cachedChild.Events[0].Targets[0])
686
+ }
687
+ projected.Events[0].Targets[0].Lines[0][0] = 99
688
+ if got := cachedChild.Events[0].Targets[0].Lines[0][0]; got != 4 {
689
+ t.Fatalf("projected child shares target line storage with cache: got=%d want=4", got)
690
+ }
691
+ sourceChild := source.traces[filepath.Clean(childMeta.Path)]
692
+ if got := sourceChild.Events[0].Targets[0].Lines[0][0]; got != 4 {
693
+ t.Fatalf("projected child shares target line storage with source: got=%d want=4", got)
694
+ }
695
+
696
+ // The raw layer caches the child parse: re-requesting the same file
697
+ // version must serve from cache instead of re-reading the JSONL.
698
+ parsesBeforeSecondChild := source.parses["child-a"]
699
+ secondChild := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents/child-a/trace")
700
+ if secondChild.Code != http.StatusOK || source.parses["child-a"] != parsesBeforeSecondChild {
701
+ t.Fatalf("child re-request reparsed: status=%d parses=%d body=%q", secondChild.Code, source.parses["child-a"], secondChild.Body.String())
702
+ }
703
+
704
+ for _, tc := range []struct {
705
+ name string
706
+ method string
707
+ path string
708
+ status int
709
+ body string
710
+ }{
711
+ {name: "missing trace", method: http.MethodGet, path: "/api/sessions/root-a/agents/missing-a/trace", status: http.StatusConflict, body: "agent trace unavailable: missing"},
712
+ {name: "failed launch", method: http.MethodGet, path: "/api/sessions/root-a/agents/failed-a/trace", status: http.StatusConflict, body: "agent trace unavailable: unavailable"},
713
+ {name: "unknown node", method: http.MethodGet, path: "/api/sessions/root-a/agents/unknown/trace", status: http.StatusNotFound, body: "agent not found"},
714
+ {name: "cross root node", method: http.MethodGet, path: "/api/sessions/root-a/agents/child-b/trace", status: http.StatusNotFound, body: "agent not found"},
715
+ {name: "graph method", method: http.MethodPost, path: "/api/sessions/root-a/agents", status: http.StatusMethodNotAllowed, body: "method not allowed"},
716
+ {name: "trace method", method: http.MethodPost, path: "/api/sessions/root-a/agents/child-a/trace", status: http.StatusMethodNotAllowed, body: "method not allowed"},
717
+ } {
718
+ t.Run(tc.name, func(t *testing.T) {
719
+ resp := requestSessionResource(t, s, tc.method, tc.path)
720
+ if resp.Code != tc.status || strings.TrimSpace(resp.Body.String()) != tc.body {
721
+ t.Fatalf("status=%d body=%q", resp.Code, resp.Body.String())
722
+ }
723
+ })
724
+ }
725
+ }
726
+
727
+ func TestAuxiliarySessionCannotBeAgentRoot(t *testing.T) {
728
+ s, _ := newAgentAPIServer(t)
729
+ resp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/child-a-key/agents")
730
+ if resp.Code != http.StatusNotFound || strings.TrimSpace(resp.Body.String()) != "session not found" {
731
+ t.Fatalf("status=%d body=%q", resp.Code, resp.Body.String())
732
+ }
733
+ }
734
+
735
+ func TestChildAgentTraceReusesRootCityMap(t *testing.T) {
736
+ s, _ := newAgentAPIServer(t)
737
+ requestSessions(t, s, "/api/sessions")
738
+ rootMeta := s.sessionCatalog["root-a"]
739
+
740
+ var builtRoots []string
741
+ buildCityMap := citymap.Builder{}.Build
742
+ s.buildCityMap = func(repoRoot string, trace *model.Trace) (*model.CityMap, error) {
743
+ builtRoots = append(builtRoots, repoRoot)
744
+ return buildCityMap(repoRoot, trace)
745
+ }
746
+
747
+ resp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/root-a/agents/child-a/trace")
748
+ if resp.Code != http.StatusOK {
749
+ t.Fatalf("child trace status = %d body=%q", resp.Code, resp.Body.String())
750
+ }
751
+ var trace model.Trace
752
+ if err := json.Unmarshal(resp.Body.Bytes(), &trace); err != nil {
753
+ t.Fatal(err)
754
+ }
755
+ if trace.Stats.FilesInRepo != 2 || len(trace.Events) == 0 || len(trace.Events[0].Targets) == 0 || trace.Events[0].Targets[0].FileID == nil {
756
+ t.Fatalf("child trace was not projected against root city: %#v", trace)
757
+ }
758
+ if len(builtRoots) != 1 || builtRoots[0] != rootMeta.Cwd {
759
+ t.Fatalf("city builds = %q, want only root %q", builtRoots, rootMeta.Cwd)
760
+ }
761
+ }
762
+
763
+ func TestAgentGraphCacheReusesMatchingFingerprint(t *testing.T) {
764
+ s, source := newAgentAPIServer(t)
765
+ root := requestSessions(t, s, "/api/sessions")[0]
766
+
767
+ first, err := s.agentGraph(root)
768
+ if err != nil {
769
+ t.Fatal(err)
770
+ }
771
+ second, err := s.agentGraph(root)
772
+ if err != nil {
773
+ t.Fatal(err)
774
+ }
775
+ if first != second {
776
+ t.Fatal("matching graph fingerprint did not reuse the cached graph")
777
+ }
778
+ if got := source.graphBuilds.Load(); got != 1 {
779
+ t.Fatalf("graph builds = %d, want 1", got)
780
+ }
781
+ }
782
+
783
+ func TestAgentGraphInflightSharesEightConcurrentBuilds(t *testing.T) {
784
+ s, source := newAgentAPIServer(t)
785
+ root := requestSessions(t, s, "/api/sessions")[0]
786
+ source.graphStarted = make(chan struct{})
787
+ source.graphRelease = make(chan struct{})
788
+
789
+ results := make(chan error, 8)
790
+ for range 8 {
791
+ go func() {
792
+ _, err := s.agentGraph(root)
793
+ results <- err
794
+ }()
795
+ }
796
+ <-source.graphStarted
797
+ close(source.graphRelease)
798
+ for range 8 {
799
+ if err := <-results; err != nil {
800
+ t.Fatal(err)
801
+ }
802
+ }
803
+ if got := source.graphBuilds.Load(); got != 1 {
804
+ t.Fatalf("concurrent graph builds = %d, want 1", got)
805
+ }
806
+ }
807
+
808
+ func TestAgentGraphCacheRebuildsWhenInputChanges(t *testing.T) {
809
+ s, source := newAgentAPIServer(t)
810
+ root := requestSessions(t, s, "/api/sessions")[0]
811
+
812
+ if _, err := s.agentGraph(root); err != nil {
813
+ t.Fatal(err)
814
+ }
815
+ if _, err := s.agentGraph(root); err != nil {
816
+ t.Fatal(err)
817
+ }
818
+ appendServerSession(t, root.Path, "changed")
819
+ if _, err := s.agentGraph(root); err != nil {
820
+ t.Fatal(err)
821
+ }
822
+ if got := source.graphBuilds.Load(); got != 2 {
823
+ t.Fatalf("graph builds after input change = %d, want 2", got)
824
+ }
825
+ }
826
+
827
+ func TestFreshScanInvalidatesAgentGraphCache(t *testing.T) {
828
+ s, source := newAgentAPIServer(t)
829
+ root := requestSessions(t, s, "/api/sessions")[0]
830
+
831
+ if _, err := s.agentGraph(root); err != nil {
832
+ t.Fatal(err)
833
+ }
834
+ if _, err := s.agentGraph(root); err != nil {
835
+ t.Fatal(err)
836
+ }
837
+ requestSessions(t, s, "/api/sessions?fresh=1")
838
+ if _, err := s.agentGraph(root); err != nil {
839
+ t.Fatal(err)
840
+ }
841
+ if got := source.graphBuilds.Load(); got != 2 {
842
+ t.Fatalf("graph builds after fresh scan = %d, want 2", got)
843
+ }
844
+ }
845
+
846
+ func TestFreshScanReloadsClaudeSidecarMetadata(t *testing.T) {
847
+ claudeDir := t.TempDir()
848
+ root := filepath.Join(claudeDir, "root-id.jsonl")
849
+ child := filepath.Join(claudeDir, "root-id", "subagents", "agent-child.jsonl")
850
+ if err := os.MkdirAll(filepath.Dir(child), 0o755); err != nil {
851
+ t.Fatal(err)
852
+ }
853
+ writeServerSession(t, root,
854
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"root-id","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
855
+ )
856
+ writeServerSession(t, child,
857
+ `{"type":"user","timestamp":"2026-07-09T00:00:01Z","sessionId":"root-id","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"internal"}}`,
858
+ )
859
+
860
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
861
+ requestSessions(t, s, "/api/sessions")
862
+ childKey := adapter.SessionKey("claude-code", child)
863
+ if got := s.sessionCatalog[childKey].Agent.Role; got != "" {
864
+ t.Fatalf("initial role = %q, want empty", got)
865
+ }
866
+
867
+ sidecar := strings.TrimSuffix(child, ".jsonl") + ".meta.json"
868
+ if err := os.WriteFile(sidecar, []byte(`{"agentType":"Explore","description":"Inspect child","toolUseId":"call-child","spawnDepth":2}`), 0o644); err != nil {
869
+ t.Fatal(err)
870
+ }
871
+ requestSessions(t, s, "/api/sessions?fresh=1")
872
+ meta := s.sessionCatalog[childKey]
873
+ if meta.Agent == nil || meta.Agent.Role != "Explore" || meta.Agent.Depth != 2 || meta.Agent.LaunchCallID != "call-child" {
874
+ t.Fatalf("fresh child agent meta = %#v", meta.Agent)
875
+ }
876
+ }
877
+
878
+ func TestServerSkipsCodexSubagentSessions(t *testing.T) {
879
+ claudeDir := t.TempDir()
880
+ codexDir := t.TempDir()
881
+ mainSession := filepath.Join(codexDir, "main.jsonl")
882
+ subagentSession := filepath.Join(codexDir, "subagent.jsonl")
883
+ writeServerJSONL(t, mainSession, map[string]any{
884
+ "timestamp": "2026-07-10T00:00:00Z",
885
+ "type": "session_meta",
886
+ "payload": map[string]any{
887
+ "id": "main-thread",
888
+ "cwd": "/tmp",
889
+ "source": "vscode",
890
+ },
891
+ })
892
+ writeServerJSONL(t, subagentSession,
893
+ map[string]any{
894
+ "timestamp": "2026-07-10T00:00:01Z",
895
+ "type": "session_meta",
896
+ "payload": map[string]any{
897
+ "id": "child-thread",
898
+ "cwd": "/tmp",
899
+ "source": map[string]any{
900
+ "subagent": map[string]any{
901
+ "thread_spawn": map[string]any{"parent_thread_id": "main-thread"},
902
+ },
903
+ },
904
+ },
905
+ },
906
+ map[string]any{
907
+ "timestamp": "2026-07-10T00:00:02Z",
908
+ "type": "session_meta",
909
+ "payload": map[string]any{
910
+ "id": "main-thread",
911
+ "cwd": "/tmp",
912
+ "source": "vscode",
913
+ },
914
+ },
915
+ )
916
+
917
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
918
+ sessions, err := s.scanSessions()
919
+ if err != nil {
920
+ t.Fatal(err)
921
+ }
922
+ if len(sessions) != 1 || sessions[0].ID != "main-thread" || sessions[0].Path != mainSession {
923
+ t.Fatalf("sessions = %#v", sessions)
924
+ }
925
+
926
+ // Explicitly opening an auxiliary rollout keeps it available internally
927
+ // without leaking it into the visible session list.
928
+ explicit := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), OpenSession: subagentSession})
929
+ explicitSessions, err := explicit.listSessions()
930
+ if err != nil {
931
+ t.Fatal(err)
932
+ }
933
+ if len(explicitSessions) != 1 || explicitSessions[0].ID != "main-thread" {
934
+ t.Fatalf("explicit sessions = %#v", explicitSessions)
935
+ }
936
+ childKey := adapter.SessionKey("codex", subagentSession)
937
+ if child, ok := explicit.sessionCatalog[childKey]; !ok || child.ID != "child-thread" || !child.Auxiliary {
938
+ t.Fatalf("explicit catalog child = %#v, ok = %v", child, ok)
939
+ }
940
+ }
941
+
942
+ func TestRepoMapServesCitymapWithoutSession(t *testing.T) {
943
+ repoRoot := t.TempDir()
944
+ if err := os.WriteFile(filepath.Join(repoRoot, "a.go"), []byte("package demo\n"), 0o644); err != nil {
945
+ t.Fatal(err)
946
+ }
947
+ if err := os.WriteFile(filepath.Join(repoRoot, "b.go"), []byte("package demo\n\nfunc B() {}\n"), 0o644); err != nil {
948
+ t.Fatal(err)
949
+ }
950
+
951
+ s := New(Config{RepoRoot: repoRoot, MapOnly: true})
952
+ resp := httptest.NewRecorder()
953
+ s.handleRepoMap(resp, httptest.NewRequest(http.MethodGet, "/api/repomap", nil))
954
+ if resp.Code != http.StatusOK {
955
+ t.Fatalf("repomap status = %d body=%s", resp.Code, resp.Body.String())
956
+ }
957
+ var city model.CityMap
958
+ if err := json.Unmarshal(resp.Body.Bytes(), &city); err != nil {
959
+ t.Fatal(err)
960
+ }
961
+ if len(city.Files) != 2 || city.Repo.Root == "" {
962
+ t.Fatalf("city = %#v", city)
963
+ }
964
+
965
+ // A second request returns the cached build.
966
+ if _, err := s.repoCityMap(repoRoot); err != nil {
967
+ t.Fatal(err)
968
+ }
969
+ }
970
+
971
+ func TestRepoMapAcceptsRepoQueryParam(t *testing.T) {
972
+ repoRoot := t.TempDir()
973
+ if err := os.WriteFile(filepath.Join(repoRoot, "a.go"), []byte("package demo\n"), 0o644); err != nil {
974
+ t.Fatal(err)
975
+ }
976
+
977
+ // No RepoRoot configured; the repo comes entirely from the query param.
978
+ s := New(Config{})
979
+ resp := httptest.NewRecorder()
980
+ s.handleRepoMap(resp, httptest.NewRequest(http.MethodGet, "/api/repomap?repo="+url.QueryEscape(repoRoot), nil))
981
+ if resp.Code != http.StatusOK {
982
+ t.Fatalf("repomap status = %d body=%s", resp.Code, resp.Body.String())
983
+ }
984
+ var city model.CityMap
985
+ if err := json.Unmarshal(resp.Body.Bytes(), &city); err != nil {
986
+ t.Fatal(err)
987
+ }
988
+ if len(city.Files) != 1 {
989
+ t.Fatalf("city = %#v", city)
990
+ }
991
+ }
992
+
993
+ func TestRepoMapWithoutRepoRootReturns404(t *testing.T) {
994
+ s := New(Config{})
995
+ resp := httptest.NewRecorder()
996
+ s.handleRepoMap(resp, httptest.NewRequest(http.MethodGet, "/api/repomap", nil))
997
+ if resp.Code != http.StatusNotFound {
998
+ t.Fatalf("repomap status = %d, want 404", resp.Code)
999
+ }
1000
+ }
1001
+
1002
+ func TestRepoMapCacheExpiresWhenRepoChanges(t *testing.T) {
1003
+ repoRoot := t.TempDir()
1004
+ if err := os.WriteFile(filepath.Join(repoRoot, "a.go"), []byte("package demo\n"), 0o644); err != nil {
1005
+ t.Fatal(err)
1006
+ }
1007
+
1008
+ s := New(Config{RepoRoot: repoRoot})
1009
+ first, err := s.repoCityMap(repoRoot)
1010
+ if err != nil {
1011
+ t.Fatal(err)
1012
+ }
1013
+ if len(first.Files) != 1 {
1014
+ t.Fatalf("initial files = %d, want 1", len(first.Files))
1015
+ }
1016
+
1017
+ // add a file, then age the cache entry past its TTL: the next build must
1018
+ // pick up the new file instead of returning the stale map
1019
+ if err := os.WriteFile(filepath.Join(repoRoot, "b.go"), []byte("package demo\n"), 0o644); err != nil {
1020
+ t.Fatal(err)
1021
+ }
1022
+ abs, _ := filepath.Abs(repoRoot)
1023
+ s.repoMapMu.Lock()
1024
+ entry := s.repoMaps[abs]
1025
+ entry.builtAt = entry.builtAt.Add(-2 * repoMapTTL)
1026
+ s.repoMaps[abs] = entry
1027
+ s.repoMapMu.Unlock()
1028
+
1029
+ second, err := s.repoCityMap(repoRoot)
1030
+ if err != nil {
1031
+ t.Fatal(err)
1032
+ }
1033
+ if len(second.Files) != 2 {
1034
+ t.Fatalf("files after change = %d, want 2 (stale cache returned)", len(second.Files))
1035
+ }
1036
+ }
1037
+
1038
+ func TestRepoMapCacheIsBounded(t *testing.T) {
1039
+ s := New(Config{})
1040
+ for i := 0; i < repoMapMaxEntries+5; i++ {
1041
+ repo := t.TempDir()
1042
+ if err := os.WriteFile(filepath.Join(repo, "a.go"), []byte("package demo\n"), 0o644); err != nil {
1043
+ t.Fatal(err)
1044
+ }
1045
+ if _, err := s.repoCityMap(repo); err != nil {
1046
+ t.Fatal(err)
1047
+ }
1048
+ }
1049
+ s.repoMapMu.Lock()
1050
+ n := len(s.repoMaps)
1051
+ s.repoMapMu.Unlock()
1052
+ if n > repoMapMaxEntries {
1053
+ t.Fatalf("repo map cache size = %d, want <= %d", n, repoMapMaxEntries)
1054
+ }
1055
+ }
1056
+
1057
+ func writeServerSession(t *testing.T, path string, lines ...string) {
1058
+ t.Helper()
1059
+ content := ""
1060
+ for _, line := range lines {
1061
+ content += line + "\n"
1062
+ }
1063
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1064
+ t.Fatal(err)
1065
+ }
1066
+ }
1067
+
1068
+ func appendServerSession(t *testing.T, path string, lines ...string) {
1069
+ t.Helper()
1070
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
1071
+ if err != nil {
1072
+ t.Fatal(err)
1073
+ }
1074
+ for _, line := range lines {
1075
+ if _, err := f.WriteString(line + "\n"); err != nil {
1076
+ _ = f.Close()
1077
+ t.Fatal(err)
1078
+ }
1079
+ }
1080
+ if err := f.Close(); err != nil {
1081
+ t.Fatal(err)
1082
+ }
1083
+ }
1084
+
1085
+ func requestSessions(t *testing.T, s *Server, target string) []model.SessionMeta {
1086
+ t.Helper()
1087
+ resp := httptest.NewRecorder()
1088
+ s.handleSessions(resp, httptest.NewRequest(http.MethodGet, target, nil))
1089
+ if resp.Code != http.StatusOK {
1090
+ t.Fatalf("sessions status = %d body=%s", resp.Code, resp.Body.String())
1091
+ }
1092
+ var sessions []model.SessionMeta
1093
+ if err := json.Unmarshal(resp.Body.Bytes(), &sessions); err != nil {
1094
+ t.Fatal(err)
1095
+ }
1096
+ return sessions
1097
+ }
1098
+
1099
+ func requestSessionResource(t *testing.T, s *Server, method, target string) *httptest.ResponseRecorder {
1100
+ t.Helper()
1101
+ resp := httptest.NewRecorder()
1102
+ s.handleSessionResource(resp, httptest.NewRequest(method, target, nil))
1103
+ return resp
1104
+ }
1105
+
1106
+ type agentAPISource struct {
1107
+ dir string
1108
+ metas map[string]model.SessionMeta
1109
+ traces map[string]*model.Trace
1110
+ graphs map[string]*model.AgentGraph
1111
+ parses map[string]int
1112
+ graphBuilds atomic.Int32
1113
+ graphStarted chan struct{}
1114
+ graphRelease chan struct{}
1115
+ }
1116
+
1117
+ func (s *agentAPISource) Harness() string { return "agent-api" }
1118
+ func (s *agentAPISource) SessionDir() string { return s.dir }
1119
+ func (s *agentAPISource) ListSessions() ([]model.SessionMeta, error) {
1120
+ return nil, nil
1121
+ }
1122
+
1123
+ func (s *agentAPISource) Summarize(path string) (model.SessionMeta, error) {
1124
+ meta, ok := s.metas[filepath.Clean(path)]
1125
+ if !ok {
1126
+ return model.SessionMeta{}, os.ErrNotExist
1127
+ }
1128
+ return meta, nil
1129
+ }
1130
+
1131
+ func (s *agentAPISource) Parse(path string) (*model.Trace, error) {
1132
+ trace, ok := s.traces[filepath.Clean(path)]
1133
+ if !ok {
1134
+ return nil, os.ErrNotExist
1135
+ }
1136
+ s.parses[trace.Session.ID]++
1137
+ clone := *trace
1138
+ clone.Events = append([]model.Event(nil), trace.Events...)
1139
+ for i := range clone.Events {
1140
+ clone.Events[i].Targets = append([]model.Target(nil), trace.Events[i].Targets...)
1141
+ clone.Events[i].Outside = append([]model.OutsideTouch(nil), trace.Events[i].Outside...)
1142
+ }
1143
+ clone.Marks = append([]model.Mark(nil), trace.Marks...)
1144
+ return &clone, nil
1145
+ }
1146
+
1147
+ func (s *agentAPISource) BuildAgentGraph(root model.SessionMeta, _ []model.SessionMeta) (*model.AgentGraph, error) {
1148
+ build := s.graphBuilds.Add(1)
1149
+ if build == 1 && s.graphStarted != nil {
1150
+ close(s.graphStarted)
1151
+ <-s.graphRelease
1152
+ }
1153
+ graph, ok := s.graphs[root.Key]
1154
+ if !ok {
1155
+ return nil, os.ErrNotExist
1156
+ }
1157
+ clone := *graph
1158
+ clone.Agents = append([]model.AgentNode(nil), graph.Agents...)
1159
+ return &clone, nil
1160
+ }
1161
+
1162
+ func (s *agentAPISource) AgentGraphInputs(root model.SessionMeta, _ []model.SessionMeta) ([]string, error) {
1163
+ return []string{root.Path}, nil
1164
+ }
1165
+
1166
+ func newAgentAPIServer(t *testing.T) (*Server, *agentAPISource) {
1167
+ t.Helper()
1168
+ dir := t.TempDir()
1169
+ rootRepo := t.TempDir()
1170
+ childRepo := t.TempDir()
1171
+ otherRepo := t.TempDir()
1172
+ for path, content := range map[string]string{
1173
+ filepath.Join(rootRepo, "a.go"): "package demo\n",
1174
+ filepath.Join(rootRepo, "b.go"): "package demo\n\nfunc B() {}\n",
1175
+ filepath.Join(childRepo, "b.go"): "package demo\n",
1176
+ filepath.Join(otherRepo, "c.go"): "package other\n",
1177
+ } {
1178
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1179
+ t.Fatal(err)
1180
+ }
1181
+ }
1182
+
1183
+ paths := map[string]string{}
1184
+ for _, id := range []string{"root-a", "child-a", "child-zero", "root-b", "child-b"} {
1185
+ paths[id] = filepath.Join(dir, id+".jsonl")
1186
+ writeServerSession(t, paths[id], "{}")
1187
+ }
1188
+ metas := map[string]model.SessionMeta{
1189
+ filepath.Clean(paths["root-a"]): {
1190
+ Key: "root-a", ID: "root-a", Harness: "agent-api", Path: paths["root-a"], Cwd: rootRepo, EventCount: 1,
1191
+ },
1192
+ filepath.Clean(paths["child-a"]): {
1193
+ Key: "child-a-key", ID: "child-a", Harness: "agent-api", Path: paths["child-a"], Cwd: childRepo, EventCount: 2, Auxiliary: true,
1194
+ },
1195
+ filepath.Clean(paths["child-zero"]): {
1196
+ Key: "child-zero-key", ID: "child-zero", Harness: "agent-api", Path: paths["child-zero"], Cwd: childRepo, EventCount: 0, Auxiliary: true,
1197
+ },
1198
+ filepath.Clean(paths["root-b"]): {
1199
+ Key: "root-b", ID: "root-b", Harness: "agent-api", Path: paths["root-b"], Cwd: otherRepo, EventCount: 1,
1200
+ },
1201
+ filepath.Clean(paths["child-b"]): {
1202
+ Key: "child-b-key", ID: "child-b", Harness: "agent-api", Path: paths["child-b"], Cwd: otherRepo, EventCount: 1, Auxiliary: true,
1203
+ },
1204
+ }
1205
+ traces := map[string]*model.Trace{}
1206
+ for id, target := range map[string]string{
1207
+ "root-a": "a.go", "child-a": "b.go", "root-b": "c.go", "child-b": "c.go",
1208
+ } {
1209
+ meta := metas[filepath.Clean(paths[id])]
1210
+ events := []model.Event{{Seq: 0, Tool: "Read", Action: "read", Targets: []model.Target{{Path: target, Touch: "read"}}}}
1211
+ if id == "child-a" {
1212
+ events[0].Targets[0].Lines = [][2]int{{4, 8}}
1213
+ events = append(events, model.Event{Seq: 1, Tool: "Exec", Action: "exec", Targets: []model.Target{}, Outside: []model.OutsideTouch{}})
1214
+ }
1215
+ traces[filepath.Clean(paths[id])] = &model.Trace{
1216
+ Version: 1,
1217
+ Session: model.TraceSession{ID: id, Harness: "agent-api", Cwd: meta.Cwd, Path: meta.Path, EventCount: len(events)},
1218
+ Events: events,
1219
+ Marks: []model.Mark{},
1220
+ Stats: model.ComputeStats(&model.Trace{Events: events}, 1, model.ObservabilityExact),
1221
+ }
1222
+ }
1223
+ zeroMeta := metas[filepath.Clean(paths["child-zero"])]
1224
+ traces[filepath.Clean(paths["child-zero"])] = &model.Trace{
1225
+ Version: 1,
1226
+ Session: model.TraceSession{ID: "child-zero", Harness: "agent-api", Cwd: zeroMeta.Cwd, Path: zeroMeta.Path, EventCount: 0},
1227
+ Events: []model.Event{},
1228
+ Marks: []model.Mark{},
1229
+ Stats: model.ComputeStats(&model.Trace{Events: []model.Event{}}, 1, model.ObservabilityExact),
1230
+ }
1231
+ graphs := map[string]*model.AgentGraph{
1232
+ "root-a": {
1233
+ Version: model.AgentGraphVersion, RootSessionKey: "root-a",
1234
+ Agents: []model.AgentNode{
1235
+ {ID: "main-a", Kind: model.AgentKindMain, Label: "Main", Status: model.AgentStatusMain, TraceAvailability: model.TraceAvailabilityAvailable, TraceSessionKey: "root-a"},
1236
+ {ID: "child-a", ParentID: "main-a", Depth: 1, Kind: model.AgentKindSubagent, Label: "Child A", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityAvailable, TraceSessionKey: "child-a-key"},
1237
+ {ID: "zero-a", ParentID: "main-a", Depth: 1, Kind: model.AgentKindSubagent, Label: "Zero", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityAvailable, TraceSessionKey: "child-zero-key"},
1238
+ {ID: "missing-a", ParentID: "main-a", Depth: 1, Kind: model.AgentKindSubagent, Label: "Missing", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityMissing},
1239
+ {ID: "failed-a", ParentID: "main-a", Depth: 1, Kind: model.AgentKindSubagent, Label: "Failed", Status: model.AgentStatusFailed, TraceAvailability: model.TraceAvailabilityUnavailable},
1240
+ },
1241
+ },
1242
+ "root-b": {
1243
+ Version: model.AgentGraphVersion, RootSessionKey: "root-b",
1244
+ Agents: []model.AgentNode{
1245
+ {ID: "main-b", Kind: model.AgentKindMain, Label: "Main", Status: model.AgentStatusMain, TraceAvailability: model.TraceAvailabilityAvailable, TraceSessionKey: "root-b"},
1246
+ {ID: "child-b", ParentID: "main-b", Depth: 1, Kind: model.AgentKindSubagent, Label: "Child B", Status: model.AgentStatusLaunched, TraceAvailability: model.TraceAvailabilityAvailable, TraceSessionKey: "child-b-key"},
1247
+ },
1248
+ },
1249
+ }
1250
+ source := &agentAPISource{dir: dir, metas: metas, traces: traces, graphs: graphs, parses: map[string]int{}}
1251
+ s := New(Config{})
1252
+ s.adapters = []adapter.Source{source}
1253
+ return s, source
1254
+ }
1255
+
1256
+ type blockingSource struct {
1257
+ dir string
1258
+ root string
1259
+ started chan struct{}
1260
+ release chan struct{}
1261
+ parses atomic.Int32
1262
+ // panicMsg makes the first parse panic after release instead of
1263
+ // returning, mimicking a loader bug mid-load.
1264
+ panicMsg string
1265
+ }
1266
+
1267
+ func (s *blockingSource) Harness() string { return "blocking" }
1268
+ func (s *blockingSource) SessionDir() string { return s.dir }
1269
+ func (s *blockingSource) ListSessions() ([]model.SessionMeta, error) {
1270
+ return nil, nil
1271
+ }
1272
+
1273
+ func (s *blockingSource) Summarize(path string) (model.SessionMeta, error) {
1274
+ return model.SessionMeta{
1275
+ Key: adapter.SessionKey(s.Harness(), path),
1276
+ ID: "blocking",
1277
+ Harness: s.Harness(),
1278
+ Path: path,
1279
+ Cwd: s.root,
1280
+ }, nil
1281
+ }
1282
+
1283
+ func (s *blockingSource) Parse(path string) (*model.Trace, error) {
1284
+ content, err := os.ReadFile(path)
1285
+ if err != nil {
1286
+ return nil, err
1287
+ }
1288
+ if s.parses.Add(1) == 1 {
1289
+ close(s.started)
1290
+ <-s.release
1291
+ if s.panicMsg != "" {
1292
+ panic(s.panicMsg)
1293
+ }
1294
+ }
1295
+ return &model.Trace{
1296
+ Version: 1,
1297
+ Session: model.TraceSession{
1298
+ ID: "blocking",
1299
+ Harness: s.Harness(),
1300
+ Title: strings.TrimSpace(string(content)),
1301
+ Cwd: s.root,
1302
+ Path: path,
1303
+ },
1304
+ Events: []model.Event{},
1305
+ Marks: []model.Mark{},
1306
+ }, nil
1307
+ }
1308
+
1309
+ type traceMapResult struct {
1310
+ trace *model.Trace
1311
+ city *model.CityMap
1312
+ err error
1313
+ }
1314
+
1315
+ func newBlockingServer(t *testing.T) (*Server, *blockingSource, string) {
1316
+ t.Helper()
1317
+ dir := t.TempDir()
1318
+ root := t.TempDir()
1319
+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package demo\n"), 0o644); err != nil {
1320
+ t.Fatal(err)
1321
+ }
1322
+ session := filepath.Join(dir, "blocking.jsonl")
1323
+ writeServerSession(t, session, "v1")
1324
+ source := &blockingSource{dir: dir, root: root, started: make(chan struct{}), release: make(chan struct{})}
1325
+ s := New(Config{})
1326
+ s.adapters = []adapter.Source{source}
1327
+ return s, source, session
1328
+ }
1329
+
1330
+ func quoteJSON(path string) string {
1331
+ return strconv.Quote(filepath.ToSlash(path))
1332
+ }
1333
+
1334
+ func writeServerJSONL(t *testing.T, path string, values ...any) {
1335
+ t.Helper()
1336
+ content := ""
1337
+ for _, value := range values {
1338
+ b, err := json.Marshal(value)
1339
+ if err != nil {
1340
+ t.Fatal(err)
1341
+ }
1342
+ content += string(b) + "\n"
1343
+ }
1344
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1345
+ t.Fatal(err)
1346
+ }
1347
+ }
1348
+
1349
+ func TestSessionAgentsForSourceWithoutGraphSupport(t *testing.T) {
1350
+ piRoot := t.TempDir()
1351
+ project := filepath.Join(piRoot, "--proj--")
1352
+ if err := os.MkdirAll(project, 0o755); err != nil {
1353
+ t.Fatal(err)
1354
+ }
1355
+ sessionPath := filepath.Join(project, "s1.jsonl")
1356
+ lines := `{"type":"session","version":3,"id":"pi-1","timestamp":"2026-07-21T14:34:07.238Z","cwd":"/tmp/proj"}
1357
+ {"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-21T14:34:22.963Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1}}
1358
+ {"type":"message","id":"e2","parentId":"e1","timestamp":"2026-07-21T14:34:23.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1","name":"bash","arguments":{"command":"ls"}}],"stopReason":"toolUse"}}
1359
+ {"type":"message","id":"e3","parentId":"e2","timestamp":"2026-07-21T14:34:24.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"bash","content":[{"type":"text","text":"ok"}],"isError":false}}
1360
+ `
1361
+ if err := os.WriteFile(sessionPath, []byte(lines), 0o644); err != nil {
1362
+ t.Fatal(err)
1363
+ }
1364
+
1365
+ s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: piRoot})
1366
+ if sessions := requestSessions(t, s, "/api/sessions"); len(sessions) != 1 {
1367
+ t.Fatalf("sessions = %#v", sessions)
1368
+ }
1369
+ key := adapter.SessionKey("pi", sessionPath)
1370
+
1371
+ // pi implements no AgentGraphSource; the agents endpoint must answer with
1372
+ // a single-root graph, not an error, or the UI treats every pi session as
1373
+ // a failed load and retries.
1374
+ resp := requestSessionResource(t, s, http.MethodGet, "/api/sessions/"+key+"/agents")
1375
+ if resp.Code != http.StatusOK {
1376
+ t.Fatalf("agents status=%d body=%q", resp.Code, resp.Body.String())
1377
+ }
1378
+ var graph model.AgentGraph
1379
+ if err := json.Unmarshal(resp.Body.Bytes(), &graph); err != nil {
1380
+ t.Fatal(err)
1381
+ }
1382
+ if graph.RootSessionKey != key || len(graph.Agents) != 1 {
1383
+ t.Fatalf("graph = %#v", graph)
1384
+ }
1385
+ root := graph.Agents[0]
1386
+ if root.Kind != model.AgentKindMain || root.TraceSessionKey != key || root.LinkMethod != model.AgentLinkMethodRoot || root.TraceEventCount != 1 {
1387
+ t.Fatalf("root node = %#v", root)
1388
+ }
1389
+ }