1package modes
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/skills"
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13 "github.com/bnema/zut/packages/tui"
14)
15
16// pinRecordingClient answers every request with "ok" and records the request.
17type pinRecordingClient struct {
18 requests chan provider.Request
19}
20
21func (c *pinRecordingClient) Name() string { return "pin-test" }
22
23func (c *pinRecordingClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
24 c.requests <- req
25 out := make(chan provider.Event, 2)
26 out <- provider.EventTextDelta{Delta: "ok"}
27 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
28 Role: provider.RoleAssistant,
29 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
30 }}
31 close(out)
32 return out, nil
33}
34
35func lastUserText(req provider.Request) string {
36 for n := len(req.Messages) - 1; n >= 0; n-- {
37 if req.Messages[n].Role != provider.RoleUser {
38 continue
39 }
40 return stripMessageTime(userMessageText(req.Messages[n]))
41 }
42 return ""
43}
44
45// stripMessageTime drops the request-time "[message time: ...]" line the
46// context builder prefixes to user messages.
47func stripMessageTime(text string) string {
48 if strings.HasPrefix(text, "[message time:") {
49 if _, rest, ok := strings.Cut(text, "\n"); ok {
50 return rest
51 }
52 }
53 return text
54}
55
56type pinTurnHarness struct {
57 i *Interactive
58 client *pinRecordingClient
59 mu sync.Mutex
60 persisted []provider.Message
61}
62
63func newPinTurnHarness(t *testing.T, pinned bool) *pinTurnHarness {
64 t.Helper()
65 h := &pinTurnHarness{client: &pinRecordingClient{requests: make(chan provider.Request, 8)}}
66 ag := core.NewAgent(h.client, "test-model", "", nil)
67 ag.OnMessageAppended = func(m provider.Message) {
68 h.mu.Lock()
69 h.persisted = append(h.persisted, m)
70 h.mu.Unlock()
71 }
72 h.i = NewInteractive(InteractiveConfig{
73 Agent: ag,
74 Provider: "anthropic",
75 Model: "claude-sonnet-4-5-20250929",
76 CWD: "/work",
77 SkillSnapshot: func() []*skills.Skill {
78 return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}}
79 },
80 LoadSkillPins: func(string) (skills.Pins, error) {
81 return skills.Pins{Project: []string{"review"}}, nil
82 },
83 PreloadPinnedSkills: pinned,
84 })
85 h.i.runCtx = context.Background()
86 return h
87}
88
89// prompt submits text as genuine user input, the way the host paths do.
90func (h *pinTurnHarness) prompt(t *testing.T, text string) provider.Request {
91 t.Helper()
92 h.i.submitOrQueueMessage(core.QueuedMessage{Text: text}, true)
93 req := receiveRequest(t, h.client.requests)
94 waitInteractiveIdle(t, h.i)
95 return req
96}
97
98func (h *pinTurnHarness) persistedUserTexts() []string {
99 h.mu.Lock()
100 defer h.mu.Unlock()
101 var out []string
102 for _, m := range h.persisted {
103 if m.Role == provider.RoleUser {
104 out = append(out, userMessageText(m))
105 }
106 }
107 return out
108}
109
110func TestPinnedSkillsJoinFirstPromptOnceAndPersist(t *testing.T) {
111 h := newPinTurnHarness(t, true)
112 first := lastUserText(h.prompt(t, "first request"))
113 if strings.Count(first, "Inspect carefully.") != 1 || !strings.HasSuffix(first, "first request") {
114 t.Fatalf("first prompt = %q", first)
115 }
116 if second := lastUserText(h.prompt(t, "second request")); second != "second request" {
117 t.Fatalf("second prompt carried pins: %q", second)
118 }
119 texts := h.persistedUserTexts()
120 if len(texts) != 2 || strings.Count(strings.Join(texts, "\n"), "Inspect carefully.") != 1 || !strings.Contains(texts[0], "Inspect carefully.") {
121 t.Fatalf("persisted transcript = %q", texts)
122 }
123 // The notice disappears once consumed.
124 h.i.mu.Lock()
125 notice := h.i.skillPins.notice(h.i.cfg.Theme, 80)
126 h.i.mu.Unlock()
127 if len(notice) != 0 {
128 t.Fatal("notice survived consumption")
129 }
130}
131
132func TestPinnedSkillsAbsentWithoutArming(t *testing.T) {
133 for name, arm := range map[string]func(*pinTurnHarness){
134 "not configured": func(*pinTurnHarness) {},
135 "resumed": func(h *pinTurnHarness) {
136 h.i.SetPinnedSkillsPending(true)
137 h.i.SetPinnedSkillsPending(false)
138 },
139 "session applied": func(h *pinTurnHarness) {
140 h.i.SetPinnedSkillsPending(true)
141 h.i.ApplySessionAgent(core.NewAgent(h.client, "test-model", "", nil), "anthropic", "claude-sonnet-4-5-20250929")
142 },
143 } {
144 t.Run(name, func(t *testing.T) {
145 h := newPinTurnHarness(t, false)
146 arm(h)
147 if got := lastUserText(h.prompt(t, "hello")); got != "hello" {
148 t.Fatalf("prompt = %q", got)
149 }
150 })
151 }
152}
153
154const pinBody = "Inspect carefully."
155
156func (h *pinTurnHarness) pinsPending() bool {
157 h.i.mu.Lock()
158 defer h.i.mu.Unlock()
159 return h.i.skillPins.pending
160}
161
162func TestPinnedSkillsIgnoreNonUserStarts(t *testing.T) {
163 h := newPinTurnHarness(t, true)
164
165 // Scheduler follow-up, extension prompt and host evidence are not user
166 // input: they must neither carry the pins nor consume them.
167 if err := h.i.SubmitFollowUp(context.Background(), "scheduled tick"); err != nil {
168 t.Fatal(err)
169 }
170 if got := lastUserText(receiveRequest(t, h.client.requests)); got != "scheduled tick" {
171 t.Fatalf("scheduled prompt = %q", got)
172 }
173 waitInteractiveIdle(t, h.i)
174
175 h.i.startTurn(context.Background(), "extension prompt") // invokeExtensionCommand "prompt" action
176 if got := lastUserText(receiveRequest(t, h.client.requests)); got != "extension prompt" {
177 t.Fatalf("extension prompt = %q", got)
178 }
179 waitInteractiveIdle(t, h.i)
180
181 h.i.submitOrQueueMessage(core.QueuedMessage{Text: "worker report", HostEvent: true}, false)
182 if got := lastUserText(receiveRequest(t, h.client.requests)); got != "worker report" {
183 t.Fatalf("host event = %q", got)
184 }
185 waitInteractiveIdle(t, h.i)
186
187 if !h.pinsPending() {
188 t.Fatal("non-user starts consumed the pins")
189 }
190 if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 {
191 t.Fatalf("first genuine user prompt = %q", first)
192 }
193}
194
195func TestPinnedSkillsKeepContinuationsAndRetriesPending(t *testing.T) {
196 h := newPinTurnHarness(t, true)
197 // Real continuation and post-compaction/overflow retry entry points.
198 h.i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier"}}}})
199 h.i.startTurnRequest(context.Background(), "", nil, true, false)
200 receiveRequest(t, h.client.requests)
201 waitInteractiveIdle(t, h.i)
202 h.i.startTurnRequest(context.Background(), "", nil, true, true)
203 receiveRequest(t, h.client.requests)
204 waitInteractiveIdle(t, h.i)
205 if !h.pinsPending() {
206 t.Fatal("continuation or retry consumed the pins")
207 }
208}
209
210func TestPinnedSkillsModelSwapAndClear(t *testing.T) {
211 h := newPinTurnHarness(t, true)
212 h.i.swapModel("anthropic", "claude-opus-4-1-20250805", nil, false)
213 if !h.pinsPending() {
214 t.Fatal("model swap consumed the pins")
215 }
216 if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 {
217 t.Fatalf("pins lost across model swap: %q", first)
218 }
219 h.i.swapModel("anthropic", "claude-sonnet-4-5-20250929", nil, false)
220 if later := lastUserText(h.prompt(t, "later")); later != "later" {
221 t.Fatalf("model swap re-armed pins: %q", later)
222 }
223 h.i.runSlash(context.Background(), "/clear")
224 if again := lastUserText(h.prompt(t, "after clear")); strings.Count(again, pinBody) != 1 {
225 t.Fatalf("/clear did not re-arm pins: %q", again)
226 }
227}
228
229// A prompt typed while a startup command still owns the agent is queued, not
230// started. It is the first genuine user prompt and must carry the pins once.
231func TestPinnedSkillsQueuedDuringStartupPreReachProvider(t *testing.T) {
232 h := newPinTurnHarness(t, true)
233 h.i.mu.Lock()
234 h.i.busy = true
235 h.i.awaitingStartupPre = true
236 h.i.mu.Unlock()
237
238 h.i.ed.SetValue("typed while pre runs")
239 h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
240 h.i.ed.SetValue("second typed")
241 h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
242
243 queued := h.i.agent.PendingQueuedMessages()
244 if len(queued) != 2 || strings.Count(queued[0].Text, pinBody) != 1 || !strings.HasSuffix(queued[0].Text, "typed while pre runs") || queued[1].Text != "second typed" {
245 t.Fatalf("queued = %+v", queued)
246 }
247
248 // The startup turn ends; the agent loop delivers the queued prompts.
249 h.i.mu.Lock()
250 h.i.busy = false
251 h.i.awaitingStartupPre = false
252 h.i.mu.Unlock()
253 h.i.startTurnRequest(context.Background(), "", nil, true, false)
254 req := receiveRequest(t, h.client.requests)
255 waitInteractiveIdle(t, h.i)
256 if strings.Count(userMessagesText(req), pinBody) != 1 {
257 t.Fatalf("provider request = %q", userMessagesText(req))
258 }
259 if strings.Count(strings.Join(h.persistedUserTexts(), "\n"), pinBody) != 1 {
260 t.Fatalf("persisted = %q", h.persistedUserTexts())
261 }
262}
263
264func TestPinnedSkillsCarryOncePastPreTurnCompaction(t *testing.T) {
265 h := newPinTurnHarness(t, true)
266 h.i.agent.SetMessages([]provider.Message{
267 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier one"}}},
268 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply one"}}},
269 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier two"}}},
270 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply two"}}},
271 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier three"}}},
272 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply three"}}},
273 })
274 threshold := 70
275 h.i.cfg.AutoCompactThreshold = &threshold
276 h.i.mu.Lock()
277 h.i.lastCtxInput = 10_000_000
278 h.i.mu.Unlock()
279
280 h.i.submitOrQueueMessage(core.QueuedMessage{Text: "big request"}, true)
281 compaction := receiveRequest(t, h.client.requests)
282 if strings.Contains(userMessagesText(compaction), pinBody) {
283 t.Fatal("compaction request carried pinned skills")
284 }
285 h.i.mu.Lock()
286 h.i.lastCtxInput = 0
287 h.i.mu.Unlock()
288 followUp := receiveRequest(t, h.client.requests)
289 waitInteractiveIdle(t, h.i)
290 if strings.Count(userMessagesText(followUp), pinBody) != 1 {
291 t.Fatalf("follow-up did not carry pins exactly once: %q", userMessagesText(followUp))
292 }
293}
294
295func TestPinnedSkillsClearResetsCompactionEstimate(t *testing.T) {
296 h := newPinTurnHarness(t, true)
297 h.prompt(t, "before clear")
298 h.i.mu.Lock()
299 h.i.lastCtxInput = 10_000_000
300 h.i.mu.Unlock()
301 h.i.runSlash(context.Background(), "/clear")
302 if got := lastUserText(h.prompt(t, "after clear")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "after clear") {
303 t.Fatalf("first request after clear = %q", got)
304 }
305}
306
307func TestPinnedSkillsRearmAfterFailedPreTurnCompaction(t *testing.T) {
308 h := newPinTurnHarness(t, true)
309 threshold := 70
310 h.i.cfg.AutoCompactThreshold = &threshold
311 h.i.mu.Lock()
312 h.i.lastCtxInput = 10_000_000
313 h.i.mu.Unlock()
314 // An empty transcript cannot compact: the pending user prompt never reaches
315 // the provider, and its pinned selection must be released for a retry.
316 h.i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true)
317 waitInteractiveIdle(t, h.i)
318 h.i.mu.Lock()
319 errText := h.i.statusErr
320 h.i.lastCtxInput = 0
321 h.i.mu.Unlock()
322 if !strings.Contains(errText, "nothing to compact") || !h.pinsPending() {
323 t.Fatalf("failed compaction: status=%q pending=%v", errText, h.pinsPending())
324 }
325 select {
326 case req := <-h.client.requests:
327 t.Fatalf("failed compaction sent unexpected request: %q", userMessagesText(req))
328 default:
329 }
330 if got := lastUserText(h.prompt(t, "retried request")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "retried request") {
331 t.Fatalf("retry after failed compaction = %q", got)
332 }
333}
334
335func TestPinnedSkillsRearmAfterCancelledPreTurnCompaction(t *testing.T) {
336 client := &compactQueueClient{
337 compactionStarted: make(chan struct{}), releaseCompaction: make(chan struct{}),
338 followUpRequest: make(chan provider.Request, 1),
339 }
340 ag := core.NewAgent(client, "test-model", "", nil)
341 ag.SetMessages([]provider.Message{
342 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier context"}}},
343 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "earlier answer"}}},
344 })
345 threshold := 70
346 i := NewInteractive(InteractiveConfig{
347 Agent: ag, Provider: "anthropic", Model: "claude-sonnet-4-5-20250929", CWD: t.TempDir(),
348 AutoCompactThreshold: &threshold, PreloadPinnedSkills: true,
349 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
350 SkillSnapshot: reviewSnapshot,
351 })
352 i.runCtx = context.Background()
353 t.Cleanup(i.CancelTurn)
354 i.mu.Lock()
355 i.lastCtxInput = 150000
356 i.mu.Unlock()
357 i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true)
358 select {
359 case <-client.compactionStarted:
360 case <-time.After(2 * time.Second):
361 t.Fatal("compaction did not start")
362 }
363 i.mu.Lock()
364 cancel := i.cancelTurn
365 i.mu.Unlock()
366 if cancel == nil {
367 t.Fatal("compaction has no cancellation")
368 }
369 cancel()
370 waitInteractiveIdle(t, i)
371 i.mu.Lock()
372 pending := i.skillPins.pending
373 i.lastCtxInput = 0
374 i.mu.Unlock()
375 if !pending {
376 t.Fatal("cancelled compaction lost the pinned selection")
377 }
378 i.submitOrQueueMessage(core.QueuedMessage{Text: "retried request"}, true)
379 select {
380 case req := <-client.followUpRequest:
381 if got := lastUserText(req); strings.Count(got, "Inspect carefully.") != 1 || !strings.HasSuffix(got, "retried request") {
382 t.Fatalf("retry after cancelled compaction = %q", got)
383 }
384 case <-time.After(2 * time.Second):
385 t.Fatal("retry did not reach provider")
386 }
387 waitInteractiveIdle(t, i)
388}
389
390func userMessagesText(req provider.Request) string {
391 var parts []string
392 for _, m := range req.Messages {
393 if m.Role == provider.RoleUser {
394 parts = append(parts, userMessageText(m))
395 }
396 }
397 return strings.Join(parts, "\n")
398}
399
400func TestSkillsPickerPinKeysUseHostCallbacksAndDedupeWarnings(t *testing.T) {
401 var toggled []string
402 i := NewInteractive(InteractiveConfig{
403 CWD: "/work",
404 SkillSnapshot: reviewSnapshot,
405 LoadSkillPins: func(string) (skills.Pins, error) {
406 return skills.Pins{Global: []string{"gone"}}, nil
407 },
408 ToggleSkillPin: func(cwd, name string, global bool) error {
409 toggled = append(toggled, cwd+":"+name)
410 return nil
411 },
412 PreloadPinnedSkills: true,
413 })
414 i.openSkillsDialog()
415 i.openSkillsDialog()
416 i.handleKey(context.Background(), pinKey('p'))
417 if len(toggled) != 1 || toggled[0] != "/work:review" {
418 t.Fatalf("toggle calls = %v", toggled)
419 }
420 i.mu.Lock()
421 warnings := 0
422 for _, e := range i.reloadErrors {
423 if strings.Contains(e, "gone") {
424 warnings++
425 }
426 }
427 notice := i.statusOK
428 i.mu.Unlock()
429 if warnings != 1 || notice == "" {
430 t.Fatalf("warnings=%d status=%q reloadErrors=%q", warnings, notice, i.reloadErrors)
431 }
432}