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/agent/tools"
17 "github.com/bnema/zut/packages/core"
18 "github.com/bnema/zut/packages/provider"
19 "github.com/google/uuid"
20)
21
22func TestWireRetryLifecyclePersistence(t *testing.T) {
23 session, err := core.NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
24 if err != nil {
25 t.Fatal(err)
26 }
27 agent := core.NewAgent(nil, "model", "system", core.Registry{})
28 agent.SetMessages([]provider.Message{{
29 Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}},
30 }})
31 wireRetryLifecyclePersistence(agent, session)
32 if agent.OnRetryLifecycle == nil {
33 t.Fatal("session-backed agent has no retry lifecycle persistence")
34 }
35 agent.OnRetryLifecycle(core.RetryLifecycleRecord{
36 Event: core.RetryLifecycleRequestFailed, Scope: core.RetryScopeAgent,
37 Attempt: 1, MaxAttempts: 1, Reason: core.RetryReasonServer, Terminal: true,
38 })
39 // Headless modes call WriteNewTranscript on both success and provider
40 // failure. Exercise that real finalization path rather than draining the
41 // session buffer directly.
42 if err := WriteNewTranscript(agent, session, 0); err != nil {
43 t.Fatal(err)
44 }
45 path := session.Path
46 if err := session.Close(); err != nil {
47 t.Fatal(err)
48 }
49 raw, err := os.ReadFile(path)
50 if err != nil {
51 t.Fatal(err)
52 }
53 var lifecycle []core.RetryLifecycleRecord
54 for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
55 var row struct {
56 Type string `json:"type"`
57 RetryLifecycle []core.RetryLifecycleRecord `json:"retry_lifecycle"`
58 }
59 if err := json.Unmarshal([]byte(line), &row); err != nil {
60 t.Fatal(err)
61 }
62 if row.Type == "usage" && len(row.RetryLifecycle) > 0 {
63 lifecycle = row.RetryLifecycle
64 }
65 }
66 if len(lifecycle) != 1 {
67 t.Fatalf("retry lifecycle = %#v, want one record", lifecycle)
68 }
69 record := lifecycle[0]
70 if record.Event != core.RetryLifecycleRequestFailed || record.Scope != core.RetryScopeAgent ||
71 record.Attempt != 1 || record.MaxAttempts != 1 || record.Reason != core.RetryReasonServer || !record.Terminal {
72 t.Fatalf("retry lifecycle record = %#v", record)
73 }
74
75 wireRetryLifecyclePersistence(agent, nil)
76 if agent.OnRetryLifecycle != nil {
77 t.Fatal("sessionless agent retained retry lifecycle persistence")
78 }
79}
80
81func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
82 root := t.TempDir()
83 original, err := core.NewSession(root, "original", "provider", "model", "test")
84 if err != nil {
85 t.Fatal(err)
86 }
87 defer original.Close()
88 next, err := core.NewSession(root, "next", "provider", "model", "test")
89 if err != nil {
90 t.Fatal(err)
91 }
92 defer next.Close()
93 for _, sess := range []*core.Session{original, next} {
94 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil {
95 t.Fatal(err)
96 }
97 }
98
99 boundSession := original
100 currentSession := next // Simulate a completed session switch before callback delivery.
101 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}
102 if err := persistToolResultState(nil, boundSession, result); err != nil {
103 t.Fatal(err)
104 }
105
106 if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone {
107 t.Fatalf("original goal = %#v, want done", original.Meta.Goal)
108 }
109 if currentSession.Meta.Goal == nil || currentSession.Meta.Goal.Status != core.GoalActive {
110 t.Fatalf("current goal = %#v, want active", currentSession.Meta.Goal)
111 }
112}
113
114func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
115 const privateGoal = "private goal text"
116 const privateMission = "private-mission-id"
117 const privateReason = "private active reason"
118 const privateSessionContent = "private session content"
119 var writeFailureGoal core.SessionGoal
120
121 newSession := func(t *testing.T) *core.Session {
122 t.Helper()
123 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
124 if err != nil {
125 t.Fatal(err)
126 }
127 if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: privateSessionContent}}}); err != nil {
128 t.Fatal(err)
129 }
130 t.Cleanup(func() { _ = sess.Close() })
131 return sess
132 }
133
134 tests := []struct {
135 name string
136 setup func(*testing.T) *core.Session
137 update tools.GoalUpdate
138 message string
139 assertUnchanged func(*testing.T, *core.Session)
140 }{
141 {
142 name: "active goal",
143 setup: func(t *testing.T) *core.Session {
144 sess := newSession(t)
145 if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive}); err != nil {
146 t.Fatal(err)
147 }
148 return sess
149 },
150 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},
151 message: "goal transition rejected: the current goal is still active",
152 },
153 {
154 name: "mismatched mission",
155 setup: func(t *testing.T) *core.Session {
156 sess := newSession(t)
157 if err := sess.EnsureMission(privateGoal, core.MissionSourceUser); err != nil {
158 t.Fatal(err)
159 }
160 return sess
161 },
162 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
163 message: "goal transition rejected: it does not belong to the active mission",
164 },
165 {
166 name: "storage unavailable",
167 setup: func(*testing.T) *core.Session { return nil },
168 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
169 message: "goal state unavailable: session persistence is disabled",
170 },
171 {
172 name: "write failure",
173 setup: func(t *testing.T) *core.Session {
174 sess := newSession(t)
175 if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive, Reason: privateReason}); err != nil {
176 t.Fatal(err)
177 }
178 writeFailureGoal = *sess.Meta.Goal
179 if err := sess.Close(); err != nil {
180 t.Fatal(err)
181 }
182 return sess
183 },
184 update: tools.GoalUpdate{Status: core.GoalDone},
185 message: "goal state could not be saved",
186 assertUnchanged: func(t *testing.T, sess *core.Session) {
187 t.Helper()
188 assertGoal := func(source string, goal *core.SessionGoal) {
189 t.Helper()
190 if goal == nil || goal.Status != writeFailureGoal.Status || goal.Objective != writeFailureGoal.Objective || goal.Reason != writeFailureGoal.Reason {
191 t.Fatalf("%s goal after failed update = %#v, want status=%q objective=%q reason=%q", source, goal, writeFailureGoal.Status, writeFailureGoal.Objective, writeFailureGoal.Reason)
192 }
193 }
194 assertGoal("in-memory", sess.Meta.Goal)
195 snapshot, err := core.ReadSessionSnapshot(sess.Path)
196 if err != nil {
197 t.Fatal(err)
198 }
199 assertGoal("persisted", snapshot.Meta.Goal)
200 },
201 },
202 }
203 for _, test := range tests {
204 t.Run(test.name, func(t *testing.T) {
205 sess := test.setup(t)
206 err := persistToolResultState(nil, sess, core.ToolResult{Details: test.update})
207 var safe *core.ToolResultCommitError
208 if !errors.As(err, &safe) {
209 t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err)
210 }
211 if safe.Message != test.message {
212 t.Fatalf("safe message = %q, want %q", safe.Message, test.message)
213 }
214 if strings.Contains(safe.Message, privateGoal) || strings.Contains(safe.Message, privateMission) || strings.Contains(safe.Message, privateSessionContent) || (sess != nil && strings.Contains(safe.Message, sess.Path)) {
215 t.Fatalf("safe message leaked private state: %q", safe.Message)
216 }
217 if test.assertUnchanged != nil {
218 test.assertUnchanged(t, sess)
219 }
220 })
221 }
222}
223
224func TestPersistGoalToolResultStartsManagerMission(t *testing.T) {
225 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
226 if err != nil {
227 t.Fatal(err)
228 }
229 defer sess.Close()
230
231 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
232 if err := persistGoalToolResult(sess, result); err != nil {
233 t.Fatal(err)
234 }
235 goal := sess.Meta.Goal
236 if goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
237 t.Fatalf("persisted goal = %#v", goal)
238 }
239 mission := sess.Meta.Mission
240 if mission == nil || mission.Source != core.MissionSourceManager || mission.Objective != goal.Objective || mission.ID != goal.MissionID || mission.ActiveGoalID != goal.ID {
241 t.Fatalf("persisted mission = %#v, goal = %#v", mission, goal)
242 }
243}
244
245func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {
246 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
247 if err != nil {
248 t.Fatal(err)
249 }
250 defer sess.Close()
251 if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
252 t.Fatal(err)
253 }
254
255 budget := uint64(1_000_000)
256 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
257 if err := persistGoalToolResult(sess, result, &budget); err != nil {
258 t.Fatal(err)
259 }
260 if goal := sess.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget {
261 t.Fatalf("persisted goal budget = %#v", goal)
262 }
263}
264
265func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T) {
266 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
267 if err != nil {
268 t.Fatal(err)
269 }
270 defer sess.Close()
271 if err := sess.EnsureMission("fix the reported failure", core.MissionSourceUser); err != nil {
272 t.Fatal(err)
273 }
274 missionID := sess.Meta.Mission.ID
275
276 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "inspect the failure"}}
277 if err := persistGoalToolResult(sess, result); err != nil {
278 t.Fatal(err)
279 }
280 goal := sess.Meta.Goal
281 if goal == nil || goal.Status != core.GoalActive || goal.MissionID != missionID {
282 t.Fatalf("goal = %#v, want active goal in mission %q", goal, missionID)
283 }
284}
285
286func TestPersistGoalToolResultPausesAndResumesCurrentGoal(t *testing.T) {
287 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
288 if err != nil {
289 t.Fatal(err)
290 }
291 defer sess.Close()
292 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
293 t.Fatal(err)
294 }
295 goalID := sess.Meta.Goal.ID
296 missionID := sess.Meta.Mission.ID
297
298 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused}}); err != nil {
299 t.Fatal(err)
300 }
301 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalPaused || got.ID != goalID || got.Objective != "current work" {
302 t.Fatalf("paused goal = %#v", got)
303 }
304
305 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, MissionID: missionID}}); err != nil {
306 t.Fatal(err)
307 }
308 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive || got.ID != goalID || got.Objective != "current work" {
309 t.Fatalf("resumed goal = %#v", got)
310 }
311}
312
313func TestPersistGoalToolResultRejectsPauseForDifferentMission(t *testing.T) {
314 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
315 if err != nil {
316 t.Fatal(err)
317 }
318 defer sess.Close()
319 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
320 t.Fatal(err)
321 }
322
323 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused, MissionID: "other-mission"}})
324 if !errors.Is(err, errGoalMissionMismatch) {
325 t.Fatalf("error = %v, want %v", err, errGoalMissionMismatch)
326 }
327 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
328 t.Fatalf("goal changed after rejected pause: %#v", got)
329 }
330}
331
332func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
333 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
334 if err != nil {
335 t.Fatal(err)
336 }
337 defer sess.Close()
338 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
339 t.Fatal(err)
340 }
341
342 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}})
343 if err == nil {
344 t.Fatal("active goal replacement succeeded")
345 }
346 if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive {
347 t.Fatalf("persisted goal = %#v", got)
348 }
349}
350
351func TestPersistGoalToolResultSupersedesActiveGoalInMission(t *testing.T) {
352 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
353 if err != nil {
354 t.Fatal(err)
355 }
356 defer sess.Close()
357 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "try the legacy API", Status: core.GoalActive}); err != nil {
358 t.Fatal(err)
359 }
360 missionID := sess.Meta.Mission.ID
361 originalID := sess.Meta.Goal.ID
362
363 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "use the supported API", GoalID: originalID, MissionID: missionID}}
364 if err := persistGoalToolResult(sess, result); err != nil {
365 t.Fatal(err)
366 }
367 if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != core.GoalActive || got.MissionID != missionID || got.Owner != core.GoalOwnerManager {
368 t.Fatalf("replacement goal = %#v", got)
369 }
370 if len(sess.Meta.GoalHistory) != 1 || sess.Meta.GoalHistory[0].ID != originalID || sess.Meta.GoalHistory[0].Status != core.GoalSuperseded {
371 t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
372 }
373}
374
375func TestPersistGoalToolResultRejectsStaleSupersedingGoal(t *testing.T) {
376 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
377 if err != nil {
378 t.Fatal(err)
379 }
380 defer sess.Close()
381 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "new user goal", Status: core.GoalActive}); err != nil {
382 t.Fatal(err)
383 }
384 before := *sess.Meta.Goal
385
386 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "stale replacement", GoalID: "older-goal", MissionID: sess.Meta.Mission.ID}}
387 if err := persistGoalToolResult(sess, result); !errors.Is(err, errGoalStaleUpdate) {
388 t.Fatalf("persist stale supersede error = %v", err)
389 }
390 if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
391 t.Fatalf("stale supersede changed state: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
392 }
393}
394
395func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
396 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
397 if err != nil {
398 t.Fatal(err)
399 }
400 defer sess.Close()
401 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil {
402 t.Fatal(err)
403 }
404 missionID := sess.Meta.Mission.ID
405 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil {
406 t.Fatal(err)
407 }
408 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil {
409 t.Fatal(err)
410 }
411 goal := sess.Meta.Goal
412 if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 {
413 t.Fatalf("next goal = %#v", goal)
414 }
415}
416
417func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) {
418 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
419 if err != nil {
420 t.Fatal(err)
421 }
422 defer sess.Close()
423 if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil {
424 t.Fatal(err)
425 }
426
427 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}})
428 if err == nil {
429 t.Fatal("mismatched mission was accepted")
430 }
431}
432
433func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
434 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
435 if err != nil {
436 t.Fatal(err)
437 }
438 defer sess.Close()
439 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
440 t.Fatal(err)
441 }
442
443 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked}}
444 if err := persistGoalToolResult(sess, result); err != nil {
445 t.Fatal(err)
446 }
447 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
448 t.Fatalf("persisted goal = %#v, want active", got)
449 }
450}
451
452func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
453 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
454 if err != nil {
455 t.Fatal(err)
456 }
457 defer sess.Close()
458 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
459 t.Fatal(err)
460 }
461
462 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
463 if err := persistGoalToolResult(sess, result); err != nil {
464 t.Fatal(err)
465 }
466 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
467 t.Fatalf("persisted goal = %#v", got)
468 }
469}
470
471type failingStdinReader struct{}
472
473func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
474
475func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) {
476 if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") {
477 t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err)
478 }
479}
480
481func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
482 root := t.TempDir()
483 cwd := t.TempDir()
484 otherCWD := t.TempDir()
485 t.Setenv("ZUT_HOME", root)
486
487 session, err := core.NewSession(root, cwd, "provider", "model", "test")
488 if err != nil {
489 t.Fatal(err)
490 }
491 if err := session.AppendMessage(provider.Message{
492 Role: provider.RoleUser,
493 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
494 }); err != nil {
495 t.Fatal(err)
496 }
497 id := session.ID
498 if err := session.Close(); err != nil {
499 t.Fatal(err)
500 }
501
502 ag := core.NewAgent(nil, "model", "", nil)
503 got, err := openOrCreateSession(context.Background(), Args{
504 CWD: otherCWD,
505 Resume: true,
506 ResumeSessionID: id,
507 }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
508 if err != nil {
509 t.Fatal(err)
510 }
511 if got == nil || got.ID != id || got.Meta.CWD != cwd {
512 t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
513 }
514 if got := firstMessageText(ag.Messages()); got != "resume me" {
515 t.Fatalf("resumed message = %q, want resume me", got)
516 }
517 if err := got.Close(); err != nil {
518 t.Fatal(err)
519 }
520
521 namedRoot := filepath.Join(root, "sessions", "agents", "demo")
522 named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
523 if err != nil {
524 t.Fatal(err)
525 }
526 if err := named.AppendMessage(provider.Message{
527 Role: provider.RoleUser,
528 Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
529 }); err != nil {
530 t.Fatal(err)
531 }
532 namedID := named.ID
533 if err := named.Close(); err != nil {
534 t.Fatal(err)
535 }
536
537 namedAgent := core.NewAgent(nil, "model", "", nil)
538 namedGot, err := openOrCreateSession(context.Background(), Args{
539 CWD: otherCWD,
540 Resume: true,
541 ResumeSessionID: namedID,
542 }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
543 if err != nil {
544 t.Fatal(err)
545 }
546 if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
547 t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
548 }
549 if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
550 t.Fatalf("named resumed message = %q, want named resume me", got)
551 }
552 if err := namedGot.Close(); err != nil {
553 t.Fatal(err)
554 }
555}
556
557func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
558 root := t.TempDir()
559 cwd := t.TempDir()
560
561 empty, err := core.NewSession(root, cwd, "provider", "model", "test")
562 if err != nil {
563 t.Fatal(err)
564 }
565 if err := empty.Close(); err != nil {
566 t.Fatal(err)
567 }
568 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
569 t.Fatalf("empty session stat error = %v, want deleted", err)
570 }
571 if got := resumableSessionID(root, empty, false); got != "" {
572 t.Fatalf("empty session hint ID = %q, want empty", got)
573 }
574
575 arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
576 arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
577 if err != nil {
578 t.Fatal(err)
579 }
580 if err := arbitrary.AppendMessage(provider.Message{
581 Role: provider.RoleUser,
582 Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
583 }); err != nil {
584 t.Fatal(err)
585 }
586 if err := arbitrary.Close(); err != nil {
587 t.Fatal(err)
588 }
589 if got := resumableSessionID(root, arbitrary, true); got != "" {
590 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
591 }
592}
593
594func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
595 root := t.TempDir()
596 cwd := t.TempDir()
597 otherCWD := t.TempDir()
598 t.Setenv("ZUT_HOME", root)
599
600 dir := core.SessionsDir(root, cwd)
601 if err := os.MkdirAll(dir, 0o755); err != nil {
602 t.Fatal(err)
603 }
604 id := uuid.NewString()
605 metaLine := struct {
606 Type string `json:"type"`
607 Meta core.SessionMeta `json:"meta"`
608 }{
609 Type: "meta",
610 Meta: core.SessionMeta{
611 ID: id,
612 CWD: cwd,
613 Provider: "provider",
614 Model: "model",
615 Started: time.Now().UTC(),
616 Version: "test",
617 },
618 }
619 data, err := json.Marshal(metaLine)
620 if err != nil {
621 t.Fatal(err)
622 }
623 path := filepath.Join(dir, "persisted-empty.jsonl")
624 if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
625 t.Fatal(err)
626 }
627
628 sess, err := openOrCreateSession(context.Background(), Args{
629 CWD: otherCWD,
630 Resume: true,
631 ResumeSessionID: id,
632 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
633 if err != nil {
634 t.Fatal(err)
635 }
636 if sess == nil || sess.Path != path {
637 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
638 }
639 if got := resumableSessionID(root, sess, false); got != "" {
640 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
641 }
642 if err := sess.Close(); err != nil {
643 t.Fatal(err)
644 }
645}
646
647func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
648 root := t.TempDir()
649 cwd := t.TempDir()
650 t.Setenv("ZUT_HOME", root)
651 id := uuid.NewString()
652
653 _, err := openOrCreateSession(context.Background(), Args{
654 CWD: cwd,
655 Resume: true,
656 ResumeSessionID: id,
657 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
658 if err == nil {
659 t.Fatal("unknown UUID resume unexpectedly succeeded")
660 }
661 if !strings.Contains(err.Error(), id) {
662 t.Fatalf("unknown UUID error = %v, want session ID", err)
663 }
664 if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
665 t.Fatalf("unknown UUID created sessions: %v", sessions)
666 }
667}
668
669func TestOpenOrCreateSessionUUIDLookupIgnoresUnrelatedCorruption(t *testing.T) {
670 root := t.TempDir()
671 cwd := t.TempDir()
672 t.Setenv("ZUT_HOME", root)
673 target, err := core.NewSession(root, cwd, "provider", "model", "test")
674 if err != nil {
675 t.Fatal(err)
676 }
677 if err := target.AppendMessage(provider.Message{
678 Role: provider.RoleUser,
679 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
680 }); err != nil {
681 t.Fatal(err)
682 }
683 if err := target.Close(); err != nil {
684 t.Fatal(err)
685 }
686 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
687 t.Fatal(err)
688 }
689
690 session, err := openOrCreateSession(context.Background(), Args{
691 CWD: cwd,
692 Resume: true,
693 ResumeSessionID: target.ID,
694 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
695 if err != nil {
696 t.Fatalf("openOrCreateSession: %v", err)
697 }
698 defer session.Close()
699 if session.Path != target.Path {
700 t.Fatalf("resumed session path = %q, want %q", session.Path, target.Path)
701 }
702}
703
704func TestResumeSessionHint(t *testing.T) {
705 id := uuid.NewString()
706 want := "Session ID: " + id + "\nResume with: zut --resume " + id + "\n\n"
707 if got := resumeSessionHint(id); got != want {
708 t.Fatalf("resume hint = %q, want %q", got, want)
709 }
710}
711
712func TestResumableSessionIDAcceptsManagedPersistedSession(t *testing.T) {
713 root := t.TempDir()
714 cwd := t.TempDir()
715 sess, err := core.NewSession(root, cwd, "provider", "model", "test")
716 if err != nil {
717 t.Fatal(err)
718 }
719 if err := sess.AppendMessage(provider.Message{
720 Role: provider.RoleUser,
721 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
722 }); err != nil {
723 t.Fatal(err)
724 }
725 if err := sess.Close(); err != nil {
726 t.Fatal(err)
727 }
728 if got := resumableSessionID(root, sess, true); got != sess.ID {
729 t.Fatalf("managed session hint ID = %q, want %q", got, sess.ID)
730 }
731}
732
733func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
734 msgs := make([]provider.Message, 0, 101)
735 msgs = append(msgs, provider.Message{
736 Role: provider.RoleTool,
737 AddedToolNames: []string{"lookup_weather"},
738 Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
739 })
740 for idx := 1; idx < 101; idx++ {
741 msgs = append(msgs, provider.Message{
742 Role: provider.RoleUser,
743 Content: []provider.Content{provider.TextBlock{Text: "message"}},
744 })
745 }
746 trimmed := trimMessagesForResume(msgs, 100)
747 if len(trimmed) != 100 {
748 t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
749 }
750 if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
751 t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
752 }
753}
754
755func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
756 for _, total := range []int{100, 101, 102} {
757 t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
758 msgs := make([]provider.Message, 0, total)
759 msgs = append(msgs, provider.Message{
760 Role: provider.RoleUser,
761 Meta: map[string]string{"compaction": "true"},
762 Content: []provider.Content{
763 provider.TextBlock{Text: "## Context Summary (compacted)"},
764 },
765 })
766 for idx := 1; idx < total; idx++ {
767 msgs = append(msgs, provider.Message{
768 Role: provider.RoleUser,
769 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
770 })
771 }
772
773 trimmed := trimMessagesForResume(msgs, 100)
774 if len(trimmed) > 100 {
775 t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
776 }
777 if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
778 t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
779 }
780 })
781 }
782}
783
784func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
785 startup := core.NewAgent(nil, "startup-model", "", nil)
786 startup.SetMessages([]provider.Message{{
787 Role: provider.RoleUser,
788 Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
789 }})
790
791 replacement := core.NewAgent(nil, "replacement-model", "", nil)
792 iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
793
794 resumed := []provider.Message{{
795 Role: provider.RoleUser,
796 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
797 }}
798 liveInteractiveAgent(iv, startup).SetMessages(resumed)
799
800 if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
801 t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
802 }
803 if got := firstMessageText(startup.Messages()); got != "startup transcript" {
804 t.Fatalf("startup agent transcript changed to %q", got)
805 }
806}
807
808func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
809 startup := core.NewAgent(nil, "startup-model", "", nil)
810 if got := liveInteractiveAgent(nil, startup); got != startup {
811 t.Fatalf("fallback agent = %p, want %p", got, startup)
812 }
813}
814
815func TestPersistModelCallbackDoesNotReenterSessionTransition(t *testing.T) {
816 t.Setenv("ZUT_HOME", t.TempDir())
817 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "old-provider", "old-model", "test")
818 if err != nil {
819 t.Fatal(err)
820 }
821 defer sess.Close()
822
823 var persistMu sync.Mutex
824 activeProvider, activeModel := "old-provider", "old-model"
825 persistModel := newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, nil)
826
827 var transitionMu sync.RWMutex
828 sessionTransition := newSessionTransition(&transitionMu)
829 done := make(chan struct{})
830 go func() {
831 sessionTransition(func() {
832 persistModel("new-provider", "new-model")
833 })
834 close(done)
835 }()
836 select {
837 case <-done:
838 case <-time.After(time.Second):
839 t.Fatal("model persistence deadlocked inside the session transition")
840 }
841 if sess.Meta.Provider != "new-provider" || sess.Meta.Model != "new-model" {
842 t.Fatalf("session model = %q/%q, want new-provider/new-model", sess.Meta.Provider, sess.Meta.Model)
843 }
844 if activeProvider != "new-provider" || activeModel != "new-model" {
845 t.Fatalf("active model = %q/%q, want new-provider/new-model", activeProvider, activeModel)
846 }
847}
848
849func firstMessageText(messages []provider.Message) string {
850 if len(messages) == 0 || len(messages[0].Content) == 0 {
851 return ""
852 }
853 text, _ := messages[0].Content[0].(provider.TextBlock)
854 return text.Text
855}
856
857func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
858 t.Helper()
859 root := t.TempDir()
860 cwd := t.TempDir()
861 sess, err := core.NewSession(root, cwd, providerName, model, "test")
862 if err != nil {
863 t.Fatal(err)
864 }
865 if err := sess.AppendMessage(provider.Message{
866 Role: provider.RoleUser,
867 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
868 }); err != nil {
869 t.Fatal(err)
870 }
871 if usage != (provider.Usage{}) {
872 if err := sess.AppendUsage(usage, usage); err != nil {
873 t.Fatal(err)
874 }
875 }
876 if err := sess.Close(); err != nil {
877 t.Fatal(err)
878 }
879 return sess.Path
880}
881
882func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
883 root := t.TempDir()
884 sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
885 if err != nil {
886 t.Fatal(err)
887 }
888 ag := core.NewAgent(nil, "model", "", nil)
889 gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
890 if err != nil {
891 t.Fatal(err)
892 }
893 if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
894 t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
895 }
896 if _, err := os.Stat(sess.Path); err != nil {
897 t.Fatalf("fresh session disappeared before close: %v", err)
898 }
899 if err := sess.Close(); err != nil {
900 t.Fatal(err)
901 }
902 if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
903 t.Fatalf("fresh empty session remained after close: %v", err)
904 }
905}
906
907func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) {
908 t.Setenv("ZUT_HOME", t.TempDir())
909 if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil {
910 t.Fatal(err)
911 }
912 path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{})
913 args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()}
914 base, err := Resolve(args, true)
915 if err != nil {
916 t.Fatal(err)
917 }
918 current := base.NewAgent()
919 runtime := newSubagentRuntime(subagentRuntimeConfig{
920 Args: args,
921 Root: t.TempDir(),
922 RepoRoot: args.CWD,
923 Provider: base.Provider,
924 Model: base.Model,
925 })
926 defer func() { _ = runtime.Close(context.Background()) }()
927
928 sess, _, err := core.OpenSession(path)
929 if err != nil {
930 t.Fatal(err)
931 }
932 gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime)
933 if err != nil {
934 t.Fatal(err)
935 }
936 defer gotSess.Close()
937 if gotAgent == current || providerName != "ollama" || model != "stored-local-model" {
938 t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model)
939 }
940 if got := runtime.currentModel(); got != "stored-local-model" {
941 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
942 }
943 if _, ok := gotAgent.ToolsSnapshot()["subagent"]; !ok {
944 t.Fatal("resumed parent lost the canonical subagent manager tool")
945 }
946}
947
948func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
949 old := core.NewAgent(nil, "old-model", "", nil)
950 old.SetMessages([]provider.Message{{
951 Role: provider.RoleUser,
952 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
953 }})
954 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
955 InputTokens: 12,
956 OutputTokens: 7,
957 })
958
959 var gotProvider, gotModel string
960 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
961 gotProvider, gotModel = providerName, model
962 return core.NewAgent(nil, model, "", nil), providerName, model, nil
963 })
964 if err != nil {
965 t.Fatal(err)
966 }
967 defer candidate.session.Close()
968
969 if gotProvider != "stored-provider" || gotModel != "stored-model" {
970 t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
971 }
972 if !candidate.rebuilt || candidate.agent == old {
973 t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
974 }
975 if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
976 t.Fatalf("candidate transcript = %q, want resumed transcript", got)
977 }
978 if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
979 t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
980 }
981 if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
982 t.Fatalf("current transcript changed during preparation: %+v", got)
983 }
984}
985
986func TestCanceledPreparedSessionPreservesLiveAgentClient(t *testing.T) {
987 t.Setenv("ZUT_HOME", t.TempDir())
988 old := (Resolved{Provider: "openai-codex", Model: "old-model", AuthMethod: "oauth",
989 Credential: "synthetic-token", AccountID: "old-account"}).newInteractiveAgent()
990 old.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}}})
991 iv := modes.NewInteractive(modes.InteractiveConfig{Agent: old, Provider: "openai-codex", Model: "old-model"})
992 oldClient := old.Client
993 path := syntheticSession(t, "openai-codex", "new-model", provider.Usage{})
994 loadCtx, cancel := context.WithCancel(context.Background())
995 defer cancel()
996 candidate, err := prepareSessionResume(path, old, "openai-codex", "old-model", func(p, m string) (*core.Agent, string, string, error) {
997 resolved := Resolved{Provider: p, Model: m, AuthMethod: "oauth", Credential: "synthetic-token", AccountID: "new-account"}
998 return resolved.newInteractiveAgent(), p, m, nil
999 })
1000 if err != nil {
1001 t.Fatal(err)
1002 }
1003 defer candidate.session.Close()
1004 if !candidate.rebuilt || candidate.agent.Client == oldClient {
1005 t.Fatal("expected a private rebuilt candidate with its own usage client")
1006 }
1007 // Match the loader's cancellation check after preparation and before its
1008 // ApplySessionAgentWithCompactHandoff commit boundary.
1009 cancel()
1010 if loadCtx.Err() == nil {
1011 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
1012 }
1013 if iv.Agent() != old || iv.Agent().Client != oldClient || firstMessageText(old.Messages()) != "current transcript" {
1014 t.Fatal("canceled preparation changed the live agent or its usage client")
1015 }
1016 // A later successful load publishes the candidate client with the agent.
1017 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
1018 if iv.Agent() != candidate.agent || iv.Agent().Client == oldClient {
1019 t.Fatal("successful commit did not install candidate client")
1020 }
1021}
1022
1023func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
1024 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
1025 sess, _, err := core.OpenSession(path)
1026 if err != nil {
1027 t.Fatal(err)
1028 }
1029 defer sess.Close()
1030
1031 current := core.NewAgent(nil, "current-model", "", nil)
1032 candidate, err := applySessionResume(sess, current, "current-provider", "current-model", false, false, func(providerName, model string) (*core.Agent, string, string, error) {
1033 return core.NewAgent(nil, model, "", nil), providerName, model, nil
1034 })
1035 if err != nil {
1036 t.Fatal(err)
1037 }
1038 defer candidate.session.Close()
1039 if !candidate.rebuilt {
1040 t.Fatal("resume did not rebuild the agent")
1041 }
1042 if got := candidate.agent.SessionID(); got != candidate.session.Meta.ID {
1043 t.Fatalf("rebuilt agent session ID = %q, want durable session ID %q", got, candidate.session.Meta.ID)
1044 }
1045}
1046
1047func TestPrepareSessionResumeForWorkspaceRebuildsMatchingProviderAndModel(t *testing.T) {
1048 path := syntheticSession(t, "provider", "model", provider.Usage{})
1049 current := core.NewAgent(nil, "model", "", nil)
1050 builds := 0
1051 candidate, err := prepareSessionResumeForWorkspace(path, current, "provider", "model", func(providerName, model string) (*core.Agent, string, string, error) {
1052 builds++
1053 return core.NewAgent(nil, model, "", nil), providerName, model, nil
1054 })
1055 if err != nil {
1056 t.Fatal(err)
1057 }
1058 defer candidate.session.Close()
1059 if builds != 1 || !candidate.rebuilt || candidate.agent == current {
1060 t.Fatalf("workspace resume = builds %d rebuilt %v agent=%p current=%p", builds, candidate.rebuilt, candidate.agent, current)
1061 }
1062}
1063
1064func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
1065 old := core.NewAgent(nil, "old-model", "", nil)
1066 old.SetMessages([]provider.Message{{
1067 Role: provider.RoleUser,
1068 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
1069 }})
1070 path := syntheticSession(t, "", "", provider.Usage{})
1071
1072 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
1073 t.Fatal("legacy session unexpectedly requested a rebuild")
1074 return nil, "", "", nil
1075 })
1076 if err != nil {
1077 t.Fatal(err)
1078 }
1079 defer candidate.session.Close()
1080
1081 if candidate.rebuilt || candidate.agent != old {
1082 t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
1083 }
1084 if got := firstMessageText(old.Messages()); got != "current transcript" {
1085 t.Fatalf("current transcript changed before commit: %q", got)
1086 }
1087 if got := firstMessageText(candidate.messages); got != "resumed transcript" {
1088 t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
1089 }
1090}
1091
1092func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
1093 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
1094 session, _, err := core.OpenSession(path)
1095 if err != nil {
1096 t.Fatal(err)
1097 }
1098 handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
1099 if err := session.UpdateCompactHandoff(handoff); err != nil {
1100 t.Fatal(err)
1101 }
1102 if err := session.Close(); err != nil {
1103 t.Fatal(err)
1104 }
1105
1106 current := core.NewAgent(nil, "current-model", "", nil)
1107 candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
1108 if err != nil {
1109 t.Fatal(err)
1110 }
1111 defer candidate.session.Close()
1112 if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
1113 t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
1114 }
1115}
1116
1117func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
1118 old := core.NewAgent(nil, "old-model", "", nil)
1119 old.SetMessages([]provider.Message{{
1120 Role: provider.RoleUser,
1121 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
1122 }})
1123 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
1124 wantErr := errors.New("synthetic builder failure")
1125
1126 if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
1127 return nil, "", "", wantErr
1128 }); !errors.Is(err, wantErr) {
1129 t.Fatalf("prepare error = %v, want %v", err, wantErr)
1130 }
1131 if got := firstMessageText(old.Messages()); got != "current transcript" {
1132 t.Fatalf("current transcript changed after failed preparation: %q", got)
1133 }
1134
1135 // The failed candidate must have released its append handle, leaving
1136 // the selected file readable and reopenable for a later retry.
1137 reopened, _, err := core.OpenSession(path)
1138 if err != nil {
1139 t.Fatalf("reopen candidate after failed preparation: %v", err)
1140 }
1141 if err := reopened.Close(); err != nil {
1142 t.Fatal(err)
1143 }
1144 if _, err := os.Stat(path); err != nil {
1145 t.Fatalf("candidate session disappeared after failed preparation: %v", err)
1146 }
1147}
1148
1149func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
1150 for _, tc := range []struct {
1151 name string
1152 explicitProvider bool
1153 explicitModel bool
1154 wantProvider string
1155 wantModel string
1156 wantBuild bool
1157 }{
1158 {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
1159 {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
1160 {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
1161 } {
1162 t.Run(tc.name, func(t *testing.T) {
1163 old := core.NewAgent(nil, "old-model", "", nil)
1164 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
1165 var gotProvider, gotModel string
1166 candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, false, func(providerName, model string) (*core.Agent, string, string, error) {
1167 gotProvider, gotModel = providerName, model
1168 return core.NewAgent(nil, model, "", nil), providerName, model, nil
1169 })
1170 if err != nil {
1171 t.Fatal(err)
1172 }
1173 defer candidate.session.Close()
1174 if tc.wantBuild {
1175 if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
1176 t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
1177 }
1178 } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
1179 t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
1180 }
1181 })
1182 }
1183}
1184
1185func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
1186 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
1187 if err != nil {
1188 t.Fatal(err)
1189 }
1190 first := provider.Message{
1191 Role: provider.RoleUser,
1192 Content: []provider.Content{provider.TextBlock{Text: "first"}},
1193 }
1194 invalid := provider.Message{
1195 Role: provider.RoleAssistant,
1196 Content: []provider.Content{provider.ToolCallBlock{
1197 ID: "call-1",
1198 Name: "tool",
1199 Arguments: json.RawMessage("{"),
1200 }},
1201 }
1202 ag := core.NewAgent(nil, "model", "", nil)
1203 ag.SetMessages([]provider.Message{first, invalid})
1204 next, err := writeNewTranscriptLocked(ag, sess, 0)
1205 if err == nil {
1206 t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
1207 }
1208 if next != 1 {
1209 t.Fatalf("next persisted message index = %d, want 1", next)
1210 }
1211
1212 second := provider.Message{
1213 Role: provider.RoleAssistant,
1214 Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
1215 }
1216 third := provider.Message{
1217 Role: provider.RoleTool,
1218 Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
1219 }
1220 ag.SetMessages([]provider.Message{first, second, third})
1221 next, err = writeNewTranscriptLocked(ag, sess, next)
1222 if err != nil {
1223 t.Fatalf("retry transcript: %v", err)
1224 }
1225 if next != 3 {
1226 t.Fatalf("next persisted message index after retry = %d, want 3", next)
1227 }
1228 if err := sess.Close(); err != nil {
1229 t.Fatal(err)
1230 }
1231
1232 reopened, messages, err := core.OpenSession(sess.Path)
1233 if err != nil {
1234 t.Fatalf("reopen session: %v", err)
1235 }
1236 t.Cleanup(func() { _ = reopened.Close() })
1237 if len(messages) != 3 {
1238 t.Fatalf("replayed message count = %d, want 3", len(messages))
1239 }
1240 if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
1241 t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
1242 }
1243}