v0.14.0 75d4151

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

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