v0.12.0 0c5f8d5

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

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