1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/skills"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/tui"
14)
15
16// Synchronous helpers composing the prefetch and publish steps for state-level
17// tests that have no Interactive mutex to release.
18
19func (s *skillPinState) arm(pending bool, cwd string, d *skillsDialog) []string {
20 if !pending {
21 s.disarm()
22 return nil
23 }
24 return s.armFetched(s.fetch(cwd, true), d)
25}
26
27func (s *skillPinState) refresh(cwd string, d *skillsDialog) []string {
28 m := s.mark(cwd)
29 return s.applyRefresh(m, cwd, s.fetch(cwd, true), d)
30}
31
32func (s *skillPinState) handleKey(k tui.Key, cwd string, d *skillsDialog) (handled bool, statusOK, statusErr string, warnings []string) {
33 req, ok := s.toggleRequest(k, cwd, d)
34 if !ok {
35 return false, "", "", nil
36 }
37 m := s.mark(cwd)
38 f, err := s.runToggle(req)
39 statusOK, statusErr, warnings = s.completeToggle(m, cwd, f, err, d)
40 return true, statusOK, statusErr, warnings
41}
42
43// mutexProbe fails the test when a callback runs while i.mu is held.
44type mutexProbe struct {
45 t *testing.T
46 i *Interactive
47 mu sync.Mutex
48 calls map[string]int
49}
50
51func newMutexProbe(t *testing.T, i *Interactive) *mutexProbe {
52 return &mutexProbe{t: t, i: i, calls: map[string]int{}}
53}
54
55func (p *mutexProbe) hit(name string) {
56 p.t.Helper()
57 if !p.i.mu.TryLock() {
58 p.t.Errorf("%s ran while i.mu was held", name)
59 } else {
60 p.i.mu.Unlock()
61 }
62 p.mu.Lock()
63 p.calls[name]++
64 p.mu.Unlock()
65}
66
67func (p *mutexProbe) count(name string) int {
68 p.mu.Lock()
69 defer p.mu.Unlock()
70 return p.calls[name]
71}
72
73func (p *mutexProbe) reset() {
74 p.mu.Lock()
75 p.calls = map[string]int{}
76 p.mu.Unlock()
77}
78
79func newProbedInteractive(t *testing.T, preload bool) (*Interactive, *mutexProbe) {
80 t.Helper()
81 var i *Interactive
82 var probe *mutexProbe
83 pins := skills.Pins{Global: []string{"review"}}
84 i = NewInteractive(InteractiveConfig{
85 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
86 LoadSkillPins: func(string) (skills.Pins, error) {
87 if probe != nil {
88 probe.hit("load")
89 }
90 return pins, nil
91 },
92 ToggleSkillPin: func(_, name string, _ bool) error {
93 probe.hit("toggle")
94 pins = skills.Pins{Global: []string{name}}
95 return nil
96 },
97 SkillSnapshot: func() []*skills.Skill {
98 if probe != nil {
99 probe.hit("snapshot")
100 }
101 return reviewSnapshot()
102 },
103 PreloadPinnedSkills: preload,
104 })
105 probe = newMutexProbe(t, i)
106 return i, probe
107}
108
109func TestSkillPinCallbacksRunOutsideInteractiveMutex(t *testing.T) {
110 t.Run("startup arm", func(t *testing.T) {
111 i, p := newProbedInteractive(t, false)
112 i.SetPinnedSkillsPending(true)
113 if p.count("load") != 1 || p.count("snapshot") != 1 {
114 t.Fatalf("arm callbacks: %v", p.calls)
115 }
116 if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
117 t.Fatal("arm lost selection")
118 }
119 })
120 t.Run("clear", func(t *testing.T) {
121 i, p := newProbedInteractive(t, true)
122 i.consumePinnedSkills("first")
123 p.reset()
124 i.runSlash(context.Background(), "/clear")
125 if p.count("load") != 1 || p.count("snapshot") != 1 {
126 t.Fatalf("clear callbacks: %v", p.calls)
127 }
128 if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
129 t.Fatal("/clear did not re-arm")
130 }
131 })
132 t.Run("cd", func(t *testing.T) {
133 i, p := newProbedInteractive(t, true)
134 i.consumePinnedSkills("first")
135 p.reset()
136 i.ApplyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir())
137 if p.count("load") != 1 || p.count("snapshot") != 1 {
138 t.Fatalf("cd callbacks: %v", p.calls)
139 }
140 if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
141 t.Fatal("/cd did not re-arm")
142 }
143 })
144 t.Run("skills dialog and toggle", func(t *testing.T) {
145 i, p := newProbedInteractive(t, true)
146 p.reset()
147 i.openSkillsDialog()
148 if p.count("snapshot") != 1 || p.count("load") != 1 {
149 t.Fatalf("/skills must walk discovery once: %v", p.calls)
150 }
151 p.reset()
152 if !i.handleSkillPinKey(pinKey('p')) {
153 t.Fatal("pin key not handled")
154 }
155 if p.count("toggle") != 1 || p.count("load") != 1 || p.count("snapshot") != 0 {
156 t.Fatalf("toggle callbacks: %v", p.calls)
157 }
158 i.mu.Lock()
159 ok := i.statusOK
160 i.mu.Unlock()
161 if ok == "" {
162 t.Fatal("toggle status missing")
163 }
164 })
165 t.Run("startup pre", func(t *testing.T) {
166 i, p := newProbedInteractive(t, true)
167 p.reset()
168 i.applyStartupPreResult(startupPreResult{})
169 if p.count("load") != 1 || p.count("snapshot") != 1 {
170 t.Fatalf("startup callbacks: %v", p.calls)
171 }
172 i.consumePinnedSkills("first")
173 p.reset()
174 i.applyStartupPreResult(startupPreResult{})
175 if len(p.calls) != 0 {
176 t.Fatalf("consumed pins were refreshed: %v", p.calls)
177 }
178 })
179 t.Run("disarm", func(t *testing.T) {
180 i, p := newProbedInteractive(t, true)
181 p.reset()
182 i.SetPinnedSkillsPending(false)
183 i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m")
184 if len(p.calls) != 0 {
185 t.Fatalf("disarm did IO: %v", p.calls)
186 }
187 })
188}
189
190func TestSkillPinRecallAndDiscardDoNoIOAndRestoreExactSelection(t *testing.T) {
191 i, p := newProbedInteractive(t, true)
192 expanded := i.consumePinnedSkills("draft")
193 p.reset()
194 // Preferences and discovery change after consumption; recall must still
195 // restore exactly what was consumed, without reading either again.
196 i.mu.Lock()
197 i.skillPins.load = func(string) (skills.Pins, error) { t.Error("recall loaded pins"); return skills.Pins{}, nil }
198 i.skillPins.snapshot = func() []*skills.Skill { t.Error("recall walked discovery"); return nil }
199 i.mu.Unlock()
200
201 if !i.restoreQueuedMessageToEditor(core.QueuedMessage{Text: expanded}) {
202 t.Fatal("restore failed")
203 }
204 if i.ed.Value() != "draft" {
205 t.Fatalf("editor = %q", i.ed.Value())
206 }
207 i.mu.Lock()
208 notice := stripANSIBytes(strings.Join(i.skillPins.notice(tui.Theme{}, 80), ""))
209 i.mu.Unlock()
210 if !strings.Contains(notice, "review (global)") {
211 t.Fatalf("scope marker lost: %q", notice)
212 }
213 if next := i.consumePinnedSkills("draft"); next != expanded {
214 t.Fatalf("recalled selection differs:\n%q\n%q", next, expanded)
215 }
216
217 i.agent.QueuePrompt(core.QueuedMessage{Text: expanded})
218 i.mu.Lock()
219 i.discardQueuedMessagesLocked(false)
220 i.mu.Unlock()
221 if next := i.consumePinnedSkills("again"); strings.Count(next, "Inspect carefully.") != 1 {
222 t.Fatalf("discard lost pins: %q", next)
223 }
224 if len(p.calls) != 0 {
225 t.Fatalf("recall did IO: %v", p.calls)
226 }
227}
228
229func TestSkillPinNoPendingWithEmptySelectionPublished(t *testing.T) {
230 // A blocked prefetch must not expose pending with a stale/empty selection.
231 release := make(chan struct{})
232 entered := make(chan struct{}, 1)
233 i := NewInteractive(InteractiveConfig{
234 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
235 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
236 SkillSnapshot: func() []*skills.Skill {
237 select {
238 case entered <- struct{}{}:
239 default:
240 }
241 <-release
242 return reviewSnapshot()
243 },
244 })
245 done := make(chan struct{})
246 go func() { i.SetPinnedSkillsPending(true); close(done) }()
247 <-entered
248 i.mu.Lock()
249 pending, selected := i.skillPins.pending, len(i.skillPins.selected)
250 i.mu.Unlock()
251 if pending || selected != 0 {
252 t.Fatalf("published early: pending=%v selected=%d", pending, selected)
253 }
254 close(release)
255 <-done
256 i.mu.Lock()
257 pending, selected = i.skillPins.pending, len(i.skillPins.selected)
258 i.mu.Unlock()
259 if !pending || selected != 1 {
260 t.Fatalf("pending=%v selected=%d", pending, selected)
261 }
262}
263
264// blockingSnapshot lets a test hold one discovery walk open.
265type blockingSnapshot struct {
266 entered chan struct{}
267 release chan struct{}
268 block bool
269 empty bool // discovery finds nothing once released
270}
271
272func (b *blockingSnapshot) snapshot() []*skills.Skill {
273 if b.block {
274 b.block = false
275 b.entered <- struct{}{}
276 <-b.release
277 if b.empty {
278 return nil
279 }
280 }
281 return reviewSnapshot()
282}
283
284func TestStaleSkillPinArmDroppedAfterDisarmOrSessionReplacement(t *testing.T) {
285 for name, overtake := range map[string]func(*Interactive){
286 "disarm": func(i *Interactive) { i.SetPinnedSkillsPending(false) },
287 "session replacement": func(i *Interactive) { i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m") },
288 } {
289 t.Run(name, func(t *testing.T) {
290 b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{}), block: true}
291 i := NewInteractive(InteractiveConfig{
292 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
293 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
294 SkillSnapshot: b.snapshot,
295 })
296 done := make(chan struct{})
297 go func() { i.SetPinnedSkillsPending(true); close(done) }()
298 <-b.entered
299 // The overtaking transition must not wait for the blocked callback.
300 finished := make(chan struct{})
301 go func() { overtake(i); close(finished) }()
302 select {
303 case <-finished:
304 case <-time.After(2 * time.Second):
305 t.Fatal("transition blocked behind in-flight prefetch")
306 }
307 close(b.release)
308 <-done
309 if got := i.consumePinnedSkills("hello"); got != "hello" {
310 t.Fatalf("stale arm published pins: %q", got)
311 }
312 })
313 }
314}
315
316func TestStaleSkillPinRefreshDroppedAfterConsumeAndRecall(t *testing.T) {
317 b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})}
318 i := NewInteractive(InteractiveConfig{
319 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
320 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
321 SkillSnapshot: b.snapshot, PreloadPinnedSkills: true,
322 })
323 // Startup refresh blocked in discovery while the user submits, then the
324 // queued prompt is recalled. The exactly restored selection must survive
325 // the (now stale) refresh, which would otherwise resolve to nothing.
326 b.block = true
327 done := make(chan struct{})
328 go func() { i.refreshPendingSkillPins(); close(done) }()
329 <-b.entered
330 expanded := i.consumePinnedSkills("draft")
331 i.mu.Lock()
332 i.skillPins.recall(expanded)
333 i.mu.Unlock()
334 // The blocked discovery finds nothing: a published stale result would
335 // empty the restored selection.
336 b.empty = true
337 close(b.release)
338 <-done
339 if got := i.consumePinnedSkills("draft"); got != expanded {
340 t.Fatalf("stale refresh replaced restored selection: %q", got)
341 }
342}
343
344func TestStaleSkillPinPrefetchDroppedAfterCWDOrGenerationChange(t *testing.T) {
345 load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }
346 s := newSkillPinState(load, nil, reviewSnapshot)
347 s.armFetched(s.fetch("/a", true), nil)
348 before := s.selected
349
350 mark := s.mark("/a")
351 stale := skillPinFetch{pins: skills.Pins{Global: []string{"gone"}}}
352 if w := s.applyRefresh(mark, "/b", stale, nil); w != nil || s.pins.Global[0] != "review" || len(s.selected) != len(before) {
353 t.Fatalf("prefetch for another cwd published: pins=%+v warnings=%v", s.pins, w)
354 }
355 s.disarm()
356 s.armFetched(s.fetch("/a", true), nil)
357 s.applyRefresh(mark, "/a", stale, nil)
358 if s.pins.Global[0] != "review" || len(s.selected) != 1 {
359 t.Fatalf("prefetch from an older conversation published: %+v", s.pins)
360 }
361}
362
363func TestEmptySkillPinsSkipDiscovery(t *testing.T) {
364 loads := 0
365 i := NewInteractive(InteractiveConfig{
366 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true,
367 LoadSkillPins: func(string) (skills.Pins, error) { loads++; return skills.Pins{}, nil },
368 SkillSnapshot: func() []*skills.Skill { t.Fatal("empty pins triggered discovery"); return nil },
369 })
370 i.refreshPendingSkillPins()
371 i.runSlash(context.Background(), "/clear")
372 i.applyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir(), nil)
373 if loads < 4 {
374 t.Fatalf("pin preferences not refreshed at lifecycle boundaries: %d loads", loads)
375 }
376}
377
378func TestStaleSkillPinArmDroppedAfterConsumption(t *testing.T) {
379 b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})}
380 i := NewInteractive(InteractiveConfig{
381 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true,
382 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
383 SkillSnapshot: b.snapshot,
384 })
385 b.block = true
386 done := make(chan struct{})
387 go func() { i.SetPinnedSkillsPending(true); close(done) }()
388 <-b.entered
389 if got := i.consumePinnedSkills("first"); !strings.Contains(got, "Inspect carefully.") {
390 t.Fatalf("initial selection not consumed: %q", got)
391 }
392 close(b.release)
393 <-done
394 if got := i.consumePinnedSkills("second"); got != "second" {
395 t.Fatalf("stale rearm duplicated preload: %q", got)
396 }
397}
398
399func TestSkillPinToggleFailureKeepsStateAndAppliesNoStatusOK(t *testing.T) {
400 i := NewInteractive(InteractiveConfig{
401 Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
402 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{}, nil },
403 ToggleSkillPin: func(string, string, bool) error { return errors.New("read-only") },
404 SkillSnapshot: reviewSnapshot,
405 })
406 i.openSkillsDialog()
407 if !i.handleSkillPinKey(pinKey('g')) {
408 t.Fatal("not handled")
409 }
410 i.mu.Lock()
411 defer i.mu.Unlock()
412 if !strings.Contains(i.statusErr, "read-only") || i.statusOK != "" {
413 t.Fatalf("status ok=%q err=%q", i.statusOK, i.statusErr)
414 }
415}