1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/bnema/zut/packages/agent/modes"
16 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"
18 "github.com/google/uuid"
19)
20
21func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
22 root := t.TempDir()
23 cwd := t.TempDir()
24 otherCWD := t.TempDir()
25 t.Setenv("ZUT_HOME", root)
26
27 session, err := core.NewSession(root, cwd, "provider", "model", "test")
28 if err != nil {
29 t.Fatal(err)
30 }
31 if err := session.AppendMessage(provider.Message{
32 Role: provider.RoleUser,
33 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
34 }); err != nil {
35 t.Fatal(err)
36 }
37 id := session.ID
38 if err := session.Close(); err != nil {
39 t.Fatal(err)
40 }
41
42 ag := core.NewAgent(nil, "model", "", nil)
43 got, err := openOrCreateSession(context.Background(), Args{
44 CWD: otherCWD,
45 Resume: true,
46 ResumeSessionID: id,
47 }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
48 if err != nil {
49 t.Fatal(err)
50 }
51 if got == nil || got.ID != id || got.Meta.CWD != cwd {
52 t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
53 }
54 if got := firstMessageText(ag.Messages()); got != "resume me" {
55 t.Fatalf("resumed message = %q, want resume me", got)
56 }
57 if err := got.Close(); err != nil {
58 t.Fatal(err)
59 }
60
61 namedRoot := filepath.Join(root, "sessions", "agents", "demo")
62 named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
63 if err != nil {
64 t.Fatal(err)
65 }
66 if err := named.AppendMessage(provider.Message{
67 Role: provider.RoleUser,
68 Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
69 }); err != nil {
70 t.Fatal(err)
71 }
72 namedID := named.ID
73 if err := named.Close(); err != nil {
74 t.Fatal(err)
75 }
76
77 namedAgent := core.NewAgent(nil, "model", "", nil)
78 namedGot, err := openOrCreateSession(context.Background(), Args{
79 CWD: otherCWD,
80 Resume: true,
81 ResumeSessionID: namedID,
82 }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
83 if err != nil {
84 t.Fatal(err)
85 }
86 if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
87 t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
88 }
89 if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
90 t.Fatalf("named resumed message = %q, want named resume me", got)
91 }
92 if err := namedGot.Close(); err != nil {
93 t.Fatal(err)
94 }
95}
96
97func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
98 root := t.TempDir()
99 cwd := t.TempDir()
100
101 empty, err := core.NewSession(root, cwd, "provider", "model", "test")
102 if err != nil {
103 t.Fatal(err)
104 }
105 if err := empty.Close(); err != nil {
106 t.Fatal(err)
107 }
108 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
109 t.Fatalf("empty session stat error = %v, want deleted", err)
110 }
111 if got := resumableSessionID(root, empty); got != "" {
112 t.Fatalf("empty session hint ID = %q, want empty", got)
113 }
114
115 arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
116 arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
117 if err != nil {
118 t.Fatal(err)
119 }
120 if err := arbitrary.AppendMessage(provider.Message{
121 Role: provider.RoleUser,
122 Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
123 }); err != nil {
124 t.Fatal(err)
125 }
126 if err := arbitrary.Close(); err != nil {
127 t.Fatal(err)
128 }
129 if got := resumableSessionID(root, arbitrary); got != "" {
130 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
131 }
132}
133
134func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
135 root := t.TempDir()
136 cwd := t.TempDir()
137 otherCWD := t.TempDir()
138 t.Setenv("ZUT_HOME", root)
139
140 dir := core.SessionsDir(root, cwd)
141 if err := os.MkdirAll(dir, 0o755); err != nil {
142 t.Fatal(err)
143 }
144 id := uuid.NewString()
145 metaLine := struct {
146 Type string `json:"type"`
147 Meta core.SessionMeta `json:"meta"`
148 }{
149 Type: "meta",
150 Meta: core.SessionMeta{
151 ID: id,
152 CWD: cwd,
153 Provider: "provider",
154 Model: "model",
155 Started: time.Now().UTC(),
156 Version: "test",
157 },
158 }
159 data, err := json.Marshal(metaLine)
160 if err != nil {
161 t.Fatal(err)
162 }
163 path := filepath.Join(dir, "persisted-empty.jsonl")
164 if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
165 t.Fatal(err)
166 }
167
168 sess, err := openOrCreateSession(context.Background(), Args{
169 CWD: otherCWD,
170 Resume: true,
171 ResumeSessionID: id,
172 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
173 if err != nil {
174 t.Fatal(err)
175 }
176 if sess == nil || sess.Path != path {
177 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
178 }
179 if got := resumableSessionID(root, sess); got != "" {
180 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
181 }
182 if err := sess.Close(); err != nil {
183 t.Fatal(err)
184 }
185}
186
187func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
188 root := t.TempDir()
189 cwd := t.TempDir()
190 t.Setenv("ZUT_HOME", root)
191 id := uuid.NewString()
192
193 _, err := openOrCreateSession(context.Background(), Args{
194 CWD: cwd,
195 Resume: true,
196 ResumeSessionID: id,
197 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
198 if err == nil {
199 t.Fatal("unknown UUID resume unexpectedly succeeded")
200 }
201 if !strings.Contains(err.Error(), id) {
202 t.Fatalf("unknown UUID error = %v, want session ID", err)
203 }
204 if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
205 t.Fatalf("unknown UUID created sessions: %v", sessions)
206 }
207}
208
209func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
210 root := t.TempDir()
211 cwd := t.TempDir()
212 t.Setenv("ZUT_HOME", root)
213 if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
214 t.Fatal(err)
215 }
216 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
217 t.Fatal(err)
218 }
219
220 _, err := openOrCreateSession(context.Background(), Args{
221 CWD: cwd,
222 Resume: true,
223 ResumeSessionID: uuid.NewString(),
224 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
225 if err == nil {
226 t.Fatal("malformed UUID lookup unexpectedly succeeded")
227 }
228 if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
229 t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
230 }
231}
232
233func TestResumeSessionHint(t *testing.T) {
234 id := uuid.NewString()
235 want := "Resume this session with: zut --resume " + id + "\n"
236 if got := resumeSessionHint(id); got != want {
237 t.Fatalf("resume hint = %q, want %q", got, want)
238 }
239}
240
241func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
242 msgs := make([]provider.Message, 0, 101)
243 msgs = append(msgs, provider.Message{
244 Role: provider.RoleTool,
245 AddedToolNames: []string{"lookup_weather"},
246 Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
247 })
248 for idx := 1; idx < 101; idx++ {
249 msgs = append(msgs, provider.Message{
250 Role: provider.RoleUser,
251 Content: []provider.Content{provider.TextBlock{Text: "message"}},
252 })
253 }
254 trimmed := trimMessagesForResume(msgs, 100)
255 if len(trimmed) != 100 {
256 t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
257 }
258 if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
259 t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
260 }
261}
262
263func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
264 for _, total := range []int{100, 101, 102} {
265 t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
266 msgs := make([]provider.Message, 0, total)
267 msgs = append(msgs, provider.Message{
268 Role: provider.RoleUser,
269 Meta: map[string]string{"compaction": "true"},
270 Content: []provider.Content{
271 provider.TextBlock{Text: "## Context Summary (compacted)"},
272 },
273 })
274 for idx := 1; idx < total; idx++ {
275 msgs = append(msgs, provider.Message{
276 Role: provider.RoleUser,
277 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
278 })
279 }
280
281 trimmed := trimMessagesForResume(msgs, 100)
282 if len(trimmed) > 100 {
283 t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
284 }
285 if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
286 t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
287 }
288 })
289 }
290}
291
292func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
293 startup := core.NewAgent(nil, "startup-model", "", nil)
294 startup.SetMessages([]provider.Message{{
295 Role: provider.RoleUser,
296 Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
297 }})
298
299 replacement := core.NewAgent(nil, "replacement-model", "", nil)
300 iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
301
302 resumed := []provider.Message{{
303 Role: provider.RoleUser,
304 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
305 }}
306 liveInteractiveAgent(iv, startup).SetMessages(resumed)
307
308 if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
309 t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
310 }
311 if got := firstMessageText(startup.Messages()); got != "startup transcript" {
312 t.Fatalf("startup agent transcript changed to %q", got)
313 }
314}
315
316func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
317 startup := core.NewAgent(nil, "startup-model", "", nil)
318 if got := liveInteractiveAgent(nil, startup); got != startup {
319 t.Fatalf("fallback agent = %p, want %p", got, startup)
320 }
321}
322
323func TestPersistModelCallbackDoesNotReenterSessionTransition(t *testing.T) {
324 t.Setenv("ZUT_HOME", t.TempDir())
325 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "old-provider", "old-model", "test")
326 if err != nil {
327 t.Fatal(err)
328 }
329 defer sess.Close()
330
331 var persistMu sync.Mutex
332 activeProvider, activeModel := "old-provider", "old-model"
333 persistModel := newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, nil)
334
335 var transitionMu sync.RWMutex
336 sessionTransition := newSessionTransition(&transitionMu)
337 done := make(chan struct{})
338 go func() {
339 sessionTransition(func() {
340 persistModel("new-provider", "new-model")
341 })
342 close(done)
343 }()
344 select {
345 case <-done:
346 case <-time.After(time.Second):
347 t.Fatal("model persistence deadlocked inside the session transition")
348 }
349 if sess.Meta.Provider != "new-provider" || sess.Meta.Model != "new-model" {
350 t.Fatalf("session model = %q/%q, want new-provider/new-model", sess.Meta.Provider, sess.Meta.Model)
351 }
352 if activeProvider != "new-provider" || activeModel != "new-model" {
353 t.Fatalf("active model = %q/%q, want new-provider/new-model", activeProvider, activeModel)
354 }
355}
356
357func firstMessageText(messages []provider.Message) string {
358 if len(messages) == 0 || len(messages[0].Content) == 0 {
359 return ""
360 }
361 text, _ := messages[0].Content[0].(provider.TextBlock)
362 return text.Text
363}
364
365func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
366 t.Helper()
367 root := t.TempDir()
368 cwd := t.TempDir()
369 sess, err := core.NewSession(root, cwd, providerName, model, "test")
370 if err != nil {
371 t.Fatal(err)
372 }
373 if err := sess.AppendMessage(provider.Message{
374 Role: provider.RoleUser,
375 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
376 }); err != nil {
377 t.Fatal(err)
378 }
379 if usage != (provider.Usage{}) {
380 if err := sess.AppendUsage(usage, usage); err != nil {
381 t.Fatal(err)
382 }
383 }
384 if err := sess.Close(); err != nil {
385 t.Fatal(err)
386 }
387 return sess.Path
388}
389
390func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
391 root := t.TempDir()
392 sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
393 if err != nil {
394 t.Fatal(err)
395 }
396 ag := core.NewAgent(nil, "model", "", nil)
397 gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
398 if err != nil {
399 t.Fatal(err)
400 }
401 if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
402 t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
403 }
404 if _, err := os.Stat(sess.Path); err != nil {
405 t.Fatalf("fresh session disappeared before close: %v", err)
406 }
407 if err := sess.Close(); err != nil {
408 t.Fatal(err)
409 }
410 if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
411 t.Fatalf("fresh empty session remained after close: %v", err)
412 }
413}
414
415func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
416 old := core.NewAgent(nil, "old-model", "", nil)
417 old.SetMessages([]provider.Message{{
418 Role: provider.RoleUser,
419 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
420 }})
421 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
422 InputTokens: 12,
423 OutputTokens: 7,
424 })
425
426 var gotProvider, gotModel string
427 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
428 gotProvider, gotModel = providerName, model
429 return core.NewAgent(nil, model, "", nil), providerName, model, nil
430 })
431 if err != nil {
432 t.Fatal(err)
433 }
434 defer candidate.session.Close()
435
436 if gotProvider != "stored-provider" || gotModel != "stored-model" {
437 t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
438 }
439 if !candidate.rebuilt || candidate.agent == old {
440 t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
441 }
442 if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
443 t.Fatalf("candidate transcript = %q, want resumed transcript", got)
444 }
445 if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
446 t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
447 }
448 if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
449 t.Fatalf("current transcript changed during preparation: %+v", got)
450 }
451}
452
453func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
454 old := core.NewAgent(nil, "old-model", "", nil)
455 old.SetMessages([]provider.Message{{
456 Role: provider.RoleUser,
457 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
458 }})
459 path := syntheticSession(t, "", "", provider.Usage{})
460
461 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
462 t.Fatal("legacy session unexpectedly requested a rebuild")
463 return nil, "", "", nil
464 })
465 if err != nil {
466 t.Fatal(err)
467 }
468 defer candidate.session.Close()
469
470 if candidate.rebuilt || candidate.agent != old {
471 t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
472 }
473 if got := firstMessageText(old.Messages()); got != "current transcript" {
474 t.Fatalf("current transcript changed before commit: %q", got)
475 }
476 if got := firstMessageText(candidate.messages); got != "resumed transcript" {
477 t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
478 }
479}
480
481func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
482 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
483 session, _, err := core.OpenSession(path)
484 if err != nil {
485 t.Fatal(err)
486 }
487 handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
488 if err := session.UpdateCompactHandoff(handoff); err != nil {
489 t.Fatal(err)
490 }
491 if err := session.Close(); err != nil {
492 t.Fatal(err)
493 }
494
495 current := core.NewAgent(nil, "current-model", "", nil)
496 candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
497 if err != nil {
498 t.Fatal(err)
499 }
500 defer candidate.session.Close()
501 if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
502 t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
503 }
504}
505
506func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
507 old := core.NewAgent(nil, "old-model", "", nil)
508 old.SetMessages([]provider.Message{{
509 Role: provider.RoleUser,
510 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
511 }})
512 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
513 wantErr := errors.New("synthetic builder failure")
514
515 if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
516 return nil, "", "", wantErr
517 }); !errors.Is(err, wantErr) {
518 t.Fatalf("prepare error = %v, want %v", err, wantErr)
519 }
520 if got := firstMessageText(old.Messages()); got != "current transcript" {
521 t.Fatalf("current transcript changed after failed preparation: %q", got)
522 }
523
524 // The failed candidate must have released its append handle, leaving
525 // the selected file readable and reopenable for a later retry.
526 reopened, _, err := core.OpenSession(path)
527 if err != nil {
528 t.Fatalf("reopen candidate after failed preparation: %v", err)
529 }
530 if err := reopened.Close(); err != nil {
531 t.Fatal(err)
532 }
533 if _, err := os.Stat(path); err != nil {
534 t.Fatalf("candidate session disappeared after failed preparation: %v", err)
535 }
536}
537
538func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
539 for _, tc := range []struct {
540 name string
541 explicitProvider bool
542 explicitModel bool
543 wantProvider string
544 wantModel string
545 wantBuild bool
546 }{
547 {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
548 {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
549 {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
550 } {
551 t.Run(tc.name, func(t *testing.T) {
552 old := core.NewAgent(nil, "old-model", "", nil)
553 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
554 var gotProvider, gotModel string
555 candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) {
556 gotProvider, gotModel = providerName, model
557 return core.NewAgent(nil, model, "", nil), providerName, model, nil
558 })
559 if err != nil {
560 t.Fatal(err)
561 }
562 defer candidate.session.Close()
563 if tc.wantBuild {
564 if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
565 t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
566 }
567 } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
568 t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
569 }
570 })
571 }
572}
573
574func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
575 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
576 if err != nil {
577 t.Fatal(err)
578 }
579 first := provider.Message{
580 Role: provider.RoleUser,
581 Content: []provider.Content{provider.TextBlock{Text: "first"}},
582 }
583 invalid := provider.Message{
584 Role: provider.RoleAssistant,
585 Content: []provider.Content{provider.ToolCallBlock{
586 ID: "call-1",
587 Name: "tool",
588 Arguments: json.RawMessage("{"),
589 }},
590 }
591 ag := core.NewAgent(nil, "model", "", nil)
592 ag.SetMessages([]provider.Message{first, invalid})
593 next, err := writeNewTranscriptLocked(ag, sess, 0)
594 if err == nil {
595 t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
596 }
597 if next != 1 {
598 t.Fatalf("next persisted message index = %d, want 1", next)
599 }
600
601 second := provider.Message{
602 Role: provider.RoleAssistant,
603 Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
604 }
605 third := provider.Message{
606 Role: provider.RoleTool,
607 Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
608 }
609 ag.SetMessages([]provider.Message{first, second, third})
610 next, err = writeNewTranscriptLocked(ag, sess, next)
611 if err != nil {
612 t.Fatalf("retry transcript: %v", err)
613 }
614 if next != 3 {
615 t.Fatalf("next persisted message index after retry = %d, want 3", next)
616 }
617 if err := sess.Close(); err != nil {
618 t.Fatal(err)
619 }
620
621 reopened, messages, err := core.OpenSession(sess.Path)
622 if err != nil {
623 t.Fatalf("reopen session: %v", err)
624 }
625 t.Cleanup(func() { _ = reopened.Close() })
626 if len(messages) != 3 {
627 t.Fatalf("replayed message count = %d, want 3", len(messages))
628 }
629 if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
630 t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
631 }
632}