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