v0.17.0 949637b

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

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