@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|