v0.51.0 a9d674e

skill_pins_lock_test.go RawHistoryFolder

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}

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