v0.50.1 a637010

skill_pins_turn_test.go RawHistoryFolder

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}

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 477

↑↓ moveenter openesc close