v0.50.1 a637010

cli_session_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close