返回 DeepSeek-Reasonix
migrate_test.go
根目录 / internal / agent / migrate_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/provider"
12 )
13
14 const legacyEventLog = `{"type":"model.turn.started","id":1,"ts":"t","turn":0,"model":"deepseek"}
15 {"type":"user.message","id":2,"ts":"t","turn":0,"text":"list the files"}
16 {"type":"model.delta","id":3,"ts":"t","turn":0,"channel":"content","text":"sure"}
17 {"type":"model.final","id":4,"ts":"t","turn":0,"content":"On it.","toolCalls":[{"id":"call_1","type":"function","function":{"name":"ls","arguments":"{\"path\":\".\"}","thought_signature":"gemini-event-signed"}}],"usage":{},"costUsd":0}
18 {"type":"tool.result","id":5,"ts":"t","turn":0,"callId":"call_1","ok":true,"output":"a.go\nb.go","durationMs":3}
19 {"type":"model.final","id":6,"ts":"t","turn":0,"content":"There are two files.","toolCalls":[],"usage":{},"costUsd":0}
20 `
21
22 func TestMigrateLegacySessionsReconstructsConversation(t *testing.T) {
23 src := t.TempDir()
24 dest := t.TempDir()
25 if err := os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644); err != nil {
26 t.Fatal(err)
27 }
28
29 n, err := MigrateLegacySessions(src, dest, nil)
30 if err != nil {
31 t.Fatalf("migrate: %v", err)
32 }
33 if n != 1 {
34 t.Fatalf("imported %d sessions, want 1", n)
35 }
36
37 loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl"))
38 if err != nil {
39 t.Fatalf("reload migrated session: %v", err)
40 }
41 got := loaded.Messages
42 if len(got) != 4 {
43 t.Fatalf("message count = %d, want 4 (user, assistant+toolcall, tool, assistant):\n%+v", len(got), got)
44 }
45 if got[0].Role != provider.RoleUser || got[0].Content != "list the files" {
46 t.Errorf("msg0 = %+v, want user 'list the files'", got[0])
47 }
48 if got[1].Role != provider.RoleAssistant || len(got[1].ToolCalls) != 1 ||
49 got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "ls" ||
50 got[1].ToolCalls[0].ThoughtSignature != "gemini-event-signed" {
51 t.Errorf("msg1 = %+v, want assistant with ls tool call call_1", got[1])
52 }
53 if got[2].Role != provider.RoleTool || got[2].ToolCallID != "call_1" ||
54 got[2].Name != "ls" || got[2].Content != "a.go\nb.go" {
55 t.Errorf("msg2 = %+v, want tool result for call_1 named ls", got[2])
56 }
57 if got[3].Role != provider.RoleAssistant || got[3].Content != "There are two files." {
58 t.Errorf("msg3 = %+v, want final assistant text", got[3])
59 }
60 }
61
62 func TestMigrateLegacySessionsReplaysNativeEventLog(t *testing.T) {
63 src := t.TempDir()
64 dest := t.TempDir()
65 path := filepath.Join(src, "native.jsonl")
66 base := NewSession("sys")
67 base.Add(provider.Message{Role: provider.RoleUser, Content: "checkpoint prompt"})
68 if err := base.Save(path); err != nil {
69 t.Fatalf("Save base: %v", err)
70 }
71 loaded, err := LoadSession(path)
72 if err != nil {
73 t.Fatalf("LoadSession base: %v", err)
74 }
75 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "event tail"})
76 if err := loaded.SaveSnapshot(path); err != nil {
77 t.Fatalf("SaveSnapshot tail: %v", err)
78 }
79
80 n, err := MigrateLegacySessions(src, dest, nil)
81 if err != nil {
82 t.Fatalf("migrate: %v", err)
83 }
84 if n != 1 {
85 t.Fatalf("imported %d sessions, want 1", n)
86 }
87 migrated, err := LoadSession(filepath.Join(dest, "native.jsonl"))
88 if err != nil {
89 t.Fatalf("LoadSession migrated: %v", err)
90 }
91 if got := migrated.Messages[len(migrated.Messages)-1].Content; got != "event tail" {
92 t.Fatalf("migrated tail = %q, want event tail", got)
93 }
94 }
95
96 func TestMigrateLegacySessionsBackfillsAlongsideExisting(t *testing.T) {
97 src := t.TempDir()
98 dest := t.TempDir()
99 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
100 os.WriteFile(filepath.Join(dest, "existing.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644)
101
102 n, err := MigrateLegacySessions(src, dest, nil)
103 if err != nil {
104 t.Fatalf("migrate: %v", err)
105 }
106 if n != 1 {
107 t.Errorf("should back-fill the legacy session even when dest has others, imported %d", n)
108 }
109 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil {
110 t.Errorf("legacy session should have been imported: %v", err)
111 }
112 if _, err := os.Stat(filepath.Join(dest, "existing.jsonl")); err != nil {
113 t.Errorf("pre-existing v1+ session must be left intact: %v", err)
114 }
115 }
116
117 func TestMigrateLegacySessionsRunsOnce(t *testing.T) {
118 src := t.TempDir()
119 dest := t.TempDir()
120 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
121
122 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 1 {
123 t.Fatalf("first run: n=%d err=%v, want 1", n, err)
124 }
125 // User deletes the imported session, then a second launch happens.
126 if err := os.Remove(filepath.Join(dest, "chat-1.jsonl")); err != nil {
127 t.Fatal(err)
128 }
129 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
130 t.Fatalf("second run must be a no-op (marker present): n=%d err=%v", n, err)
131 }
132 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); !os.IsNotExist(err) {
133 t.Errorf("a deleted import must not reappear after the one-time migration")
134 }
135 if _, err := os.Stat(filepath.Join(dest, legacyEventsHomeImportMarker)); err != nil {
136 t.Errorf("source-specific import marker missing: %v", err)
137 }
138 if _, err := os.Stat(filepath.Join(dest, legacyImportMarker)); err != nil {
139 t.Errorf("legacy compatibility import marker missing: %v", err)
140 }
141 }
142
143 func TestMigrateLegacySessionsFromExplicitDirIgnoresDefaultMarkers(t *testing.T) {
144 src := t.TempDir()
145 dest := t.TempDir()
146 if err := os.WriteFile(filepath.Join(src, "custom-install.jsonl"), []byte(legacyMessageLog), 0o644); err != nil {
147 t.Fatal(err)
148 }
149 writeImportMarkers(dest, legacyRoutedHomeImportMarker, legacyJsonlPassMarker)
150
151 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
152 t.Fatalf("default migrate with markers: n=%d err=%v, want 0 nil", n, err)
153 }
154 n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil)
155 if err != nil {
156 t.Fatalf("explicit migrate: %v", err)
157 }
158 if n != 1 {
159 t.Fatalf("explicit imported %d sessions, want 1", n)
160 }
161 if _, err := os.Stat(filepath.Join(dest, "custom-install.jsonl")); err != nil {
162 t.Fatalf("explicit imported session missing: %v", err)
163 }
164 if n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil); err != nil || n != 0 {
165 t.Fatalf("explicit migrate should be source-marker idempotent: n=%d err=%v", n, err)
166 }
167 }
168
169 func TestMigrateLegacySessionsRoutedPassIgnoresFlatMarkers(t *testing.T) {
170 src := t.TempDir()
171 dest := t.TempDir()
172 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
173 for _, m := range []string{legacyImportMarker, legacyEventsHomeImportMarker} {
174 if err := os.WriteFile(filepath.Join(dest, m), nil, 0o644); err != nil {
175 t.Fatal(err)
176 }
177 }
178
179 // Flat markers must not block the routed pass — it has to run once for
180 // existing upgraders to re-home sessions the flat import stranded (#3937).
181 n, err := MigrateLegacySessions(src, dest, nil)
182 if err != nil {
183 t.Fatalf("migrate: %v", err)
184 }
185 if n != 1 {
186 t.Fatalf("routed pass should run despite flat markers, got %d", n)
187 }
188 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
189 t.Fatalf("routed marker must gate the second run: n=%d err=%v", n, err)
190 }
191 }
192
193 func TestMigrateLegacySessionsSourceMarkersAreIndependent(t *testing.T) {
194 src := t.TempDir()
195 dest := t.TempDir()
196 os.WriteFile(filepath.Join(src, "appdata-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
197 if err := os.MkdirAll(dest, 0o755); err != nil {
198 t.Fatal(err)
199 }
200 if err := os.WriteFile(filepath.Join(dest, legacyImportMarker), nil, 0o644); err != nil {
201 t.Fatal(err)
202 }
203
204 const appDataMarker = ".legacy-imported.v0-events-appdata"
205 n, err := migrateLegacySessions(src, dest, appDataMarker, nil)
206 if err != nil {
207 t.Fatalf("migrate: %v", err)
208 }
209 if n != 1 {
210 t.Fatalf("independent source marker should allow a new source import, got %d", n)
211 }
212 if _, err := os.Stat(filepath.Join(dest, "appdata-chat.jsonl")); err != nil {
213 t.Errorf("new source session should have been imported: %v", err)
214 }
215 if _, err := os.Stat(filepath.Join(dest, appDataMarker)); err != nil {
216 t.Errorf("new source marker missing: %v", err)
217 }
218 }
219
220 func TestMigrateLegacyConfigSourceDoesNotBlockHomeSource(t *testing.T) {
221 configSrc := t.TempDir()
222 homeSrc := t.TempDir()
223 dest := t.TempDir()
224 os.WriteFile(filepath.Join(homeSrc, "home-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
225
226 if n, err := MigrateLegacySessionsFromConfigDir(configSrc, dest, nil); err != nil || n != 0 {
227 t.Fatalf("config source without events: n=%d err=%v, want 0 nil", n, err)
228 }
229 if _, err := os.Stat(filepath.Join(dest, legacyRoutedConfigImportMarker)); err != nil {
230 t.Fatalf("config source marker missing: %v", err)
231 }
232
233 if n, err := MigrateLegacySessions(homeSrc, dest, nil); err != nil || n != 1 {
234 t.Fatalf("home source after config source: n=%d err=%v, want 1 nil", n, err)
235 }
236 if _, err := os.Stat(filepath.Join(dest, "home-chat.jsonl")); err != nil {
237 t.Fatalf("home source should still import after config source marker: %v", err)
238 }
239 }
240
241 func TestMigrateLegacySessionsSkipsAlreadyImported(t *testing.T) {
242 src := t.TempDir()
243 dest := t.TempDir()
244 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
245 os.WriteFile(filepath.Join(dest, "chat-1.jsonl"), []byte(`{"role":"user","content":"edited"}`+"\n"), 0o644)
246
247 n, err := MigrateLegacySessions(src, dest, nil)
248 if err != nil {
249 t.Fatalf("migrate: %v", err)
250 }
251 if n != 0 {
252 t.Errorf("a same-named existing session must not be overwritten, imported %d", n)
253 }
254 loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl"))
255 if err != nil {
256 t.Fatal(err)
257 }
258 if len(loaded.Messages) != 1 || loaded.Messages[0].Content != "edited" {
259 t.Errorf("existing same-named session was clobbered: %+v", loaded.Messages)
260 }
261 }
262
263 func TestMigrateLegacySessionsNoSrcIsNoop(t *testing.T) {
264 n, err := MigrateLegacySessions(filepath.Join(t.TempDir(), "nope"), t.TempDir(), nil)
265 if err != nil || n != 0 {
266 t.Errorf("missing legacy session dir should be a silent no-op, got n=%d err=%v", n, err)
267 }
268 }
269
270 func writeLegacyMeta(t *testing.T, srcDir, base, workspace, summary string) {
271 t.Helper()
272 b, err := json.Marshal(map[string]string{"workspace": workspace, "summary": summary})
273 if err != nil {
274 t.Fatal(err)
275 }
276 if err := os.WriteFile(filepath.Join(srcDir, base+".meta.json"), b, 0o644); err != nil {
277 t.Fatal(err)
278 }
279 }
280
281 const v1MessageSession = `{"role":"user","content":"recovered after downgrade"}
282 {"role":"assistant","content":"ok"}
283 `
284
285 func TestMigratedJsonlSessionPersistsNewTurns(t *testing.T) {
286 src := t.TempDir()
287 dest := t.TempDir()
288 if err := os.WriteFile(filepath.Join(src, "desktop-legacy.jsonl"), []byte(v1MessageSession), 0o644); err != nil {
289 t.Fatalf("write legacy session: %v", err)
290 }
291 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 1 {
292 t.Fatalf("MigrateLegacySessions: n=%d err=%v", n, err)
293 }
294
295 path := filepath.Join(dest, "desktop-legacy.jsonl")
296 loaded, err := LoadSession(path)
297 if err != nil {
298 t.Fatalf("LoadSession migrated: %v", err)
299 }
300 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "new turn"})
301 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "persisted"})
302 if err := loaded.SaveSnapshot(path); err != nil {
303 t.Fatalf("SaveSnapshot migrated: %v", err)
304 }
305
306 reloaded, err := LoadSession(path)
307 if err != nil {
308 t.Fatalf("LoadSession after switch: %v", err)
309 }
310 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "persisted" {
311 t.Fatalf("migrated session tail = %q, want persisted", got)
312 }
313 }
314
315 // stampMigrated marks src/dest as already through the one-time passes, with the
316 // routing watermark set to `at`. It mirrors what a completed migration leaves
317 // behind so the re-home pass (not the full passes) handles the next run.
318 func stampMigrated(t *testing.T, dest string, at time.Time) {
319 t.Helper()
320 for _, m := range []string{legacyRoutedHomeImportMarker, legacyJsonlPassMarker, legacyImportMarker} {
321 path := filepath.Join(dest, m)
322 if err := os.WriteFile(path, nil, 0o644); err != nil {
323 t.Fatal(err)
324 }
325 if err := os.Chtimes(path, at, at); err != nil {
326 t.Fatal(err)
327 }
328 }
329 }
330
331 // TestRehomeStrandedSessionAfterDowngrade reproduces #4666: after the one-time
332 // routing pass completes, a downgrade-to-old-build writes a project session into
333 // the flat dir. The next upgrade must re-home it into its workspace dir even
334 // though the routing marker is present.
335 func TestRehomeStrandedSessionAfterDowngrade(t *testing.T) {
336 src := t.TempDir()
337 dest := t.TempDir()
338 workspace := t.TempDir()
339 projectDest := t.TempDir()
340 projectDir := func(root string) string {
341 if root == workspace {
342 return projectDest
343 }
344 return ""
345 }
346
347 // Migration already ran a day ago.
348 past := time.Now().Add(-24 * time.Hour)
349 stampMigrated(t, dest, past)
350
351 // The downgraded build then wrote a project session into the flat dir.
352 base := "20260101-000000.000000000-deepseek"
353 sessionPath := filepath.Join(src, base+".jsonl")
354 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
355 t.Fatal(err)
356 }
357 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "downgrade work"}); err != nil {
358 t.Fatal(err)
359 }
360 ref := "sa_20260101_000000_000000000_aabbccddeeff"
361 writeMigratedSubagentArtifact(t, src, ref, base)
362
363 n, err := MigrateLegacySessions(src, dest, projectDir)
364 if err != nil {
365 t.Fatalf("migrate: %v", err)
366 }
367 if n != 1 {
368 t.Fatalf("stranded project session should be re-homed, imported %d", n)
369 }
370 if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil {
371 t.Errorf("session should land in the project dir: %v", err)
372 }
373 // The branch sidecar must follow so the sidebar keeps title/topic.
374 if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil {
375 t.Errorf("branch meta sidecar should be copied alongside: %v", err)
376 }
377 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil {
378 t.Errorf("subagent transcript should be copied alongside: %v", err)
379 }
380 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil {
381 t.Errorf("subagent metadata should be copied alongside: %v", err)
382 }
383 // Source is never modified.
384 if _, err := os.Stat(sessionPath); err != nil {
385 t.Errorf("source session must be left intact: %v", err)
386 }
387 }
388
389 func TestJsonlPassRoutesBranchMetaWhenJsonlMarkerMissing(t *testing.T) {
390 src := t.TempDir()
391 dest := t.TempDir()
392 workspace := t.TempDir()
393 projectDest := t.TempDir()
394 projectDir := func(root string) string {
395 if root == workspace {
396 return projectDest
397 }
398 return ""
399 }
400
401 past := time.Now().Add(-24 * time.Hour)
402 routedMarker := filepath.Join(dest, legacyRoutedHomeImportMarker)
403 if err := os.WriteFile(routedMarker, nil, 0o644); err != nil {
404 t.Fatal(err)
405 }
406 if err := os.Chtimes(routedMarker, past, past); err != nil {
407 t.Fatal(err)
408 }
409
410 base := "20260101-003000.000000000-deepseek"
411 sessionPath := filepath.Join(src, base+".jsonl")
412 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
413 t.Fatal(err)
414 }
415 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "half-upgraded"}); err != nil {
416 t.Fatal(err)
417 }
418 ref := "sa_20260101_003000_000000000_aabbccddeeff"
419 writeMigratedSubagentArtifact(t, src, ref, base)
420
421 n, err := MigrateLegacySessions(src, dest, projectDir)
422 if err != nil {
423 t.Fatalf("migrate: %v", err)
424 }
425 if n != 1 {
426 t.Fatalf("branch-meta jsonl session should be imported once, got %d", n)
427 }
428 if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil {
429 t.Fatalf("session should be routed to the project dir while the jsonl marker is missing: %v", err)
430 }
431 if _, err := os.Stat(filepath.Join(dest, base+".jsonl")); !os.IsNotExist(err) {
432 t.Fatalf("project session must not be copied into the global dir: %v", err)
433 }
434 if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil {
435 t.Fatalf("branch meta sidecar should be copied alongside: %v", err)
436 }
437 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil {
438 t.Fatalf("subagent transcript should be copied alongside: %v", err)
439 }
440 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil {
441 t.Fatalf("subagent metadata should be copied alongside: %v", err)
442 }
443 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
444 t.Fatalf("second run should be a no-op: n=%d err=%v", n, err)
445 }
446 }
447
448 // TestRehomeLeavesGlobalSessionsAlone guards the main risk: the flat dir is also
449 // where CLI/global sessions live. A session with no project scope must stay put.
450 func TestRehomeLeavesGlobalSessionsAlone(t *testing.T) {
451 src := t.TempDir()
452 dest := t.TempDir()
453 projectDir := func(string) string { return t.TempDir() }
454
455 stampMigrated(t, dest, time.Now().Add(-24*time.Hour))
456
457 // A global session (no branch meta, no workspace) written post-migration.
458 base := "20260101-010000.000000000-deepseek"
459 if err := os.WriteFile(filepath.Join(src, base+".jsonl"), []byte(v1MessageSession), 0o644); err != nil {
460 t.Fatal(err)
461 }
462
463 n, err := MigrateLegacySessions(src, dest, projectDir)
464 if err != nil {
465 t.Fatalf("migrate: %v", err)
466 }
467 if n != 0 {
468 t.Errorf("a global flat session must not be re-homed, imported %d", n)
469 }
470 }
471
472 // TestRehomeIgnoresSessionsOlderThanWatermark ensures a session the user
473 // imported and then deleted is not resurrected: only files newer than the
474 // migration watermark are candidates.
475 func TestRehomeIgnoresSessionsOlderThanWatermark(t *testing.T) {
476 src := t.TempDir()
477 dest := t.TempDir()
478 workspace := t.TempDir()
479 projectDest := t.TempDir()
480 projectDir := func(root string) string {
481 if root == workspace {
482 return projectDest
483 }
484 return ""
485 }
486
487 now := time.Now()
488 stampMigrated(t, dest, now) // watermark = now
489
490 // A project session whose mtime predates the watermark (it was already seen
491 // by the original pass and the user deleted the import).
492 base := "20250101-000000.000000000-deepseek"
493 sessionPath := filepath.Join(src, base+".jsonl")
494 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
495 t.Fatal(err)
496 }
497 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
498 t.Fatal(err)
499 }
500 old := now.Add(-48 * time.Hour)
501 if err := os.Chtimes(sessionPath, old, old); err != nil {
502 t.Fatal(err)
503 }
504
505 n, err := MigrateLegacySessions(src, dest, projectDir)
506 if err != nil {
507 t.Fatalf("migrate: %v", err)
508 }
509 if n != 0 {
510 t.Errorf("a pre-watermark session must not be revived, imported %d", n)
511 }
512 }
513
514 // TestRehomeIsIdempotent verifies the second boot does not re-import: the
515 // destination check skips already-routed sessions and the watermark advances.
516 func TestRehomeIsIdempotent(t *testing.T) {
517 src := t.TempDir()
518 dest := t.TempDir()
519 workspace := t.TempDir()
520 projectDest := t.TempDir()
521 projectDir := func(root string) string {
522 if root == workspace {
523 return projectDest
524 }
525 return ""
526 }
527
528 stampMigrated(t, dest, time.Now().Add(-24*time.Hour))
529 base := "20260101-020000.000000000-deepseek"
530 sessionPath := filepath.Join(src, base+".jsonl")
531 os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644)
532 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
533 t.Fatal(err)
534 }
535
536 if n, _ := MigrateLegacySessions(src, dest, projectDir); n != 1 {
537 t.Fatalf("first run should re-home 1, got %d", n)
538 }
539 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
540 t.Fatalf("second run must be a no-op: n=%d err=%v", n, err)
541 }
542 }
543
544 func TestRehomeKeepsWatermarkWhenProjectCopyFails(t *testing.T) {
545 src := t.TempDir()
546 dest := t.TempDir()
547 workspace := t.TempDir()
548 blocker := filepath.Join(t.TempDir(), "not-a-directory")
549 if err := os.WriteFile(blocker, []byte("block"), 0o644); err != nil {
550 t.Fatal(err)
551 }
552 projectDir := func(root string) string {
553 if root == workspace {
554 return filepath.Join(blocker, "sessions")
555 }
556 return ""
557 }
558
559 past := time.Now().Add(-24 * time.Hour).Round(0)
560 stampMigrated(t, dest, past)
561 base := "20260101-023000.000000000-deepseek"
562 sessionPath := filepath.Join(src, base+".jsonl")
563 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
564 t.Fatal(err)
565 }
566 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
567 t.Fatal(err)
568 }
569
570 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
571 t.Fatalf("copy failure should not import: n=%d err=%v", n, err)
572 }
573 info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker))
574 if err != nil {
575 t.Fatal(err)
576 }
577 if !info.ModTime().Equal(past) {
578 t.Fatalf("watermark advanced after copy failure: got %s want %s", info.ModTime(), past)
579 }
580 }
581
582 func TestRehomeKeepsWatermarkWhenSubagentCopyFails(t *testing.T) {
583 src := t.TempDir()
584 dest := t.TempDir()
585 workspace := t.TempDir()
586 projectDest := t.TempDir()
587 projectDir := func(root string) string {
588 if root == workspace {
589 return projectDest
590 }
591 return ""
592 }
593
594 past := time.Now().Add(-24 * time.Hour).Round(0)
595 stampMigrated(t, dest, past)
596 base := "20260101-024000.000000000-deepseek"
597 sessionPath := filepath.Join(src, base+".jsonl")
598 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
599 t.Fatal(err)
600 }
601 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
602 t.Fatal(err)
603 }
604 writeMigratedSubagentArtifact(t, src, "sa_20260101_024000_000000000_aabbccddeeff", base)
605 if err := os.MkdirAll(projectDest, 0o755); err != nil {
606 t.Fatal(err)
607 }
608 if err := os.WriteFile(filepath.Join(projectDest, "subagents"), []byte("block"), 0o644); err != nil {
609 t.Fatal(err)
610 }
611
612 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 1 {
613 t.Fatalf("parent session should still import: n=%d err=%v", n, err)
614 }
615 info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker))
616 if err != nil {
617 t.Fatal(err)
618 }
619 if !info.ModTime().Equal(past) {
620 t.Fatalf("watermark advanced after subagent copy failure: got %s want %s", info.ModTime(), past)
621 }
622 }
623
624 func writeMigratedSubagentArtifact(t *testing.T, sessionDir, ref, parentSession string) {
625 t.Helper()
626 subagentDir := filepath.Join(sessionDir, "subagents")
627 if err := os.MkdirAll(subagentDir, 0o755); err != nil {
628 t.Fatal(err)
629 }
630 if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
631 t.Fatal(err)
632 }
633 meta := SubagentMeta{
634 Ref: ref,
635 Status: SubagentCompleted,
636 Kind: "task",
637 Name: "task",
638 ParentSession: parentSession,
639 }
640 data, err := json.Marshal(meta)
641 if err != nil {
642 t.Fatal(err)
643 }
644 if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
645 t.Fatal(err)
646 }
647 }
648
649 func TestMigrateLegacySessionsRoutesByWorkspaceMeta(t *testing.T) {
650 src := t.TempDir()
651 global := t.TempDir()
652 workspace := t.TempDir()
653 projRoot := t.TempDir()
654 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
655 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
656 writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test")
657
658 n, err := MigrateLegacySessions(src, global, router)
659 if err != nil || n != 1 {
660 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
661 }
662 dest := filepath.Join(projRoot, filepath.Base(workspace), "sessions")
663 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil {
664 t.Fatalf("session should land in the workspace dir: %v", err)
665 }
666 if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); !os.IsNotExist(err) {
667 t.Errorf("session must not also land in the global dir")
668 }
669 titles, err := os.ReadFile(filepath.Join(dest, ".titles.json"))
670 if err != nil {
671 t.Fatalf("titles file: %v", err)
672 }
673 m := map[string]string{}
674 if err := json.Unmarshal(titles, &m); err != nil || m["chat-1.jsonl"] != "fix the retry test" {
675 t.Errorf("title = %q (err=%v), want legacy summary", m["chat-1.jsonl"], err)
676 }
677 }
678
679 func TestMigrateLegacySessionsDeadWorkspaceFallsBackToGlobal(t *testing.T) {
680 src := t.TempDir()
681 global := t.TempDir()
682 projRoot := t.TempDir()
683 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
684 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
685 writeLegacyMeta(t, src, "chat-1", filepath.Join(src, "no-such-workspace"), "")
686
687 n, err := MigrateLegacySessions(src, global, router)
688 if err != nil || n != 1 {
689 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
690 }
691 if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); err != nil {
692 t.Errorf("session with a dead workspace should fall back to the global dir: %v", err)
693 }
694 }
695
696 func TestMigrateLegacySessionsRehomesFlatImport(t *testing.T) {
697 src := t.TempDir()
698 global := t.TempDir()
699 workspace := t.TempDir()
700 projRoot := t.TempDir()
701 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
702 srcLog := filepath.Join(src, "chat-1.events.jsonl")
703 os.WriteFile(srcLog, []byte(legacyEventLog), 0o644)
704 writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test")
705
706 // Simulate the old flat import: file in the global dir, mtime stamped from
707 // the legacy event log.
708 flat := filepath.Join(global, "chat-1.jsonl")
709 os.WriteFile(flat, []byte(`{"role":"user","content":"flat-imported"}`+"\n"), 0o644)
710 info, err := os.Stat(srcLog)
711 if err != nil {
712 t.Fatal(err)
713 }
714 if err := os.Chtimes(flat, info.ModTime(), info.ModTime()); err != nil {
715 t.Fatal(err)
716 }
717
718 n, err := MigrateLegacySessions(src, global, router)
719 if err != nil || n != 1 {
720 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
721 }
722 moved := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")
723 b, err := os.ReadFile(moved)
724 if err != nil {
725 t.Fatalf("re-homed session missing: %v", err)
726 }
727 if !strings.Contains(string(b), "flat-imported") {
728 t.Errorf("re-home must move the existing import, not reconstruct: %s", b)
729 }
730 if _, err := os.Stat(flat); !os.IsNotExist(err) {
731 t.Errorf("flat import should be moved out of the global dir")
732 }
733 }
734
735 func TestMigrateLegacySessionsKeepsNativeSameNameSession(t *testing.T) {
736 src := t.TempDir()
737 global := t.TempDir()
738 workspace := t.TempDir()
739 projRoot := t.TempDir()
740 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
741 srcLog := filepath.Join(src, "chat-1.events.jsonl")
742 os.WriteFile(srcLog, []byte(legacyEventLog), 0o644)
743 old := time.Now().Add(-48 * time.Hour)
744 if err := os.Chtimes(srcLog, old, old); err != nil {
745 t.Fatal(err)
746 }
747 writeLegacyMeta(t, src, "chat-1", workspace, "")
748
749 // A native v1+ session that happens to share the name: mtime won't match
750 // the legacy log, so it must stay where it is.
751 native := filepath.Join(global, "chat-1.jsonl")
752 os.WriteFile(native, []byte(`{"role":"user","content":"native"}`+"\n"), 0o644)
753
754 if _, err := MigrateLegacySessions(src, global, router); err != nil {
755 t.Fatalf("migrate: %v", err)
756 }
757 b, err := os.ReadFile(native)
758 if err != nil || !strings.Contains(string(b), "native") {
759 t.Errorf("native global session must be left intact: err=%v body=%s", err, b)
760 }
761 if _, err := os.Stat(filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")); err != nil {
762 t.Errorf("legacy session should still be reconstructed into its workspace dir: %v", err)
763 }
764 }
765
766 func TestMigrateLegacySessionsSkipsEmptyLog(t *testing.T) {
767 src := t.TempDir()
768 dest := t.TempDir()
769 os.WriteFile(filepath.Join(src, "empty.events.jsonl"), []byte(`{"type":"model.turn.started","id":1,"ts":"t","turn":0}`+"\n"), 0o644)
770
771 n, err := MigrateLegacySessions(src, dest, nil)
772 if err != nil {
773 t.Fatalf("migrate: %v", err)
774 }
775 if n != 0 {
776 t.Errorf("a log with no user/assistant/tool messages should not produce a session, imported %d", n)
777 }
778 }
779
780 const legacyMessageLog = `{"role":"user","content":"hello from v0.x"}
781 {"role":"assistant","content":"hi there","tool_calls":[{"id":"call_1","name":"read_file","arguments":"{\"path\":\"main.go\"}"}]}
782 {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main"}
783 {"role":"assistant","content":"I found the file."}
784 `
785
786 func TestMigrateLegacySessionsImportsJsonlOnly(t *testing.T) {
787 src := t.TempDir()
788 dest := t.TempDir()
789 os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644)
790 writeLegacyMeta(t, src, "acp-chat", "", "ACP session about main.go")
791
792 n, err := MigrateLegacySessions(src, dest, nil)
793 if err != nil {
794 t.Fatalf("migrate: %v", err)
795 }
796 if n != 1 {
797 t.Fatalf("imported %d sessions, want 1 (jsonl-only)", n)
798 }
799
800 destPath := filepath.Join(dest, "acp-chat.jsonl")
801 data, err := os.ReadFile(destPath)
802 if err != nil {
803 t.Fatalf("imported session missing: %v", err)
804 }
805 if !strings.Contains(string(data), `"hello from v0.x"`) {
806 t.Errorf("imported content wrong:\n%s", data)
807 }
808
809 // Title from the meta sidecar should be stored.
810 titles, err := os.ReadFile(filepath.Join(dest, ".titles.json"))
811 if err != nil {
812 t.Fatalf("titles file: %v", err)
813 }
814 m := map[string]string{}
815 if err := json.Unmarshal(titles, &m); err != nil || m["acp-chat.jsonl"] != "ACP session about main.go" {
816 t.Errorf("title = %q (err=%v), want ACP session about main.go", m["acp-chat.jsonl"], err)
817 }
818
819 // Marker must be stamped.
820 if _, err := os.Stat(filepath.Join(dest, legacyJsonlPassMarker)); err != nil {
821 t.Errorf("jsonl pass marker missing: %v", err)
822 }
823 }
824
825 func TestMigrateLegacySessionsPrefersJsonlWhenNewer(t *testing.T) {
826 src := t.TempDir()
827 dest := t.TempDir()
828
829 eventsPath := filepath.Join(src, "chat-1.events.jsonl")
830 jsonlPath := filepath.Join(src, "chat-1.jsonl")
831
832 // Write the event log first (older mtime).
833 os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644)
834 time.Sleep(10 * time.Millisecond) // ensure mtime differs
835 // Write the .jsonl second (newer mtime) — it should be preferred.
836 os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644)
837 writeLegacyMeta(t, src, "chat-1", "", "newer jsonl wins")
838
839 n, err := MigrateLegacySessions(src, dest, nil)
840 if err != nil {
841 t.Fatalf("migrate: %v", err)
842 }
843 if n != 1 {
844 t.Fatalf("imported %d, want 1", n)
845 }
846
847 data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl"))
848 if err != nil {
849 t.Fatal(err)
850 }
851 // The .jsonl content ("hello from v0.x") should win over the reconstructed
852 // event log content ("list the files").
853 if !strings.Contains(string(data), `"hello from v0.x"`) {
854 t.Errorf("expected .jsonl content to be preferred:\n%s", data)
855 }
856 }
857
858 func TestMigrateLegacySessionsFallsBackToEventsWhenJsonlOlder(t *testing.T) {
859 src := t.TempDir()
860 dest := t.TempDir()
861
862 jsonlPath := filepath.Join(src, "chat-1.jsonl")
863 eventsPath := filepath.Join(src, "chat-1.events.jsonl")
864
865 // Write the .jsonl first (older mtime).
866 os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644)
867 time.Sleep(10 * time.Millisecond)
868 // Write the events log second (newer mtime) — events should be reconstructed.
869 os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644)
870 writeLegacyMeta(t, src, "chat-1", "", "events are newer")
871
872 n, err := MigrateLegacySessions(src, dest, nil)
873 if err != nil {
874 t.Fatalf("migrate: %v", err)
875 }
876 if n != 1 {
877 t.Fatalf("imported %d, want 1", n)
878 }
879
880 data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl"))
881 if err != nil {
882 t.Fatal(err)
883 }
884 // The reconstructed event-log content ("list the files") should win because
885 // the events log has a newer mtime.
886 if !strings.Contains(string(data), `"list the files"`) {
887 t.Errorf("expected events content to be reconstructed:\n%s", data)
888 }
889 }
890
891 func TestMigrateLegacySessionsImportsJsonlBakFallback(t *testing.T) {
892 src := t.TempDir()
893 dest := t.TempDir()
894
895 // Only a .jsonl.bak — no .jsonl, no .events.jsonl. Should recover from bak.
896 os.WriteFile(filepath.Join(src, "recovered.jsonl.bak"), []byte(legacyMessageLog), 0o644)
897 writeLegacyMeta(t, src, "recovered", "", "recovered from bak")
898
899 n, err := MigrateLegacySessions(src, dest, nil)
900 if err != nil {
901 t.Fatalf("migrate: %v", err)
902 }
903 if n != 1 {
904 t.Fatalf("imported %d, want 1 (.bak recovery)", n)
905 }
906
907 data, err := os.ReadFile(filepath.Join(dest, "recovered.jsonl"))
908 if err != nil {
909 t.Fatalf("recovered session missing: %v", err)
910 }
911 if !strings.Contains(string(data), `"hello from v0.x"`) {
912 t.Errorf("recovered content wrong:\n%s", data)
913 }
914 }
915
916 func TestMigrateLegacySessionsSkipsBakWhenJsonlExists(t *testing.T) {
917 src := t.TempDir()
918 dest := t.TempDir()
919
920 // Both .jsonl and .jsonl.bak exist — prefer .jsonl.
921 os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyMessageLog), 0o644)
922 os.WriteFile(filepath.Join(src, "chat.jsonl.bak"), []byte(`{"role":"user","content":"stale backup"}`+"\n"), 0o644)
923 writeLegacyMeta(t, src, "chat", "", "from jsonl not bak")
924
925 n, err := MigrateLegacySessions(src, dest, nil)
926 if err != nil {
927 t.Fatalf("migrate: %v", err)
928 }
929 if n != 1 {
930 t.Fatalf("imported %d, want 1", n)
931 }
932
933 data, err := os.ReadFile(filepath.Join(dest, "chat.jsonl"))
934 if err != nil {
935 t.Fatal(err)
936 }
937 if !strings.Contains(string(data), `"hello from v0.x"`) {
938 t.Errorf("expected .jsonl content, not .bak:\n%s", data)
939 }
940 }
941
942 func TestMigrateLegacySessionsSkipsNonMessageJsonl(t *testing.T) {
943 src := t.TempDir()
944 dest := t.TempDir()
945
946 // A .jsonl file that is NOT in message format (starts with event-log "id").
947 os.WriteFile(filepath.Join(src, "bad.jsonl"), []byte(`{"id":1,"type":"model.turn.started"}`+"\n"), 0o644)
948
949 n, err := MigrateLegacySessions(src, dest, nil)
950 if err != nil {
951 t.Fatalf("migrate: %v", err)
952 }
953 if n != 0 {
954 t.Errorf("non-message .jsonl should be skipped, imported %d", n)
955 }
956 }
957
958 func TestMigrateLegacySessionsRecursesIntoSubdirectories(t *testing.T) {
959 src := t.TempDir()
960 global := t.TempDir()
961 workspace := t.TempDir()
962 projRoot := t.TempDir()
963 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
964
965 // Set up a project-scoped subdirectory with sessions.
966 subDir := filepath.Join(src, "Users_Yuki_git_polytone-audio-engine")
967 if err := os.MkdirAll(subDir, 0o755); err != nil {
968 t.Fatal(err)
969 }
970 os.WriteFile(filepath.Join(subDir, "proj-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
971 writeLegacyMeta(t, subDir, "proj-chat", workspace, "project session")
972
973 // Also add a subdirectory that has no session files — should be skipped.
974 emptySub := filepath.Join(src, "empty-dir")
975 os.MkdirAll(emptySub, 0o755)
976
977 n, err := MigrateLegacySessions(src, global, router)
978 if err != nil {
979 t.Fatalf("migrate: %v", err)
980 }
981 if n != 1 {
982 t.Fatalf("imported %d, want 1 (subdirectory session)", n)
983 }
984
985 // Should land in the project session dir, not global.
986 projDest := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "proj-chat.jsonl")
987 if _, err := os.Stat(projDest); err != nil {
988 t.Errorf("subdirectory session not in project dir %s: %v", projDest, err)
989 }
990 if _, err := os.Stat(filepath.Join(global, "proj-chat.jsonl")); !os.IsNotExist(err) {
991 t.Errorf("subdirectory session should not land in global dir")
992 }
993 }
994
995 func TestMigrateLegacySessionsJsonlPassIsIdempotent(t *testing.T) {
996 src := t.TempDir()
997 dest := t.TempDir()
998
999 os.WriteFile(filepath.Join(src, "desktop-session.jsonl"), []byte(legacyMessageLog), 0o644)
1000
1001 // First run imports it.
1002 n, err := MigrateLegacySessions(src, dest, nil)
1003 if err != nil || n != 1 {
1004 t.Fatalf("first run: n=%d err=%v, want 1", n, err)
1005 }
1006 // Delete the imported session.
1007 os.Remove(filepath.Join(dest, "desktop-session.jsonl"))
1008
1009 // Second run: jsonl pass marker exists, must not re-import.
1010 n, err = MigrateLegacySessions(src, dest, nil)
1011 if err != nil || n != 0 {
1012 t.Fatalf("second run must be no-op: n=%d err=%v", n, err)
1013 }
1014 if _, err := os.Stat(filepath.Join(dest, "desktop-session.jsonl")); !os.IsNotExist(err) {
1015 t.Errorf("deleted session must not reappear after jsonl pass marker")
1016 }
1017 }
1018
1019 func TestMigrateLegacySessionsJsonlPassRunsForExistingUpgrader(t *testing.T) {
1020 src := t.TempDir()
1021 dest := t.TempDir()
1022
1023 os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644)
1024
1025 // Simulate an upgrader whose events pass already completed in a prior
1026 // version: the routed marker is stamped but the v3-jsonl marker is not.
1027 writeImportMarkers(dest, legacyRoutedHomeImportMarker)
1028
1029 n, err := MigrateLegacySessions(src, dest, nil)
1030 if err != nil {
1031 t.Fatalf("migrate: %v", err)
1032 }
1033 if n != 1 {
1034 t.Fatalf("imported %d, want 1 (.jsonl-only must reach existing upgraders)", n)
1035 }
1036 if _, err := os.Stat(filepath.Join(dest, "acp-chat.jsonl")); err != nil {
1037 t.Errorf("jsonl-only session not imported for existing upgrader: %v", err)
1038 }
1039 }
1040
1041 // legacyNestedFunctionLog uses the OpenAI-style nested-function tool-call format
1042 // that the TS version wrote: name and arguments live under "function".
1043 const legacyNestedFunctionLog = `{"role":"user","content":"read the file"}
1044 {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"main.go\"}","thought_signature":"gemini-jsonl-signed"}}],"reasoning_content":"need to read it"}
1045 {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main\nfunc main() {}"}
1046 {"role":"assistant","content":"Found the main function."}
1047 `
1048
1049 func TestTransformAndCopyJsonlFlattensNestedToolCalls(t *testing.T) {
1050 src := t.TempDir()
1051 dest := t.TempDir()
1052 os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyNestedFunctionLog), 0o644)
1053 writeLegacyMeta(t, src, "chat", "", "nested tool calls test")
1054
1055 n, err := MigrateLegacySessions(src, dest, nil)
1056 if err != nil {
1057 t.Fatalf("migrate: %v", err)
1058 }
1059 if n != 1 {
1060 t.Fatalf("imported %d, want 1", n)
1061 }
1062
1063 // Reload and verify the tool calls are flat, not nested.
1064 loaded, err := LoadSession(filepath.Join(dest, "chat.jsonl"))
1065 if err != nil {
1066 t.Fatal(err)
1067 }
1068 msgs := loaded.Messages
1069 if len(msgs) != 4 {
1070 t.Fatalf("message count = %d, want 4", len(msgs))
1071 }
1072 // Message 1: assistant with tool call.
1073 if len(msgs[1].ToolCalls) != 1 {
1074 t.Fatalf("assistant tool_calls = %d, want 1", len(msgs[1].ToolCalls))
1075 }
1076 tc := msgs[1].ToolCalls[0]
1077 if tc.ID != "call_1" {
1078 t.Errorf("tool call id = %q, want call_1", tc.ID)
1079 }
1080 if tc.Name != "read_file" {
1081 t.Errorf("tool call name = %q, want read_file", tc.Name)
1082 }
1083 if tc.Arguments != `{"path":"main.go"}` {
1084 t.Errorf("tool call arguments = %q, want {\"path\":\"main.go\"}", tc.Arguments)
1085 }
1086 if tc.ThoughtSignature != "gemini-jsonl-signed" {
1087 t.Errorf("tool call thought_signature = %q, want gemini-jsonl-signed", tc.ThoughtSignature)
1088 }
1089 // Message 2: tool result.
1090 if msgs[2].Role != provider.RoleTool || msgs[2].ToolCallID != "call_1" || msgs[2].Name != "read_file" {
1091 t.Errorf("tool result = %+v, want tool result for call_1", msgs[2])
1092 }
1093 // Message 3: final assistant text.
1094 if msgs[3].Content != "Found the main function." {
1095 t.Errorf("final content = %q", msgs[3].Content)
1096 }
1097 }
1098
1098 lines GO