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