v0.50.1 a637010

btw_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"slices"
7	"strings"
8	"sync"
9	"sync/atomic"
10	"testing"
11	"time"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15	"github.com/bnema/zut/packages/tui"
16)
17
18type btwToolTestClient struct {
19	mu       sync.Mutex
20	requests []provider.Request
21	done     chan struct{}
22}
23
24func (c *btwToolTestClient) Name() string { return "btw-test" }
25
26func (c *btwToolTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
27	c.mu.Lock()
28	c.requests = append(c.requests, req)
29	call := len(c.requests)
30	c.mu.Unlock()
31
32	out := make(chan provider.Event, 4)
33	go func() {
34		defer close(out)
35		if call == 1 {
36			out <- provider.EventToolStart{ID: "tool-1", Name: "echo"}
37			out <- provider.EventToolArgs{ID: "tool-1", Delta: `{"text":"hello"}`}
38			out <- provider.EventToolEnd{ID: "tool-1"}
39			out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
40				Role: provider.RoleAssistant,
41				Content: []provider.Content{provider.ToolCallBlock{
42					ID: "tool-1", Name: "echo", Arguments: json.RawMessage(`{"text":"hello"}`),
43				}},
44			}}
45			return
46		}
47		out <- provider.EventTextDelta{Delta: "tool finished"}
48		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
49			Role: provider.RoleAssistant,
50			Content: []provider.Content{
51				provider.TextBlock{Text: "tool finished"},
52			},
53		}}
54		close(c.done)
55	}()
56	return out, nil
57}
58
59type btwEchoTool struct {
60	runs atomic.Int32
61}
62
63func (t *btwEchoTool) Name() string            { return "echo" }
64func (t *btwEchoTool) Description() string     { return "echo text" }
65func (t *btwEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
66func (t *btwEchoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
67	t.runs.Add(1)
68	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "hello"}}}, nil
69}
70
71func TestBtwToolExpansionUsesSideChatView(t *testing.T) {
72	result := strings.Repeat("output line\n", tui.ToolCollapseLines+10)
73	d := &btwDialog{
74		active:   true,
75		toolView: &tui.View{Theme: tui.Dark},
76		turns: []btwTurn{{
77			User: "show output",
78			Tools: []tui.ToolCallView{{
79				ID: "tool-1", Name: "bash", Result: result, Done: true,
80			}},
81		}},
82		editor: tui.NewEditor(""),
83	}
84
85	if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
86		t.Fatalf("long /btw tool result was not initially collapsed:\n%s", rendered)
87	}
88	d.ToggleToolExpansion()
89	if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
90		t.Fatalf("long /btw tool result remained collapsed after expansion:\n%s", rendered)
91	}
92}
93
94func TestBtwCtrlOTogglesSideViewDuringConfirmation(t *testing.T) {
95	resp := make(chan core.ConfirmDecision, 1)
96	confirm := newConfirmDialog()
97	confirm.Enqueue(&confirmRequest{toolName: "bash", resp: resp})
98	btw := &btwDialog{active: true, toolView: &tui.View{}}
99	i := &Interactive{
100		view:          &tui.View{},
101		btwDialog:     btw,
102		confirmDialog: confirm,
103		dirty:         make(chan struct{}, 1),
104	}
105
106	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
107	if !btw.toolView.ExpandAll {
108		t.Fatal("ctrl+o did not expand /btw tool output during confirmation")
109	}
110	if i.view.ExpandAll {
111		t.Fatal("ctrl+o expanded the main view instead of the active /btw view")
112	}
113	if !confirm.Active() {
114		t.Fatal("ctrl+o closed the confirmation dialog")
115	}
116	select {
117	case decision := <-resp:
118		t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
119	default:
120	}
121}
122
123func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) {
124	client := &btwToolTestClient{done: make(chan struct{})}
125	tool := &btwEchoTool{}
126	main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
127	main.SetMessages([]provider.Message{{
128		Role:    provider.RoleUser,
129		Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
130	}})
131	var policyCalls atomic.Int32
132	var policyOrigin string
133	main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
134		policyCalls.Add(1)
135		policyOrigin = call.Origin
136		return true, "", nil
137	}
138
139	d := newBtwDialog()
140	d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
141
142	select {
143	case <-client.done:
144	case <-time.After(2 * time.Second):
145		t.Fatal("timed out waiting for /btw tool turn")
146	}
147	deadline := time.Now().Add(2 * time.Second)
148	for d.Loading() && time.Now().Before(deadline) {
149		time.Sleep(time.Millisecond)
150	}
151	if d.Loading() {
152		t.Fatal("/btw remained loading after the agent turn completed")
153	}
154
155	if got := tool.runs.Load(); got != 1 {
156		t.Fatalf("tool executions = %d, want 1", got)
157	}
158	if got := policyCalls.Load(); got != 1 {
159		t.Fatalf("main agent tool-policy calls = %d, want 1", got)
160	}
161	if policyOrigin != btwOrigin(1) {
162		t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
163	}
164
165	client.mu.Lock()
166	requests := append([]provider.Request(nil), client.requests...)
167	client.mu.Unlock()
168	if len(requests) != 2 {
169		t.Fatalf("provider requests = %d, want 2", len(requests))
170	}
171	if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
172		t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
173	}
174	if got := len(main.Messages()); got != 1 {
175		t.Fatalf("main transcript messages = %d, want frozen length 1", got)
176	}
177
178	d.mu.Lock()
179	if len(d.turns) != 1 {
180		t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
181	}
182	turn := d.turns[0]
183	if turn.Assistant != "tool finished" {
184		t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
185	}
186	if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
187		t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
188	}
189	d.mu.Unlock()
190
191	const paneWidth = 62
192	rows := d.Render(tui.Theme{}, paneWidth)
193	rendered := strings.Join(rows, "\n")
194	if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
195		t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
196	}
197	toolRow := -1
198	for idx, row := range rows {
199		if strings.Contains(row, "┌─") {
200			toolRow = idx
201			break
202		}
203	}
204	if toolRow < 1 || rows[toolRow-1] != "" {
205		t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
206	}
207	if cursorRow, _ := d.CursorPos(paneWidth); cursorRow != len(rows)-3 {
208		t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
209	}
210}
211
212func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
213	d := &btwDialog{
214		active:   true,
215		loading:  true,
216		theme:    tui.Dark,
217		toolView: &tui.View{Theme: tui.Dark},
218		spin:     newSpinner(tui.Dark),
219		activity: newAgentActivity("anthropic", "claude-test"),
220	}
221	d.spin.Start()
222	d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
223
224	rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
225	if !strings.Contains(rendered, "Sending request to anthropic") {
226		t.Fatalf("/btw activity = %q, want factual request label", rendered)
227	}
228	if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
229		t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
230	}
231}
232
233func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
234	d := &btwDialog{
235		active:     true,
236		loading:    true,
237		generation: 2,
238		turns:      []btwTurn{{User: "new question"}},
239		activity:   newAgentActivity("provider", "model"),
240	}
241
242	d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
243	d.completeTurn(0, 1, "stale error")
244
245	d.mu.Lock()
246	defer d.mu.Unlock()
247	if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
248		t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
249	}
250	if !d.loading {
251		t.Fatal("stale completion cleared the reopened dialog loading state")
252	}
253}
254
255func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
256	client := &btwToolTestClient{done: make(chan struct{})}
257	tool := &btwEchoTool{}
258	main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
259	d := newBtwDialog()
260	i := &Interactive{
261		btwDialog:     d,
262		confirmDialog: newConfirmDialog(),
263		dirty:         make(chan struct{}, 1),
264	}
265	gate := core.NewConfirmGate(i)
266	mainResp := make(chan core.ConfirmDecision, 1)
267	i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
268	i.confirmDialog.Blur()
269	main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
270		return gate.CheckToolCall(core.ToolCallConfirmation{
271			ID:      call.ID,
272			Name:    call.Name,
273			Summary: "hello",
274			Origin:  call.Origin,
275		})
276	}
277
278	d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
279	sideConfirmationFirst := func() bool {
280		i.confirmDialog.mu.Lock()
281		defer i.confirmDialog.mu.Unlock()
282		return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
283	}
284	deadline := time.Now().Add(2 * time.Second)
285	for !sideConfirmationFirst() && time.Now().Before(deadline) {
286		time.Sleep(time.Millisecond)
287	}
288	if !sideConfirmationFirst() {
289		t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
290	}
291	if !i.confirmDialog.Focused() {
292		t.Fatal("side-agent confirmation did not focus the prioritized dialog")
293	}
294
295	d.mu.Lock()
296	if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
297		d.mu.Unlock()
298		t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
299	}
300	if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
301		d.mu.Unlock()
302		t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
303	}
304	if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
305		d.mu.Unlock()
306		t.Fatalf("side-agent activity = %q, want %q", got, want)
307	}
308	d.mu.Unlock()
309
310	i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
311	select {
312	case <-client.done:
313	case <-time.After(2 * time.Second):
314		t.Fatal("side-agent did not continue after confirmation")
315	}
316	if got := tool.runs.Load(); got != 1 {
317		t.Fatalf("tool executions = %d, want 1 after confirmation", got)
318	}
319	if i.confirmDialog.Focused() {
320		t.Fatal("main confirmation regained focus instead of returning input to /btw")
321	}
322	i.confirmDialog.CancelAll("test complete")
323	select {
324	case <-mainResp:
325	default:
326		t.Fatal("main confirmation was not retained after resolving the side-chat call")
327	}
328}
329
330// TestNewBtwAgentCarriesMainPlan guards GO-003: the /btw side agent shares the
331// main registry (which contains plan) and the main transcript, so it must also
332// carry the main plan. Otherwise `show` answers empty and an indexed update
333// fails on a checklist the side transcript displays.
334func TestNewBtwAgentCarriesMainPlan(t *testing.T) {
335	main := core.NewAgent(nil, "model", "system", nil)
336	want := []core.PlanStep{
337		{Step: "one", Status: core.PlanPending},
338		{Step: "two", Status: core.PlanInProgress},
339	}
340	main.SetPlan(want)
341
342	side := newBtwAgent(main, "system", "model", "btw")
343	if got := side.CurrentPlan(); !slices.Equal(got, want) {
344		t.Fatalf("side agent plan = %#v, want %#v", got, want)
345	}
346
347	// The side plan must be an independent copy.
348	got := side.CurrentPlan()
349	got[0].Step = "mutated"
350	if again := main.CurrentPlan(); again[0].Step != "one" {
351		t.Fatalf("side plan aliases the main plan: %#v", again)
352	}
353}

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