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