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}