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