1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13)
14
15type interactiveTitleClient struct {
16 mu sync.Mutex
17 requests []provider.Request
18 title string
19 titleErr error
20}
21
22func (c *interactiveTitleClient) Name() string { return "interactive-title-test" }
23
24func (c *interactiveTitleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
25 c.mu.Lock()
26 c.requests = append(c.requests, req)
27 c.mu.Unlock()
28 if req.System != "" && c.titleErr != nil {
29 return nil, c.titleErr
30 }
31 out := make(chan provider.Event, 2)
32 if req.System != "" {
33 out <- provider.EventTextDelta{Delta: c.title}
34 out <- provider.EventDone{Stop: provider.StopEnd}
35 } else {
36 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37 Role: provider.RoleAssistant,
38 Content: []provider.Content{provider.TextBlock{Text: "main response"}},
39 }}
40 }
41 close(out)
42 return out, nil
43}
44
45func (c *interactiveTitleClient) requestCount() int {
46 c.mu.Lock()
47 defer c.mu.Unlock()
48 return len(c.requests)
49}
50
51func waitInteractiveIdle(t *testing.T, i *Interactive) {
52 t.Helper()
53 deadline := time.Now().Add(time.Second)
54 for time.Now().Before(deadline) {
55 i.mu.Lock()
56 busy := i.busy
57 i.mu.Unlock()
58 if !busy {
59 return
60 }
61 time.Sleep(time.Millisecond)
62 }
63 t.Fatal("interactive turn did not finish")
64}
65
66func waitInteractiveTitle(t *testing.T, i *Interactive, want string) {
67 t.Helper()
68 deadline := time.Now().Add(time.Second)
69 for time.Now().Before(deadline) {
70 i.mu.Lock()
71 got := i.sessionTitle
72 i.mu.Unlock()
73 if got == want {
74 return
75 }
76 time.Sleep(time.Millisecond)
77 }
78 i.mu.Lock()
79 got := i.sessionTitle
80 i.mu.Unlock()
81 t.Fatalf("session title = %q, want %q", got, want)
82}
83
84func waitTerminalOutput(t *testing.T, term *alertTestTerminal, want string) {
85 t.Helper()
86 deadline := time.Now().Add(time.Second)
87 for time.Now().Before(deadline) {
88 if got := term.String(); got == want {
89 return
90 }
91 time.Sleep(time.Millisecond)
92 }
93 t.Fatalf("terminal title output = %q, want %q", term.String(), want)
94}
95
96func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) {
97 term := &alertTestTerminal{}
98 client := &interactiveTitleClient{title: "Fix login retries"}
99 ag := core.NewAgent(client, "test-model", "", nil)
100 persisted := make(chan string, 1)
101 i := NewInteractive(InteractiveConfig{
102 Agent: ag,
103 Terminal: term,
104 PersistTitle: func(title string) error {
105 persisted <- title
106 return nil
107 },
108 })
109 i.markInteractiveStarted()
110
111 i.Submit("Please fix login retries")
112 waitInteractiveIdle(t, i)
113
114 select {
115 case got := <-persisted:
116 if got != "Fix login retries" {
117 t.Fatalf("persisted title = %q, want %q", got, "Fix login retries")
118 }
119 case <-time.After(time.Second):
120 t.Fatal("title was not persisted")
121 }
122 waitTerminalOutput(t, term, "\x1b]0;zut: Fix login retries\x07")
123 if got := len(ag.Messages()); got != 2 {
124 t.Fatalf("transcript message count = %d, want user + assistant only", got)
125 }
126 if got := client.requestCount(); got != 2 {
127 t.Fatalf("provider request count = %d, want main + hidden title", got)
128 }
129}
130
131func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) {
132 term := &alertTestTerminal{}
133 client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")}
134 ag := core.NewAgent(client, "test-model", "", nil)
135 persisted := make(chan string, 1)
136 i := NewInteractive(InteractiveConfig{
137 Agent: ag,
138 Terminal: term,
139 PersistTitle: func(title string) error {
140 persisted <- title
141 return nil
142 },
143 })
144 i.markInteractiveStarted()
145
146 prompt := "Please fix the login retry behavior"
147 i.Submit(prompt)
148 waitInteractiveIdle(t, i)
149
150 select {
151 case got := <-persisted:
152 if got != "Please fix the login retry behavior" {
153 t.Fatalf("fallback title = %q, want prompt text", got)
154 }
155 case <-time.After(time.Second):
156 t.Fatal("fallback title was not persisted")
157 }
158 if got := len(ag.Messages()); got != 2 {
159 t.Fatalf("transcript message count = %d, want main turn only", got)
160 }
161}
162
163func TestSkillSlashPromptDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
164 term := &alertTestTerminal{}
165 client := &interactiveTitleClient{title: "should not be used"}
166 ag := core.NewAgent(client, "test-model", "", nil)
167 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
168 i.markInteractiveStarted()
169
170 i.submitOrQueuePrompt(context.Background(), "expanded skill instructions")
171 waitInteractiveIdle(t, i)
172
173 if got := client.requestCount(); got != 1 {
174 t.Fatalf("provider request count = %d, want only the slash-expanded main request", got)
175 }
176 i.mu.Lock()
177 seen := i.titleRealPromptSeen
178 i.mu.Unlock()
179 if seen {
180 t.Fatal("slash-expanded prompt consumed the first real prompt title slot")
181 }
182}
183
184func TestStartupPreDoesNotConsumeTheFirstRealPromptTitle(t *testing.T) {
185 term := &alertTestTerminal{}
186 client := &interactiveTitleClient{title: "real prompt title"}
187 ag := core.NewAgent(client, "test-model", "", nil)
188 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
189 i.markInteractiveStarted()
190 i.awaitingStartupPre = true
191
192 i.Submit("load startup resources")
193 waitInteractiveIdle(t, i)
194
195 if got := client.requestCount(); got != 1 {
196 t.Fatalf("startup pre request count = %d, want only the startup request", got)
197 }
198 i.mu.Lock()
199 seen := i.titleRealPromptSeen
200 i.mu.Unlock()
201 if seen {
202 t.Fatal("startup pre consumed the first real prompt title slot")
203 }
204}
205
206func TestDisabledTerminalTitlesSkipHiddenRequest(t *testing.T) {
207 term := &alertTestTerminal{}
208 disabled := false
209 client := &interactiveTitleClient{title: "should not be used"}
210 ag := core.NewAgent(client, "test-model", "", nil)
211 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term, TerminalTitleEnabled: &disabled})
212 i.markInteractiveStarted()
213
214 i.startTurn(context.Background(), "do the work")
215 waitInteractiveIdle(t, i)
216
217 if got := client.requestCount(); got != 1 {
218 t.Fatalf("provider request count = %d, want only the main request", got)
219 }
220 if got := term.String(); got != "" {
221 t.Fatalf("disabled title output = %q, want empty", got)
222 }
223}
224
225func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
226 term := &alertTestTerminal{}
227 client := &interactiveTitleClient{title: "new branch title"}
228 ag := core.NewAgent(client, "test-model", "", nil)
229 ag.SetMessages([]provider.Message{{
230 Role: provider.RoleUser,
231 Content: []provider.Content{provider.TextBlock{Text: "copied parent prompt"}},
232 }})
233 i := NewInteractive(InteractiveConfig{
234 Agent: ag,
235 Terminal: term,
236 InitialSessionTitlePending: true,
237 })
238 i.markInteractiveStarted()
239
240 i.Submit("new branch work")
241 waitInteractiveIdle(t, i)
242 // The hidden title request is deliberately asynchronous, so wait for its
243 // visible result rather than assuming it finishes before the main turn.
244 waitInteractiveTitle(t, i, "new branch title")
245 if got := client.requestCount(); got != 2 {
246 t.Fatalf("provider request count = %d, want main + hidden title", got)
247 }
248 i.mu.Lock()
249 seen := i.titleRealPromptSeen
250 i.mu.Unlock()
251 if !seen {
252 t.Fatal("fresh branch did not record its first real prompt")
253 }
254}
255
256func TestDisablingTerminalTitlesRestoresNeutralTitle(t *testing.T) {
257 term := &alertTestTerminal{}
258 enabled := true
259 i := NewInteractive(InteractiveConfig{
260 Terminal: term,
261 TerminalTitleEnabled: &enabled,
262 InitialSessionTitle: "Existing work",
263 })
264 i.markInteractiveStarted()
265 i.applySettingToggle("terminal_title_enabled", false)
266
267 got := term.String()
268 if !strings.Contains(got, "\x1b]0;zut: Existing work\x07") || !strings.Contains(got, "\x1b]0;zut\x07") {
269 t.Fatalf("terminal title output = %q, want existing and neutral title sequences", got)
270 }
271}
272
273func TestLoadedSessionTitleIsRestoredWithoutGeneration(t *testing.T) {
274 term := &alertTestTerminal{}
275 ag := core.NewAgent(nil, "test-model", "", nil)
276 ag.SetMessages([]provider.Message{{
277 Role: provider.RoleUser,
278 Content: []provider.Content{provider.TextBlock{Text: "existing prompt"}},
279 }})
280 i := NewInteractive(InteractiveConfig{
281 Agent: ag,
282 Terminal: term,
283 InitialSessionTitle: "Existing work",
284 })
285 i.markInteractiveStarted()
286
287 if got, want := term.String(), "\x1b]0;zut: Existing work\x07"; got != want {
288 t.Fatalf("restored terminal title = %q, want %q", got, want)
289 }
290 i.mu.Lock()
291 seen := i.titleRealPromptSeen
292 started := i.titleGenerationStarted
293 i.mu.Unlock()
294 if !seen || !started {
295 t.Fatal("loaded session was eligible for a new title")
296 }
297}