1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/patriceckhart/zot/packages/core"
13 "github.com/patriceckhart/zot/packages/provider"
14 "github.com/patriceckhart/zot/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 main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
132 policyCalls.Add(1)
133 return true, "", nil
134 }
135
136 d := newBtwDialog()
137 d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
138
139 select {
140 case <-client.done:
141 case <-time.After(2 * time.Second):
142 t.Fatal("timed out waiting for /btw tool turn")
143 }
144 deadline := time.Now().Add(2 * time.Second)
145 for d.Loading() && time.Now().Before(deadline) {
146 time.Sleep(time.Millisecond)
147 }
148 if d.Loading() {
149 t.Fatal("/btw remained loading after the agent turn completed")
150 }
151
152 if got := tool.runs.Load(); got != 1 {
153 t.Fatalf("tool executions = %d, want 1", got)
154 }
155 if got := policyCalls.Load(); got != 1 {
156 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157 }
158
159 client.mu.Lock()
160 requests := append([]provider.Request(nil), client.requests...)
161 client.mu.Unlock()
162 if len(requests) != 2 {
163 t.Fatalf("provider requests = %d, want 2", len(requests))
164 }
165 if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
166 t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
167 }
168 if got := len(main.Messages()); got != 1 {
169 t.Fatalf("main transcript messages = %d, want frozen length 1", got)
170 }
171
172 d.mu.Lock()
173 if len(d.turns) != 1 {
174 t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
175 }
176 turn := d.turns[0]
177 if turn.Assistant != "tool finished" {
178 t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
179 }
180 if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
181 t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
182 }
183 d.mu.Unlock()
184
185 rows := d.Render(tui.Theme{}, 80)
186 rendered := strings.Join(rows, "\n")
187 if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
188 t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
189 }
190 toolRow := -1
191 for idx, row := range rows {
192 if strings.Contains(row, "┌─") {
193 toolRow = idx
194 break
195 }
196 }
197 if toolRow < 1 || rows[toolRow-1] != "" {
198 t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
199 }
200 if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 {
201 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202 }
203}