v0.13.0 befd698

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

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 477

↑↓ moveenter openesc close