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 TestPersistGoalToolResultRejectsActiveGoalReplacement(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.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
272 t.Fatal(err)
273 }
274
275 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}})
276 if err == nil {
277 t.Fatal("active goal replacement succeeded")
278 }
279 if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive {
280 t.Fatalf("persisted goal = %#v", got)
281 }
282}
283
284func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
285 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
286 if err != nil {
287 t.Fatal(err)
288 }
289 defer sess.Close()
290 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil {
291 t.Fatal(err)
292 }
293 missionID := sess.Meta.Mission.ID
294 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil {
295 t.Fatal(err)
296 }
297 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil {
298 t.Fatal(err)
299 }
300 goal := sess.Meta.Goal
301 if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 {
302 t.Fatalf("next goal = %#v", goal)
303 }
304}
305
306func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) {
307 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
308 if err != nil {
309 t.Fatal(err)
310 }
311 defer sess.Close()
312 if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil {
313 t.Fatal(err)
314 }
315
316 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}})
317 if err == nil {
318 t.Fatal("mismatched mission was accepted")
319 }
320}
321
322func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
323 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
324 if err != nil {
325 t.Fatal(err)
326 }
327 defer sess.Close()
328 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
329 t.Fatal(err)
330 }
331
332 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked}}
333 if err := persistGoalToolResult(sess, result); err != nil {
334 t.Fatal(err)
335 }
336 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
337 t.Fatalf("persisted goal = %#v, want active", got)
338 }
339}
340
341func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
342 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
343 if err != nil {
344 t.Fatal(err)
345 }
346 defer sess.Close()
347 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
348 t.Fatal(err)
349 }
350
351 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
352 if err := persistGoalToolResult(sess, result); err != nil {
353 t.Fatal(err)
354 }
355 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
356 t.Fatalf("persisted goal = %#v", got)
357 }
358}
359
360type failingStdinReader struct{}
361
362func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
363
364func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) {
365 if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") {
366 t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err)
367 }
368}
369
370func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
371 root := t.TempDir()
372 cwd := t.TempDir()
373 otherCWD := t.TempDir()
374 t.Setenv("ZUT_HOME", root)
375
376 session, err := core.NewSession(root, cwd, "provider", "model", "test")
377 if err != nil {
378 t.Fatal(err)
379 }
380 if err := session.AppendMessage(provider.Message{
381 Role: provider.RoleUser,
382 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
383 }); err != nil {
384 t.Fatal(err)
385 }
386 id := session.ID
387 if err := session.Close(); err != nil {
388 t.Fatal(err)
389 }
390
391 ag := core.NewAgent(nil, "model", "", nil)
392 got, err := openOrCreateSession(context.Background(), Args{
393 CWD: otherCWD,
394 Resume: true,
395 ResumeSessionID: id,
396 }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
397 if err != nil {
398 t.Fatal(err)
399 }
400 if got == nil || got.ID != id || got.Meta.CWD != cwd {
401 t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
402 }
403 if got := firstMessageText(ag.Messages()); got != "resume me" {
404 t.Fatalf("resumed message = %q, want resume me", got)
405 }
406 if err := got.Close(); err != nil {
407 t.Fatal(err)
408 }
409
410 namedRoot := filepath.Join(root, "sessions", "agents", "demo")
411 named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
412 if err != nil {
413 t.Fatal(err)
414 }
415 if err := named.AppendMessage(provider.Message{
416 Role: provider.RoleUser,
417 Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
418 }); err != nil {
419 t.Fatal(err)
420 }
421 namedID := named.ID
422 if err := named.Close(); err != nil {
423 t.Fatal(err)
424 }
425
426 namedAgent := core.NewAgent(nil, "model", "", nil)
427 namedGot, err := openOrCreateSession(context.Background(), Args{
428 CWD: otherCWD,
429 Resume: true,
430 ResumeSessionID: namedID,
431 }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
432 if err != nil {
433 t.Fatal(err)
434 }
435 if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
436 t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
437 }
438 if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
439 t.Fatalf("named resumed message = %q, want named resume me", got)
440 }
441 if err := namedGot.Close(); err != nil {
442 t.Fatal(err)
443 }
444}
445
446func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
447 root := t.TempDir()
448 cwd := t.TempDir()
449
450 empty, err := core.NewSession(root, cwd, "provider", "model", "test")
451 if err != nil {
452 t.Fatal(err)
453 }
454 if err := empty.Close(); err != nil {
455 t.Fatal(err)
456 }
457 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
458 t.Fatalf("empty session stat error = %v, want deleted", err)
459 }
460 if got := resumableSessionID(root, empty, false); got != "" {
461 t.Fatalf("empty session hint ID = %q, want empty", got)
462 }
463
464 arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
465 arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
466 if err != nil {
467 t.Fatal(err)
468 }
469 if err := arbitrary.AppendMessage(provider.Message{
470 Role: provider.RoleUser,
471 Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
472 }); err != nil {
473 t.Fatal(err)
474 }
475 if err := arbitrary.Close(); err != nil {
476 t.Fatal(err)
477 }
478 if got := resumableSessionID(root, arbitrary, true); got != "" {
479 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
480 }
481}
482
483func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
484 root := t.TempDir()
485 cwd := t.TempDir()
486 otherCWD := t.TempDir()
487 t.Setenv("ZUT_HOME", root)
488
489 dir := core.SessionsDir(root, cwd)
490 if err := os.MkdirAll(dir, 0o755); err != nil {
491 t.Fatal(err)
492 }
493 id := uuid.NewString()
494 metaLine := struct {
495 Type string `json:"type"`
496 Meta core.SessionMeta `json:"meta"`
497 }{
498 Type: "meta",
499 Meta: core.SessionMeta{
500 ID: id,
501 CWD: cwd,
502 Provider: "provider",
503 Model: "model",
504 Started: time.Now().UTC(),
505 Version: "test",
506 },
507 }
508 data, err := json.Marshal(metaLine)
509 if err != nil {
510 t.Fatal(err)
511 }
512 path := filepath.Join(dir, "persisted-empty.jsonl")
513 if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
514 t.Fatal(err)
515 }
516
517 sess, err := openOrCreateSession(context.Background(), Args{
518 CWD: otherCWD,
519 Resume: true,
520 ResumeSessionID: id,
521 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
522 if err != nil {
523 t.Fatal(err)
524 }
525 if sess == nil || sess.Path != path {
526 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
527 }
528 if got := resumableSessionID(root, sess, false); got != "" {
529 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
530 }
531 if err := sess.Close(); err != nil {
532 t.Fatal(err)
533 }
534}
535
536func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
537 root := t.TempDir()
538 cwd := t.TempDir()
539 t.Setenv("ZUT_HOME", root)
540 id := uuid.NewString()
541
542 _, err := openOrCreateSession(context.Background(), Args{
543 CWD: cwd,
544 Resume: true,
545 ResumeSessionID: id,
546 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
547 if err == nil {
548 t.Fatal("unknown UUID resume unexpectedly succeeded")
549 }
550 if !strings.Contains(err.Error(), id) {
551 t.Fatalf("unknown UUID error = %v, want session ID", err)
552 }
553 if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
554 t.Fatalf("unknown UUID created sessions: %v", sessions)
555 }
556}
557
558func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
559 root := t.TempDir()
560 cwd := t.TempDir()
561 t.Setenv("ZUT_HOME", root)
562 if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
563 t.Fatal(err)
564 }
565 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
566 t.Fatal(err)
567 }
568
569 _, err := openOrCreateSession(context.Background(), Args{
570 CWD: cwd,
571 Resume: true,
572 ResumeSessionID: uuid.NewString(),
573 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
574 if err == nil {
575 t.Fatal("malformed UUID lookup unexpectedly succeeded")
576 }
577 if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
578 t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
579 }
580}
581
582func TestResumeSessionHint(t *testing.T) {
583 id := uuid.NewString()
584 want := "Session ID: " + id + "\nResume with: zut --resume " + id + "\n\n"
585 if got := resumeSessionHint(id); got != want {
586 t.Fatalf("resume hint = %q, want %q", got, want)
587 }
588}
589
590func TestResumableSessionIDAcceptsManagedPersistedSession(t *testing.T) {
591 root := t.TempDir()
592 cwd := t.TempDir()
593 sess, err := core.NewSession(root, cwd, "provider", "model", "test")
594 if err != nil {
595 t.Fatal(err)
596 }
597 if err := sess.AppendMessage(provider.Message{
598 Role: provider.RoleUser,
599 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
600 }); err != nil {
601 t.Fatal(err)
602 }
603 if err := sess.Close(); err != nil {
604 t.Fatal(err)
605 }
606 if got := resumableSessionID(root, sess, true); got != sess.ID {
607 t.Fatalf("managed session hint ID = %q, want %q", got, sess.ID)
608 }
609}
610
611func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
612 msgs := make([]provider.Message, 0, 101)
613 msgs = append(msgs, provider.Message{
614 Role: provider.RoleTool,
615 AddedToolNames: []string{"lookup_weather"},
616 Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
617 })
618 for idx := 1; idx < 101; idx++ {
619 msgs = append(msgs, provider.Message{
620 Role: provider.RoleUser,
621 Content: []provider.Content{provider.TextBlock{Text: "message"}},
622 })
623 }
624 trimmed := trimMessagesForResume(msgs, 100)
625 if len(trimmed) != 100 {
626 t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
627 }
628 if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
629 t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
630 }
631}
632
633func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
634 for _, total := range []int{100, 101, 102} {
635 t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
636 msgs := make([]provider.Message, 0, total)
637 msgs = append(msgs, provider.Message{
638 Role: provider.RoleUser,
639 Meta: map[string]string{"compaction": "true"},
640 Content: []provider.Content{
641 provider.TextBlock{Text: "## Context Summary (compacted)"},
642 },
643 })
644 for idx := 1; idx < total; idx++ {
645 msgs = append(msgs, provider.Message{
646 Role: provider.RoleUser,
647 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
648 })
649 }
650
651 trimmed := trimMessagesForResume(msgs, 100)
652 if len(trimmed) > 100 {
653 t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
654 }
655 if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
656 t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
657 }
658 })
659 }
660}
661
662func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
663 startup := core.NewAgent(nil, "startup-model", "", nil)
664 startup.SetMessages([]provider.Message{{
665 Role: provider.RoleUser,
666 Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
667 }})
668
669 replacement := core.NewAgent(nil, "replacement-model", "", nil)
670 iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
671
672 resumed := []provider.Message{{
673 Role: provider.RoleUser,
674 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
675 }}
676 liveInteractiveAgent(iv, startup).SetMessages(resumed)
677
678 if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
679 t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
680 }
681 if got := firstMessageText(startup.Messages()); got != "startup transcript" {
682 t.Fatalf("startup agent transcript changed to %q", got)
683 }
684}
685
686func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
687 startup := core.NewAgent(nil, "startup-model", "", nil)
688 if got := liveInteractiveAgent(nil, startup); got != startup {
689 t.Fatalf("fallback agent = %p, want %p", got, startup)
690 }
691}
692
693func TestPersistModelCallbackDoesNotReenterSessionTransition(t *testing.T) {
694 t.Setenv("ZUT_HOME", t.TempDir())
695 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "old-provider", "old-model", "test")
696 if err != nil {
697 t.Fatal(err)
698 }
699 defer sess.Close()
700
701 var persistMu sync.Mutex
702 activeProvider, activeModel := "old-provider", "old-model"
703 persistModel := newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, nil)
704
705 var transitionMu sync.RWMutex
706 sessionTransition := newSessionTransition(&transitionMu)
707 done := make(chan struct{})
708 go func() {
709 sessionTransition(func() {
710 persistModel("new-provider", "new-model")
711 })
712 close(done)
713 }()
714 select {
715 case <-done:
716 case <-time.After(time.Second):
717 t.Fatal("model persistence deadlocked inside the session transition")
718 }
719 if sess.Meta.Provider != "new-provider" || sess.Meta.Model != "new-model" {
720 t.Fatalf("session model = %q/%q, want new-provider/new-model", sess.Meta.Provider, sess.Meta.Model)
721 }
722 if activeProvider != "new-provider" || activeModel != "new-model" {
723 t.Fatalf("active model = %q/%q, want new-provider/new-model", activeProvider, activeModel)
724 }
725}
726
727func firstMessageText(messages []provider.Message) string {
728 if len(messages) == 0 || len(messages[0].Content) == 0 {
729 return ""
730 }
731 text, _ := messages[0].Content[0].(provider.TextBlock)
732 return text.Text
733}
734
735func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
736 t.Helper()
737 root := t.TempDir()
738 cwd := t.TempDir()
739 sess, err := core.NewSession(root, cwd, providerName, model, "test")
740 if err != nil {
741 t.Fatal(err)
742 }
743 if err := sess.AppendMessage(provider.Message{
744 Role: provider.RoleUser,
745 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
746 }); err != nil {
747 t.Fatal(err)
748 }
749 if usage != (provider.Usage{}) {
750 if err := sess.AppendUsage(usage, usage); err != nil {
751 t.Fatal(err)
752 }
753 }
754 if err := sess.Close(); err != nil {
755 t.Fatal(err)
756 }
757 return sess.Path
758}
759
760func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
761 root := t.TempDir()
762 sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
763 if err != nil {
764 t.Fatal(err)
765 }
766 ag := core.NewAgent(nil, "model", "", nil)
767 gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
768 if err != nil {
769 t.Fatal(err)
770 }
771 if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
772 t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
773 }
774 if _, err := os.Stat(sess.Path); err != nil {
775 t.Fatalf("fresh session disappeared before close: %v", err)
776 }
777 if err := sess.Close(); err != nil {
778 t.Fatal(err)
779 }
780 if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
781 t.Fatalf("fresh empty session remained after close: %v", err)
782 }
783}
784
785func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) {
786 t.Setenv("ZUT_HOME", t.TempDir())
787 if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil {
788 t.Fatal(err)
789 }
790 path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{})
791 args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()}
792 base, err := Resolve(args, true)
793 if err != nil {
794 t.Fatal(err)
795 }
796 current := base.NewAgent()
797 runtime := newSubagentRuntime(subagentRuntimeConfig{
798 Args: args,
799 Root: t.TempDir(),
800 RepoRoot: args.CWD,
801 Provider: base.Provider,
802 Model: base.Model,
803 })
804 defer func() { _ = runtime.Close(context.Background()) }()
805
806 sess, _, err := core.OpenSession(path)
807 if err != nil {
808 t.Fatal(err)
809 }
810 gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime)
811 if err != nil {
812 t.Fatal(err)
813 }
814 defer gotSess.Close()
815 if gotAgent == current || providerName != "ollama" || model != "stored-local-model" {
816 t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model)
817 }
818 if got := runtime.currentModel(); got != "stored-local-model" {
819 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
820 }
821 if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {
822 t.Fatal("resumed parent lost canonical subagent_spawn manager tool")
823 }
824}
825
826func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
827 old := core.NewAgent(nil, "old-model", "", nil)
828 old.SetMessages([]provider.Message{{
829 Role: provider.RoleUser,
830 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
831 }})
832 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
833 InputTokens: 12,
834 OutputTokens: 7,
835 })
836
837 var gotProvider, gotModel string
838 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
839 gotProvider, gotModel = providerName, model
840 return core.NewAgent(nil, model, "", nil), providerName, model, nil
841 })
842 if err != nil {
843 t.Fatal(err)
844 }
845 defer candidate.session.Close()
846
847 if gotProvider != "stored-provider" || gotModel != "stored-model" {
848 t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
849 }
850 if !candidate.rebuilt || candidate.agent == old {
851 t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
852 }
853 if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
854 t.Fatalf("candidate transcript = %q, want resumed transcript", got)
855 }
856 if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
857 t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
858 }
859 if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
860 t.Fatalf("current transcript changed during preparation: %+v", got)
861 }
862}
863
864func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
865 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
866 sess, _, err := core.OpenSession(path)
867 if err != nil {
868 t.Fatal(err)
869 }
870 defer sess.Close()
871
872 current := core.NewAgent(nil, "current-model", "", nil)
873 candidate, err := applySessionResume(sess, current, "current-provider", "current-model", false, false, func(providerName, model string) (*core.Agent, string, string, error) {
874 return core.NewAgent(nil, model, "", nil), providerName, model, nil
875 })
876 if err != nil {
877 t.Fatal(err)
878 }
879 defer candidate.session.Close()
880 if !candidate.rebuilt {
881 t.Fatal("resume did not rebuild the agent")
882 }
883 if got := candidate.agent.SessionID(); got != candidate.session.Meta.ID {
884 t.Fatalf("rebuilt agent session ID = %q, want durable session ID %q", got, candidate.session.Meta.ID)
885 }
886}
887
888func TestPrepareSessionResumeForWorkspaceRebuildsMatchingProviderAndModel(t *testing.T) {
889 path := syntheticSession(t, "provider", "model", provider.Usage{})
890 current := core.NewAgent(nil, "model", "", nil)
891 builds := 0
892 candidate, err := prepareSessionResumeForWorkspace(path, current, "provider", "model", func(providerName, model string) (*core.Agent, string, string, error) {
893 builds++
894 return core.NewAgent(nil, model, "", nil), providerName, model, nil
895 })
896 if err != nil {
897 t.Fatal(err)
898 }
899 defer candidate.session.Close()
900 if builds != 1 || !candidate.rebuilt || candidate.agent == current {
901 t.Fatalf("workspace resume = builds %d rebuilt %v agent=%p current=%p", builds, candidate.rebuilt, candidate.agent, current)
902 }
903}
904
905func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
906 old := core.NewAgent(nil, "old-model", "", nil)
907 old.SetMessages([]provider.Message{{
908 Role: provider.RoleUser,
909 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
910 }})
911 path := syntheticSession(t, "", "", provider.Usage{})
912
913 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
914 t.Fatal("legacy session unexpectedly requested a rebuild")
915 return nil, "", "", nil
916 })
917 if err != nil {
918 t.Fatal(err)
919 }
920 defer candidate.session.Close()
921
922 if candidate.rebuilt || candidate.agent != old {
923 t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
924 }
925 if got := firstMessageText(old.Messages()); got != "current transcript" {
926 t.Fatalf("current transcript changed before commit: %q", got)
927 }
928 if got := firstMessageText(candidate.messages); got != "resumed transcript" {
929 t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
930 }
931}
932
933func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
934 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
935 session, _, err := core.OpenSession(path)
936 if err != nil {
937 t.Fatal(err)
938 }
939 handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
940 if err := session.UpdateCompactHandoff(handoff); err != nil {
941 t.Fatal(err)
942 }
943 if err := session.Close(); err != nil {
944 t.Fatal(err)
945 }
946
947 current := core.NewAgent(nil, "current-model", "", nil)
948 candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
949 if err != nil {
950 t.Fatal(err)
951 }
952 defer candidate.session.Close()
953 if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
954 t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
955 }
956}
957
958func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
959 old := core.NewAgent(nil, "old-model", "", nil)
960 old.SetMessages([]provider.Message{{
961 Role: provider.RoleUser,
962 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
963 }})
964 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
965 wantErr := errors.New("synthetic builder failure")
966
967 if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
968 return nil, "", "", wantErr
969 }); !errors.Is(err, wantErr) {
970 t.Fatalf("prepare error = %v, want %v", err, wantErr)
971 }
972 if got := firstMessageText(old.Messages()); got != "current transcript" {
973 t.Fatalf("current transcript changed after failed preparation: %q", got)
974 }
975
976 // The failed candidate must have released its append handle, leaving
977 // the selected file readable and reopenable for a later retry.
978 reopened, _, err := core.OpenSession(path)
979 if err != nil {
980 t.Fatalf("reopen candidate after failed preparation: %v", err)
981 }
982 if err := reopened.Close(); err != nil {
983 t.Fatal(err)
984 }
985 if _, err := os.Stat(path); err != nil {
986 t.Fatalf("candidate session disappeared after failed preparation: %v", err)
987 }
988}
989
990func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
991 for _, tc := range []struct {
992 name string
993 explicitProvider bool
994 explicitModel bool
995 wantProvider string
996 wantModel string
997 wantBuild bool
998 }{
999 {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
1000 {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
1001 {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
1002 } {
1003 t.Run(tc.name, func(t *testing.T) {
1004 old := core.NewAgent(nil, "old-model", "", nil)
1005 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
1006 var gotProvider, gotModel string
1007 candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, false, func(providerName, model string) (*core.Agent, string, string, error) {
1008 gotProvider, gotModel = providerName, model
1009 return core.NewAgent(nil, model, "", nil), providerName, model, nil
1010 })
1011 if err != nil {
1012 t.Fatal(err)
1013 }
1014 defer candidate.session.Close()
1015 if tc.wantBuild {
1016 if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
1017 t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
1018 }
1019 } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
1020 t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
1021 }
1022 })
1023 }
1024}
1025
1026func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
1027 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
1028 if err != nil {
1029 t.Fatal(err)
1030 }
1031 first := provider.Message{
1032 Role: provider.RoleUser,
1033 Content: []provider.Content{provider.TextBlock{Text: "first"}},
1034 }
1035 invalid := provider.Message{
1036 Role: provider.RoleAssistant,
1037 Content: []provider.Content{provider.ToolCallBlock{
1038 ID: "call-1",
1039 Name: "tool",
1040 Arguments: json.RawMessage("{"),
1041 }},
1042 }
1043 ag := core.NewAgent(nil, "model", "", nil)
1044 ag.SetMessages([]provider.Message{first, invalid})
1045 next, err := writeNewTranscriptLocked(ag, sess, 0)
1046 if err == nil {
1047 t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
1048 }
1049 if next != 1 {
1050 t.Fatalf("next persisted message index = %d, want 1", next)
1051 }
1052
1053 second := provider.Message{
1054 Role: provider.RoleAssistant,
1055 Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
1056 }
1057 third := provider.Message{
1058 Role: provider.RoleTool,
1059 Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
1060 }
1061 ag.SetMessages([]provider.Message{first, second, third})
1062 next, err = writeNewTranscriptLocked(ag, sess, next)
1063 if err != nil {
1064 t.Fatalf("retry transcript: %v", err)
1065 }
1066 if next != 3 {
1067 t.Fatalf("next persisted message index after retry = %d, want 3", next)
1068 }
1069 if err := sess.Close(); err != nil {
1070 t.Fatal(err)
1071 }
1072
1073 reopened, messages, err := core.OpenSession(sess.Path)
1074 if err != nil {
1075 t.Fatalf("reopen session: %v", err)
1076 }
1077 t.Cleanup(func() { _ = reopened.Close() })
1078 if len(messages) != 3 {
1079 t.Fatalf("replayed message count = %d, want 3", len(messages))
1080 }
1081 if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
1082 t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
1083 }
1084}