v0.38.0 58133ea

title_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/provider"
11)
12
13type titleTestClient struct {
14	request provider.Request
15	events  []provider.Event
16	err     error
17}
18
19func (c *titleTestClient) Name() string { return "title-test" }
20
21func (c *titleTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
22	c.request = req
23	if c.err != nil {
24		return nil, c.err
25	}
26	out := make(chan provider.Event, len(c.events))
27	for _, event := range c.events {
28		out <- event
29	}
30	close(out)
31	return out, nil
32}
33
34type blockingTitleClient struct {
35	started chan struct{}
36}
37
38func (c *blockingTitleClient) Name() string { return "blocking-title" }
39
40func (c *blockingTitleClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
41	close(c.started)
42	out := make(chan provider.Event)
43	go func() {
44		<-ctx.Done()
45		close(out)
46	}()
47	return out, nil
48}
49
50func TestGenerateSessionTitleUsesAHiddenMinimalRequest(t *testing.T) {
51	client := &titleTestClient{events: []provider.Event{
52		provider.EventTextDelta{Delta: "Fix "},
53		provider.EventTextDelta{Delta: "login flow"},
54		provider.EventDone{Stop: provider.StopEnd},
55	}}
56
57	got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Please fix the login flow")
58	if err != nil {
59		t.Fatal(err)
60	}
61	if got != "Fix login flow" {
62		t.Fatalf("title = %q, want %q", got, "Fix login flow")
63	}
64	if client.request.Model != "test-model" {
65		t.Fatalf("model = %q, want test-model", client.request.Model)
66	}
67	if client.request.System == "" {
68		t.Fatal("title request has no system instruction")
69	}
70	if len(client.request.Tools) != 0 {
71		t.Fatalf("title request advertised %d tools, want none", len(client.request.Tools))
72	}
73	if client.request.MaxTokens != 16 {
74		t.Fatalf("max tokens = %d, want 16", client.request.MaxTokens)
75	}
76	if len(client.request.Messages) != 1 || client.request.Messages[0].Role != provider.RoleUser {
77		t.Fatalf("title request messages = %#v, want one user message", client.request.Messages)
78	}
79}
80
81func TestGenerateSessionTitleFallsBackToFinalMessageWhenNoDeltas(t *testing.T) {
82	client := &titleTestClient{events: []provider.Event{
83		provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
84			Role:    provider.RoleAssistant,
85			Content: []provider.Content{provider.TextBlock{Text: "Title: \"Review API\""}},
86		}},
87	}}
88
89	got, err := GenerateSessionTitle(context.Background(), client, "test-model", "Review the API")
90	if err != nil {
91		t.Fatal(err)
92	}
93	if got != "Review API" {
94		t.Fatalf("title = %q, want %q", got, "Review API")
95	}
96}
97
98func TestGenerateSessionTitlePropagatesStreamErrors(t *testing.T) {
99	want := errors.New("provider unavailable")
100	client := &titleTestClient{events: []provider.Event{
101		provider.EventDone{Stop: provider.StopError, Err: want},
102	}}
103	if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); !errors.Is(err, want) {
104		t.Fatalf("error = %v, want wrapped %v", err, want)
105	}
106}
107
108func TestGenerateSessionTitleRejectsIncompleteStream(t *testing.T) {
109	client := &titleTestClient{}
110	if _, err := GenerateSessionTitle(context.Background(), client, "test-model", "Do work"); err == nil {
111		t.Fatal("incomplete stream returned nil error")
112	}
113}
114
115func TestGenerateSessionTitleStopsWhenContextIsCancelled(t *testing.T) {
116	ctx, cancel := context.WithCancel(context.Background())
117	client := &blockingTitleClient{started: make(chan struct{})}
118	result := make(chan error, 1)
119	go func() {
120		_, err := GenerateSessionTitle(ctx, client, "test-model", "Do work")
121		result <- err
122	}()
123	<-client.started
124	cancel()
125
126	select {
127	case err := <-result:
128		if !errors.Is(err, context.Canceled) {
129			t.Fatalf("error = %v, want context canceled", err)
130		}
131	case <-time.After(time.Second):
132		t.Fatal("title generation did not stop after context cancellation")
133	}
134}
135
136func TestNormalizeSessionTitleIsConciseAndSafe(t *testing.T) {
137	got := NormalizeSessionTitle("\n  Title: `A very long title that needs to be shortened because it is too long`  ")
138	if got != "A very long title that needs to be shor…" {
139		t.Fatalf("normalized title = %q", got)
140	}
141	if len([]rune(got)) != SessionTitleMaxChars {
142		t.Fatalf("normalized title rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
143	}
144}
145
146func TestOpenSessionRestoresAppendOnlyTitle(t *testing.T) {
147	root := t.TempDir()
148	session, err := NewSession(root, "/workspace", "test", "test-model", "test")
149	if err != nil {
150		t.Fatal(err)
151	}
152	if err := session.AppendMessage(provider.Message{
153		Role:    provider.RoleUser,
154		Content: []provider.Content{provider.TextBlock{Text: "work"}},
155	}); err != nil {
156		t.Fatal(err)
157	}
158	if err := session.UpdateTitle("Fix login"); err != nil {
159		t.Fatal(err)
160	}
161	path := session.Path
162	if err := session.Close(); err != nil {
163		t.Fatal(err)
164	}
165
166	reopened, _, err := OpenSession(path)
167	if err != nil {
168		t.Fatal(err)
169	}
170	defer reopened.Close()
171	if reopened.Title != "Fix login" {
172		t.Fatalf("restored title = %q, want %q", reopened.Title, "Fix login")
173	}
174	if reopened.Meta.Title != "" {
175		t.Fatalf("meta title = %q, want append-only meta title to remain empty", reopened.Meta.Title)
176	}
177}
178
179func TestUpdateTitleLeavesMemoryUnchangedWhenWriteFails(t *testing.T) {
180	root := t.TempDir()
181	session, err := NewSession(root, "/workspace", "test", "test-model", "test")
182	if err != nil {
183		t.Fatal(err)
184	}
185	if err := session.AppendMessage(provider.Message{
186		Role:    provider.RoleUser,
187		Content: []provider.Content{provider.TextBlock{Text: "work"}},
188	}); err != nil {
189		t.Fatal(err)
190	}
191	if err := session.Close(); err != nil {
192		t.Fatal(err)
193	}
194
195	if err := session.UpdateTitle("unwritten title"); err == nil {
196		t.Fatal("UpdateTitle returned nil after the session writer was closed")
197	}
198	if session.Title != "" {
199		t.Fatalf("in-memory title = %q after failed write, want empty", session.Title)
200	}
201}
202
203func TestNormalizeSessionTitlePreservesUnicodeBoundaries(t *testing.T) {
204	got := NormalizeSessionTitle("éééééééééééééééééééééééééééééééééééééééééééé")
205	if len([]rune(got)) != SessionTitleMaxChars {
206		t.Fatalf("rune count = %d, want %d", len([]rune(got)), SessionTitleMaxChars)
207	}
208	if !json.Valid([]byte(`"` + got + `"`)) {
209		t.Fatalf("normalized unicode title is not valid UTF-8 JSON: %q", got)
210	}
211}

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