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 TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
115 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
116 if err != nil {
117 t.Fatal(err)
118 }
119 defer sess.Close()
120
121 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
122 if err := persistGoalToolResult(sess, result); err != nil {
123 t.Fatal(err)
124 }
125 if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
126 t.Fatalf("persisted goal = %#v", goal)
127 }
128}
129
130func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
131 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
132 if err != nil {
133 t.Fatal(err)
134 }
135 defer sess.Close()
136 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
137 t.Fatal(err)
138 }
139
140 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}})
141 if err == nil {
142 t.Fatal("active goal replacement succeeded")
143 }
144 if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive {
145 t.Fatalf("persisted goal = %#v", got)
146 }
147}
148
149func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
150 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
151 if err != nil {
152 t.Fatal(err)
153 }
154 defer sess.Close()
155 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil {
156 t.Fatal(err)
157 }
158 missionID := sess.Meta.Mission.ID
159 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil {
160 t.Fatal(err)
161 }
162 if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil {
163 t.Fatal(err)
164 }
165 goal := sess.Meta.Goal
166 if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 {
167 t.Fatalf("next goal = %#v", goal)
168 }
169}
170
171func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) {
172 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
173 if err != nil {
174 t.Fatal(err)
175 }
176 defer sess.Close()
177 if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil {
178 t.Fatal(err)
179 }
180
181 err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}})
182 if err == nil {
183 t.Fatal("mismatched mission was accepted")
184 }
185}
186
187func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
188 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
189 if err != nil {
190 t.Fatal(err)
191 }
192 defer sess.Close()
193 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
194 t.Fatal(err)
195 }
196
197 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked}}
198 if err := persistGoalToolResult(sess, result); err != nil {
199 t.Fatal(err)
200 }
201 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
202 t.Fatalf("persisted goal = %#v, want active", got)
203 }
204}
205
206func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
207 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
208 if err != nil {
209 t.Fatal(err)
210 }
211 defer sess.Close()
212 if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
213 t.Fatal(err)
214 }
215
216 result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
217 if err := persistGoalToolResult(sess, result); err != nil {
218 t.Fatal(err)
219 }
220 if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
221 t.Fatalf("persisted goal = %#v", got)
222 }
223}
224
225type failingStdinReader struct{}
226
227func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
228
229func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) {
230 if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") {
231 t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err)
232 }
233}
234
235func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
236 root := t.TempDir()
237 cwd := t.TempDir()
238 otherCWD := t.TempDir()
239 t.Setenv("ZUT_HOME", root)
240
241 session, err := core.NewSession(root, cwd, "provider", "model", "test")
242 if err != nil {
243 t.Fatal(err)
244 }
245 if err := session.AppendMessage(provider.Message{
246 Role: provider.RoleUser,
247 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
248 }); err != nil {
249 t.Fatal(err)
250 }
251 id := session.ID
252 if err := session.Close(); err != nil {
253 t.Fatal(err)
254 }
255
256 ag := core.NewAgent(nil, "model", "", nil)
257 got, err := openOrCreateSession(context.Background(), Args{
258 CWD: otherCWD,
259 Resume: true,
260 ResumeSessionID: id,
261 }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
262 if err != nil {
263 t.Fatal(err)
264 }
265 if got == nil || got.ID != id || got.Meta.CWD != cwd {
266 t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
267 }
268 if got := firstMessageText(ag.Messages()); got != "resume me" {
269 t.Fatalf("resumed message = %q, want resume me", got)
270 }
271 if err := got.Close(); err != nil {
272 t.Fatal(err)
273 }
274
275 namedRoot := filepath.Join(root, "sessions", "agents", "demo")
276 named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
277 if err != nil {
278 t.Fatal(err)
279 }
280 if err := named.AppendMessage(provider.Message{
281 Role: provider.RoleUser,
282 Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
283 }); err != nil {
284 t.Fatal(err)
285 }
286 namedID := named.ID
287 if err := named.Close(); err != nil {
288 t.Fatal(err)
289 }
290
291 namedAgent := core.NewAgent(nil, "model", "", nil)
292 namedGot, err := openOrCreateSession(context.Background(), Args{
293 CWD: otherCWD,
294 Resume: true,
295 ResumeSessionID: namedID,
296 }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
297 if err != nil {
298 t.Fatal(err)
299 }
300 if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
301 t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
302 }
303 if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
304 t.Fatalf("named resumed message = %q, want named resume me", got)
305 }
306 if err := namedGot.Close(); err != nil {
307 t.Fatal(err)
308 }
309}
310
311func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
312 root := t.TempDir()
313 cwd := t.TempDir()
314
315 empty, err := core.NewSession(root, cwd, "provider", "model", "test")
316 if err != nil {
317 t.Fatal(err)
318 }
319 if err := empty.Close(); err != nil {
320 t.Fatal(err)
321 }
322 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
323 t.Fatalf("empty session stat error = %v, want deleted", err)
324 }
325 if got := resumableSessionID(root, empty, false); got != "" {
326 t.Fatalf("empty session hint ID = %q, want empty", got)
327 }
328
329 arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
330 arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
331 if err != nil {
332 t.Fatal(err)
333 }
334 if err := arbitrary.AppendMessage(provider.Message{
335 Role: provider.RoleUser,
336 Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
337 }); err != nil {
338 t.Fatal(err)
339 }
340 if err := arbitrary.Close(); err != nil {
341 t.Fatal(err)
342 }
343 if got := resumableSessionID(root, arbitrary, true); got != "" {
344 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
345 }
346}
347
348func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
349 root := t.TempDir()
350 cwd := t.TempDir()
351 otherCWD := t.TempDir()
352 t.Setenv("ZUT_HOME", root)
353
354 dir := core.SessionsDir(root, cwd)
355 if err := os.MkdirAll(dir, 0o755); err != nil {
356 t.Fatal(err)
357 }
358 id := uuid.NewString()
359 metaLine := struct {
360 Type string `json:"type"`
361 Meta core.SessionMeta `json:"meta"`
362 }{
363 Type: "meta",
364 Meta: core.SessionMeta{
365 ID: id,
366 CWD: cwd,
367 Provider: "provider",
368 Model: "model",
369 Started: time.Now().UTC(),
370 Version: "test",
371 },
372 }
373 data, err := json.Marshal(metaLine)
374 if err != nil {
375 t.Fatal(err)
376 }
377 path := filepath.Join(dir, "persisted-empty.jsonl")
378 if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
379 t.Fatal(err)
380 }
381
382 sess, err := openOrCreateSession(context.Background(), Args{
383 CWD: otherCWD,
384 Resume: true,
385 ResumeSessionID: id,
386 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
387 if err != nil {
388 t.Fatal(err)
389 }
390 if sess == nil || sess.Path != path {
391 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
392 }
393 if got := resumableSessionID(root, sess, false); got != "" {
394 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
395 }
396 if err := sess.Close(); err != nil {
397 t.Fatal(err)
398 }
399}
400
401func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
402 root := t.TempDir()
403 cwd := t.TempDir()
404 t.Setenv("ZUT_HOME", root)
405 id := uuid.NewString()
406
407 _, err := openOrCreateSession(context.Background(), Args{
408 CWD: cwd,
409 Resume: true,
410 ResumeSessionID: id,
411 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
412 if err == nil {
413 t.Fatal("unknown UUID resume unexpectedly succeeded")
414 }
415 if !strings.Contains(err.Error(), id) {
416 t.Fatalf("unknown UUID error = %v, want session ID", err)
417 }
418 if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
419 t.Fatalf("unknown UUID created sessions: %v", sessions)
420 }
421}
422
423func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
424 root := t.TempDir()
425 cwd := t.TempDir()
426 t.Setenv("ZUT_HOME", root)
427 if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
428 t.Fatal(err)
429 }
430 if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
431 t.Fatal(err)
432 }
433
434 _, err := openOrCreateSession(context.Background(), Args{
435 CWD: cwd,
436 Resume: true,
437 ResumeSessionID: uuid.NewString(),
438 }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
439 if err == nil {
440 t.Fatal("malformed UUID lookup unexpectedly succeeded")
441 }
442 if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
443 t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
444 }
445}
446
447func TestResumeSessionHint(t *testing.T) {
448 id := uuid.NewString()
449 want := "Session ID: " + id + "\nResume with: zut --resume " + id + "\n\n"
450 if got := resumeSessionHint(id); got != want {
451 t.Fatalf("resume hint = %q, want %q", got, want)
452 }
453}
454
455func TestResumableSessionIDAcceptsManagedPersistedSession(t *testing.T) {
456 root := t.TempDir()
457 cwd := t.TempDir()
458 sess, err := core.NewSession(root, cwd, "provider", "model", "test")
459 if err != nil {
460 t.Fatal(err)
461 }
462 if err := sess.AppendMessage(provider.Message{
463 Role: provider.RoleUser,
464 Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
465 }); err != nil {
466 t.Fatal(err)
467 }
468 if err := sess.Close(); err != nil {
469 t.Fatal(err)
470 }
471 if got := resumableSessionID(root, sess, true); got != sess.ID {
472 t.Fatalf("managed session hint ID = %q, want %q", got, sess.ID)
473 }
474}
475
476func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
477 msgs := make([]provider.Message, 0, 101)
478 msgs = append(msgs, provider.Message{
479 Role: provider.RoleTool,
480 AddedToolNames: []string{"lookup_weather"},
481 Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}},
482 })
483 for idx := 1; idx < 101; idx++ {
484 msgs = append(msgs, provider.Message{
485 Role: provider.RoleUser,
486 Content: []provider.Content{provider.TextBlock{Text: "message"}},
487 })
488 }
489 trimmed := trimMessagesForResume(msgs, 100)
490 if len(trimmed) != 100 {
491 t.Fatalf("trimmed message count = %d, want 100", len(trimmed))
492 }
493 if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" {
494 t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames)
495 }
496}
497
498func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
499 for _, total := range []int{100, 101, 102} {
500 t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
501 msgs := make([]provider.Message, 0, total)
502 msgs = append(msgs, provider.Message{
503 Role: provider.RoleUser,
504 Meta: map[string]string{"compaction": "true"},
505 Content: []provider.Content{
506 provider.TextBlock{Text: "## Context Summary (compacted)"},
507 },
508 })
509 for idx := 1; idx < total; idx++ {
510 msgs = append(msgs, provider.Message{
511 Role: provider.RoleUser,
512 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
513 })
514 }
515
516 trimmed := trimMessagesForResume(msgs, 100)
517 if len(trimmed) > 100 {
518 t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
519 }
520 if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
521 t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
522 }
523 })
524 }
525}
526
527func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
528 startup := core.NewAgent(nil, "startup-model", "", nil)
529 startup.SetMessages([]provider.Message{{
530 Role: provider.RoleUser,
531 Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
532 }})
533
534 replacement := core.NewAgent(nil, "replacement-model", "", nil)
535 iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
536
537 resumed := []provider.Message{{
538 Role: provider.RoleUser,
539 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
540 }}
541 liveInteractiveAgent(iv, startup).SetMessages(resumed)
542
543 if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
544 t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
545 }
546 if got := firstMessageText(startup.Messages()); got != "startup transcript" {
547 t.Fatalf("startup agent transcript changed to %q", got)
548 }
549}
550
551func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
552 startup := core.NewAgent(nil, "startup-model", "", nil)
553 if got := liveInteractiveAgent(nil, startup); got != startup {
554 t.Fatalf("fallback agent = %p, want %p", got, startup)
555 }
556}
557
558func TestPersistModelCallbackDoesNotReenterSessionTransition(t *testing.T) {
559 t.Setenv("ZUT_HOME", t.TempDir())
560 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "old-provider", "old-model", "test")
561 if err != nil {
562 t.Fatal(err)
563 }
564 defer sess.Close()
565
566 var persistMu sync.Mutex
567 activeProvider, activeModel := "old-provider", "old-model"
568 persistModel := newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, nil)
569
570 var transitionMu sync.RWMutex
571 sessionTransition := newSessionTransition(&transitionMu)
572 done := make(chan struct{})
573 go func() {
574 sessionTransition(func() {
575 persistModel("new-provider", "new-model")
576 })
577 close(done)
578 }()
579 select {
580 case <-done:
581 case <-time.After(time.Second):
582 t.Fatal("model persistence deadlocked inside the session transition")
583 }
584 if sess.Meta.Provider != "new-provider" || sess.Meta.Model != "new-model" {
585 t.Fatalf("session model = %q/%q, want new-provider/new-model", sess.Meta.Provider, sess.Meta.Model)
586 }
587 if activeProvider != "new-provider" || activeModel != "new-model" {
588 t.Fatalf("active model = %q/%q, want new-provider/new-model", activeProvider, activeModel)
589 }
590}
591
592func firstMessageText(messages []provider.Message) string {
593 if len(messages) == 0 || len(messages[0].Content) == 0 {
594 return ""
595 }
596 text, _ := messages[0].Content[0].(provider.TextBlock)
597 return text.Text
598}
599
600func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string {
601 t.Helper()
602 root := t.TempDir()
603 cwd := t.TempDir()
604 sess, err := core.NewSession(root, cwd, providerName, model, "test")
605 if err != nil {
606 t.Fatal(err)
607 }
608 if err := sess.AppendMessage(provider.Message{
609 Role: provider.RoleUser,
610 Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
611 }); err != nil {
612 t.Fatal(err)
613 }
614 if usage != (provider.Usage{}) {
615 if err := sess.AppendUsage(usage, usage); err != nil {
616 t.Fatal(err)
617 }
618 }
619 if err := sess.Close(); err != nil {
620 t.Fatal(err)
621 }
622 return sess.Path
623}
624
625func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
626 root := t.TempDir()
627 sess, err := core.NewSession(root, "/workspace", "provider", "model", "test")
628 if err != nil {
629 t.Fatal(err)
630 }
631 ag := core.NewAgent(nil, "model", "", nil)
632 gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag)
633 if err != nil {
634 t.Fatal(err)
635 }
636 if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" {
637 t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model)
638 }
639 if _, err := os.Stat(sess.Path); err != nil {
640 t.Fatalf("fresh session disappeared before close: %v", err)
641 }
642 if err := sess.Close(); err != nil {
643 t.Fatal(err)
644 }
645 if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) {
646 t.Fatalf("fresh empty session remained after close: %v", err)
647 }
648}
649
650func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) {
651 t.Setenv("ZUT_HOME", t.TempDir())
652 if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil {
653 t.Fatal(err)
654 }
655 path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{})
656 args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()}
657 base, err := Resolve(args, true)
658 if err != nil {
659 t.Fatal(err)
660 }
661 current := base.NewAgent()
662 runtime := newSubagentRuntime(subagentRuntimeConfig{
663 Args: args,
664 Root: t.TempDir(),
665 RepoRoot: args.CWD,
666 Provider: base.Provider,
667 Model: base.Model,
668 })
669 defer func() { _ = runtime.Close(context.Background()) }()
670
671 sess, _, err := core.OpenSession(path)
672 if err != nil {
673 t.Fatal(err)
674 }
675 gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime)
676 if err != nil {
677 t.Fatal(err)
678 }
679 defer gotSess.Close()
680 if gotAgent == current || providerName != "ollama" || model != "stored-local-model" {
681 t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model)
682 }
683 if got := runtime.currentModel(); got != "stored-local-model" {
684 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
685 }
686 if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {
687 t.Fatal("resumed parent lost canonical subagent_spawn manager tool")
688 }
689}
690
691func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
692 old := core.NewAgent(nil, "old-model", "", nil)
693 old.SetMessages([]provider.Message{{
694 Role: provider.RoleUser,
695 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
696 }})
697 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{
698 InputTokens: 12,
699 OutputTokens: 7,
700 })
701
702 var gotProvider, gotModel string
703 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) {
704 gotProvider, gotModel = providerName, model
705 return core.NewAgent(nil, model, "", nil), providerName, model, nil
706 })
707 if err != nil {
708 t.Fatal(err)
709 }
710 defer candidate.session.Close()
711
712 if gotProvider != "stored-provider" || gotModel != "stored-model" {
713 t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel)
714 }
715 if !candidate.rebuilt || candidate.agent == old {
716 t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old)
717 }
718 if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" {
719 t.Fatalf("candidate transcript = %q, want resumed transcript", got)
720 }
721 if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 {
722 t.Fatalf("candidate usage = %+v, want input=12 output=7", got)
723 }
724 if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" {
725 t.Fatalf("current transcript changed during preparation: %+v", got)
726 }
727}
728
729func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
730 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
731 sess, _, err := core.OpenSession(path)
732 if err != nil {
733 t.Fatal(err)
734 }
735 defer sess.Close()
736
737 current := core.NewAgent(nil, "current-model", "", nil)
738 candidate, err := applySessionResume(sess, current, "current-provider", "current-model", false, false, func(providerName, model string) (*core.Agent, string, string, error) {
739 return core.NewAgent(nil, model, "", nil), providerName, model, nil
740 })
741 if err != nil {
742 t.Fatal(err)
743 }
744 defer candidate.session.Close()
745 if !candidate.rebuilt {
746 t.Fatal("resume did not rebuild the agent")
747 }
748 if got := candidate.agent.SessionID(); got != candidate.session.Meta.ID {
749 t.Fatalf("rebuilt agent session ID = %q, want durable session ID %q", got, candidate.session.Meta.ID)
750 }
751}
752
753func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
754 old := core.NewAgent(nil, "old-model", "", nil)
755 old.SetMessages([]provider.Message{{
756 Role: provider.RoleUser,
757 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
758 }})
759 path := syntheticSession(t, "", "", provider.Usage{})
760
761 candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
762 t.Fatal("legacy session unexpectedly requested a rebuild")
763 return nil, "", "", nil
764 })
765 if err != nil {
766 t.Fatal(err)
767 }
768 defer candidate.session.Close()
769
770 if candidate.rebuilt || candidate.agent != old {
771 t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent)
772 }
773 if got := firstMessageText(old.Messages()); got != "current transcript" {
774 t.Fatalf("current transcript changed before commit: %q", got)
775 }
776 if got := firstMessageText(candidate.messages); got != "resumed transcript" {
777 t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got)
778 }
779}
780
781func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
782 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
783 session, _, err := core.OpenSession(path)
784 if err != nil {
785 t.Fatal(err)
786 }
787 handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
788 if err := session.UpdateCompactHandoff(handoff); err != nil {
789 t.Fatal(err)
790 }
791 if err := session.Close(); err != nil {
792 t.Fatal(err)
793 }
794
795 current := core.NewAgent(nil, "current-model", "", nil)
796 candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
797 if err != nil {
798 t.Fatal(err)
799 }
800 defer candidate.session.Close()
801 if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
802 t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
803 }
804}
805
806func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
807 old := core.NewAgent(nil, "old-model", "", nil)
808 old.SetMessages([]provider.Message{{
809 Role: provider.RoleUser,
810 Content: []provider.Content{provider.TextBlock{Text: "current transcript"}},
811 }})
812 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
813 wantErr := errors.New("synthetic builder failure")
814
815 if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) {
816 return nil, "", "", wantErr
817 }); !errors.Is(err, wantErr) {
818 t.Fatalf("prepare error = %v, want %v", err, wantErr)
819 }
820 if got := firstMessageText(old.Messages()); got != "current transcript" {
821 t.Fatalf("current transcript changed after failed preparation: %q", got)
822 }
823
824 // The failed candidate must have released its append handle, leaving
825 // the selected file readable and reopenable for a later retry.
826 reopened, _, err := core.OpenSession(path)
827 if err != nil {
828 t.Fatalf("reopen candidate after failed preparation: %v", err)
829 }
830 if err := reopened.Close(); err != nil {
831 t.Fatal(err)
832 }
833 if _, err := os.Stat(path); err != nil {
834 t.Fatalf("candidate session disappeared after failed preparation: %v", err)
835 }
836}
837
838func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
839 for _, tc := range []struct {
840 name string
841 explicitProvider bool
842 explicitModel bool
843 wantProvider string
844 wantModel string
845 wantBuild bool
846 }{
847 {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"},
848 {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true},
849 {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true},
850 } {
851 t.Run(tc.name, func(t *testing.T) {
852 old := core.NewAgent(nil, "old-model", "", nil)
853 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
854 var gotProvider, gotModel string
855 candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) {
856 gotProvider, gotModel = providerName, model
857 return core.NewAgent(nil, model, "", nil), providerName, model, nil
858 })
859 if err != nil {
860 t.Fatal(err)
861 }
862 defer candidate.session.Close()
863 if tc.wantBuild {
864 if gotProvider != tc.wantProvider || gotModel != tc.wantModel {
865 t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel)
866 }
867 } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt {
868 t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel)
869 }
870 })
871 }
872}
873
874func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
875 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
876 if err != nil {
877 t.Fatal(err)
878 }
879 first := provider.Message{
880 Role: provider.RoleUser,
881 Content: []provider.Content{provider.TextBlock{Text: "first"}},
882 }
883 invalid := provider.Message{
884 Role: provider.RoleAssistant,
885 Content: []provider.Content{provider.ToolCallBlock{
886 ID: "call-1",
887 Name: "tool",
888 Arguments: json.RawMessage("{"),
889 }},
890 }
891 ag := core.NewAgent(nil, "model", "", nil)
892 ag.SetMessages([]provider.Message{first, invalid})
893 next, err := writeNewTranscriptLocked(ag, sess, 0)
894 if err == nil {
895 t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
896 }
897 if next != 1 {
898 t.Fatalf("next persisted message index = %d, want 1", next)
899 }
900
901 second := provider.Message{
902 Role: provider.RoleAssistant,
903 Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
904 }
905 third := provider.Message{
906 Role: provider.RoleTool,
907 Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
908 }
909 ag.SetMessages([]provider.Message{first, second, third})
910 next, err = writeNewTranscriptLocked(ag, sess, next)
911 if err != nil {
912 t.Fatalf("retry transcript: %v", err)
913 }
914 if next != 3 {
915 t.Fatalf("next persisted message index after retry = %d, want 3", next)
916 }
917 if err := sess.Close(); err != nil {
918 t.Fatal(err)
919 }
920
921 reopened, messages, err := core.OpenSession(sess.Path)
922 if err != nil {
923 t.Fatalf("reopen session: %v", err)
924 }
925 t.Cleanup(func() { _ = reopened.Close() })
926 if len(messages) != 3 {
927 t.Fatalf("replayed message count = %d, want 3", len(messages))
928 }
929 if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
930 t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
931 }
932}