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.streamFlushPending && len(i.streamPending) == 0
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.streamPending = []rune("final answer")
170 i.streamFlushPending = true
171 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172 ctx, cancel := context.WithCancel(context.Background())
173 defer cancel()
174 go i.runStreamPacer(ctx)
175
176 deadline := time.Now().Add(time.Second)
177 for time.Now().Before(deadline) {
178 i.mu.Lock()
179 ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
180 i.mu.Unlock()
181 if ready {
182 i.redraw()
183 break
184 }
185 time.Sleep(time.Millisecond)
186 }
187 if got := term.String(); got != "\a" {
188 t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189 }
190 i.mu.Lock()
191 on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
192 i.mu.Unlock()
193 if on || pending != 0 || alert != nil {
194 t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
195 }
196}
197
198func TestMainAlertReasonPolicy(t *testing.T) {
199 cancelled, cancel := context.WithCancel(context.Background())
200 cancel()
201
202 tests := []struct {
203 name string
204 ctx context.Context
205 err error
206 turnErr error
207 stop provider.StopReason
208 awaiting bool
209 queued bool
210 rescue bool
211 recovering bool
212 auto bool
213 want string
214 }{
215 {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
216 {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
217 {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
218 {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
219 {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
220 {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
221 {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
222 {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
223 {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
224 {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
225 {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
226 }
227 for _, tt := range tests {
228 t.Run(tt.name, func(t *testing.T) {
229 got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
230 if got != tt.want {
231 t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
232 }
233 })
234 }
235}
236
237func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
238 i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
239 i.openSettingsDialog()
240 var item *settingsItem
241 for idx := range i.settingsDialog.items {
242 if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
243 item = &i.settingsDialog.items[idx]
244 break
245 }
246 }
247 if item == nil || !item.value {
248 t.Fatal("terminal alerts setting should be present and enabled by default")
249 }
250
251 i.applySettingToggle("terminal_alerts_enabled", false)
252 if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
253 t.Fatal("terminal alerts setting did not disable")
254 }
255 i.applySettingToggle("terminal_alerts_enabled", true)
256 if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
257 t.Fatal("terminal alerts setting did not re-enable")
258 }
259}