1package modes
2
3import (
4 "context"
5 "errors"
6 "io"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/extproto"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14 "github.com/bnema/zut/packages/tui"
15)
16
17type alertTestTerminal struct {
18 mu sync.Mutex
19 data []byte
20}
21
22func (t *alertTestTerminal) Write(p []byte) (int, error) {
23 t.mu.Lock()
24 defer t.mu.Unlock()
25 t.data = append(t.data, p...)
26 return len(p), nil
27}
28func (t *alertTestTerminal) WriteString(s string) (int, error) {
29 t.mu.Lock()
30 defer t.mu.Unlock()
31 t.data = append(t.data, s...)
32 return len(s), nil
33}
34func (t *alertTestTerminal) String() string {
35 t.mu.Lock()
36 defer t.mu.Unlock()
37 return string(t.data)
38}
39
40func (*alertTestTerminal) Size() (int, int) { return 80, 24 }
41func (*alertTestTerminal) OnResize(func()) {}
42func (*alertTestTerminal) EnterRaw() (func() error, error) {
43 return func() error { return nil }, nil
44}
45func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
46func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
47 return 0, false, nil
48}
49func (*alertTestTerminal) SetNonblock(bool) error { return nil }
50
51func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) {
52 term := &alertTestTerminal{}
53 i := &Interactive{cfg: InteractiveConfig{Terminal: term}}
54
55 i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
56 if got := term.String(); got != "\a" {
57 t.Fatalf("alert output = %q, want standalone BEL", got)
58 }
59}
60
61func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) {
62 term := &alertTestTerminal{}
63 disabled := false
64 i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}}
65
66 i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
67 if got := term.String(); got != "" {
68 t.Fatalf("disabled alert output = %q, want empty", got)
69 }
70}
71
72type alertBlockingClient struct{}
73
74func (alertBlockingClient) Name() string { return "alert-blocking" }
75func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
76 out := make(chan provider.Event, 1)
77 go func() {
78 defer close(out)
79 <-ctx.Done()
80 out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
81 }()
82 return out, nil
83}
84
85func TestCancelledMainTurnDoesNotBell(t *testing.T) {
86 term := &alertTestTerminal{}
87 ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
88 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
89 i.startTurn(context.Background(), "question")
90
91 deadline := time.Now().Add(time.Second)
92 started := false
93 for time.Now().Before(deadline) {
94 i.mu.Lock()
95 busy := i.busy
96 i.mu.Unlock()
97 if busy {
98 started = true
99 break
100 }
101 time.Sleep(time.Millisecond)
102 }
103 if !started {
104 t.Fatal("turn did not enter busy state")
105 }
106 i.CancelTurn()
107
108 deadline = time.Now().Add(time.Second)
109 stopped := false
110 for time.Now().Before(deadline) {
111 i.mu.Lock()
112 busy := i.busy
113 i.mu.Unlock()
114 if !busy {
115 stopped = true
116 break
117 }
118 time.Sleep(time.Millisecond)
119 }
120 if !stopped {
121 t.Fatal("turn did not finish after cancellation")
122 }
123 if got := term.String(); got != "" {
124 t.Fatalf("cancelled main turn alert output = %q, want empty", got)
125 }
126}
127
128func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
129 term := &alertTestTerminal{}
130 client := &streamTestClient{events: []provider.Event{
131 provider.EventTextDelta{Delta: "answer"},
132 provider.EventDone{
133 Stop: provider.StopEnd,
134 Message: provider.Message{
135 Role: provider.RoleAssistant,
136 Content: []provider.Content{provider.TextBlock{Text: "answer"}},
137 },
138 },
139 }}
140 ag := core.NewAgent(client, "test-model", "", nil)
141 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
142 i.rend = tui.NewRenderer(io.Discard)
143 ctx, cancel := context.WithCancel(context.Background())
144 defer cancel()
145 i.runCtx = ctx
146 go i.runStreamPacer(ctx)
147 i.startTurn(ctx, "question")
148
149 deadline := time.Now().Add(time.Second)
150 for time.Now().Before(deadline) {
151 i.mu.Lock()
152 ready := i.pendingAlert != nil && !i.stream.Active()
153 i.mu.Unlock()
154 if ready {
155 i.redraw()
156 break
157 }
158 time.Sleep(time.Millisecond)
159 }
160 if got := term.String(); got != "\a" {
161 t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
162 }
163}
164
165func TestMainAlertWaitsForPacedText(t *testing.T) {
166 term := &alertTestTerminal{}
167 i := NewInteractive(InteractiveConfig{Terminal: term})
168 i.rend = tui.NewRenderer(io.Discard)
169 i.stream.Start(-1, 0)
170 i.stream.Push("final answer")
171 i.stream.Finish()
172 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
173 ctx, cancel := context.WithCancel(context.Background())
174 defer cancel()
175 go i.runStreamPacer(ctx)
176
177 deadline := time.Now().Add(time.Second)
178 for time.Now().Before(deadline) {
179 i.mu.Lock()
180 ready := !i.stream.Active() && i.pendingAlert != nil
181 i.mu.Unlock()
182 if ready {
183 i.redraw()
184 break
185 }
186 time.Sleep(time.Millisecond)
187 }
188 if got := term.String(); got != "\a" {
189 t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
190 }
191 i.mu.Lock()
192 on, alert := i.stream.Active(), i.pendingAlert
193 i.mu.Unlock()
194 if on || alert != nil {
195 t.Fatalf("stream state after alert: on=%v alert=%v", on, alert)
196 }
197}
198
199func TestMainAlertReasonPolicy(t *testing.T) {
200 cancelled, cancel := context.WithCancel(context.Background())
201 cancel()
202
203 tests := []struct {
204 name string
205 ctx context.Context
206 err error
207 turnErr error
208 stop provider.StopReason
209 awaiting bool
210 queued bool
211 rescue bool
212 recovering bool
213 auto bool
214 want string
215 }{
216 {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
217 {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
218 {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
219 {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
220 {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
221 {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
222 {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
223 {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
224 {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
225 {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
226 {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
227 }
228 for _, tt := range tests {
229 t.Run(tt.name, func(t *testing.T) {
230 got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
231 if got != tt.want {
232 t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
233 }
234 })
235 }
236}
237
238func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
239 i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
240 i.openSettingsDialog()
241 var item *settingsItem
242 for idx := range i.settingsDialog.items {
243 if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
244 item = &i.settingsDialog.items[idx]
245 break
246 }
247 }
248 if item == nil || !item.value {
249 t.Fatal("terminal alerts setting should be present and enabled by default")
250 }
251
252 i.applySettingToggle("terminal_alerts_enabled", false)
253 if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
254 t.Fatal("terminal alerts setting did not disable")
255 }
256 i.applySettingToggle("terminal_alerts_enabled", true)
257 if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
258 t.Fatal("terminal alerts setting did not re-enable")
259 }
260}