v0.31.0 9467429

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

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