返回 DeepSeek-Reasonix
dispatch_test.go
根目录 / internal / acp / dispatch_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11 "unicode/utf8"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 )
18
19 // fakeNotifier captures Notify calls and answers Request via an injectable hook,
20 // standing in for *Conn in adapter unit tests.
21 type fakeNotifier struct {
22 mu sync.Mutex
23 notifs []capturedNotif
24 onReq func(method string, params any) (json.RawMessage, error)
25 onReqCtx func(ctx context.Context, method string, params any) (json.RawMessage, error)
26 reqSeen []capturedNotif
27 }
28
29 type capturedNotif struct {
30 method string
31 params any
32 }
33
34 func (f *fakeNotifier) Notify(method string, params any) error {
35 f.mu.Lock()
36 defer f.mu.Unlock()
37 f.notifs = append(f.notifs, capturedNotif{method, params})
38 return nil
39 }
40
41 func (f *fakeNotifier) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
42 f.mu.Lock()
43 f.reqSeen = append(f.reqSeen, capturedNotif{method, params})
44 f.mu.Unlock()
45 if f.onReqCtx != nil {
46 return f.onReqCtx(ctx, method, params)
47 }
48 if f.onReq != nil {
49 return f.onReq(method, params)
50 }
51 return nil, nil
52 }
53
54 // updateMap marshals the i-th captured notification's params and decodes the
55 // nested "update" object into a generic map for shape assertions.
56 func (f *fakeNotifier) updateMap(t *testing.T, i int) map[string]any {
57 t.Helper()
58 f.mu.Lock()
59 defer f.mu.Unlock()
60 if i >= len(f.notifs) {
61 t.Fatalf("only %d notifications captured, wanted index %d", len(f.notifs), i)
62 }
63 n := f.notifs[i]
64 if n.method != "session/update" {
65 t.Fatalf("notif %d method = %q, want session/update", i, n.method)
66 }
67 raw, err := json.Marshal(n.params)
68 if err != nil {
69 t.Fatalf("marshal params: %v", err)
70 }
71 var decoded struct {
72 SessionID string `json:"sessionId"`
73 Update map[string]any `json:"update"`
74 }
75 if err := json.Unmarshal(raw, &decoded); err != nil {
76 t.Fatalf("unmarshal params: %v", err)
77 }
78 if decoded.SessionID != "sess-1" {
79 t.Errorf("notif %d sessionId = %q, want sess-1", i, decoded.SessionID)
80 }
81 return decoded.Update
82 }
83
84 func TestUpdateSinkReplayStripsSteerWrapper(t *testing.T) {
85 fn := &fakeNotifier{}
86 sink := newUpdateSink(fn, "sess-1")
87 sink.replay([]provider.Message{{
88 Role: provider.RoleUser,
89 Content: agent.MidTurnSteerPrefix + "\nuse plan B",
90 }})
91
92 u := fn.updateMap(t, 0)
93 content, _ := u["content"].(map[string]any)
94 if content["text"] != "use plan B" {
95 t.Fatalf("replayed steer = %v, want raw user text", content["text"])
96 }
97 }
98
99 func TestUpdateSinkMapsEvents(t *testing.T) {
100 fn := &fakeNotifier{}
101 sink := newUpdateSink(fn, "sess-1")
102
103 sink.Emit(event.Event{Kind: event.Reasoning, Text: "thinking..."})
104 sink.Emit(event.Event{Kind: event.Text, Text: "answer"})
105 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
106 ID: "call-1", Name: "read_file", Args: `{"path":"a.go"}`, ReadOnly: true,
107 }})
108 sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
109 ID: "call-1", Name: "read_file", Output: "package main",
110 }})
111 sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
112 ID: "call-2", Name: "bash", Err: "permission denied",
113 }})
114
115 if got := len(fn.notifs); got != 5 {
116 t.Fatalf("emitted %d notifications, want 5", got)
117 }
118
119 // agent_thought_chunk
120 u := fn.updateMap(t, 0)
121 if u["sessionUpdate"] != "agent_thought_chunk" {
122 t.Errorf("update 0 = %v, want agent_thought_chunk", u["sessionUpdate"])
123 }
124 if content, _ := u["content"].(map[string]any); content["text"] != "thinking..." {
125 t.Errorf("update 0 content text = %v", content)
126 }
127
128 // agent_message_chunk
129 u = fn.updateMap(t, 1)
130 if u["sessionUpdate"] != "agent_message_chunk" {
131 t.Errorf("update 1 = %v, want agent_message_chunk", u["sessionUpdate"])
132 }
133
134 // tool_call (pending, with kind + rawInput)
135 u = fn.updateMap(t, 2)
136 if u["sessionUpdate"] != "tool_call" || u["status"] != "pending" {
137 t.Errorf("update 2 = %v", u)
138 }
139 if u["kind"] != "read" {
140 t.Errorf("update 2 kind = %v, want read", u["kind"])
141 }
142 if u["toolCallId"] != "call-1" {
143 t.Errorf("update 2 toolCallId = %v, want call-1", u["toolCallId"])
144 }
145 if ri, _ := u["rawInput"].(map[string]any); ri["path"] != "a.go" {
146 t.Errorf("update 2 rawInput = %v", u["rawInput"])
147 }
148
149 // tool_call_update completed
150 u = fn.updateMap(t, 3)
151 if u["sessionUpdate"] != "tool_call_update" || u["status"] != "completed" {
152 t.Errorf("update 3 = %v", u)
153 }
154
155 // tool_call_update failed surfaces the error text
156 u = fn.updateMap(t, 4)
157 if u["status"] != "failed" {
158 t.Errorf("update 4 status = %v, want failed", u["status"])
159 }
160 arr, _ := u["content"].([]any)
161 if len(arr) != 1 {
162 t.Fatalf("update 4 content = %v", u["content"])
163 }
164 wrap, _ := arr[0].(map[string]any)
165 inner, _ := wrap["content"].(map[string]any)
166 if inner["text"] != "permission denied" {
167 t.Errorf("update 4 inner text = %v, want permission denied", inner["text"])
168 }
169 }
170
171 func TestUpdateSinkDropsAndWarns(t *testing.T) {
172 fn := &fakeNotifier{}
173 sink := newUpdateSink(fn, "sess-1")
174
175 // Dropped kinds: TurnStarted, Message, Usage, Phase, and empty deltas.
176 sink.Emit(event.Event{Kind: event.TurnStarted})
177 sink.Emit(event.Event{Kind: event.Message, Text: "full", Reasoning: "chain"})
178 sink.Emit(event.Event{Kind: event.Usage})
179 sink.Emit(event.Event{Kind: event.Phase, Text: "planning"})
180 sink.Emit(event.Event{Kind: event.Text, Text: ""})
181 if got := len(fn.notifs); got != 0 {
182 t.Fatalf("dropped kinds produced %d notifications, want 0", got)
183 }
184
185 // Warn-level notices are surfaced as a message chunk; info notices are not.
186 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "fyi"})
187 if got := len(fn.notifs); got != 0 {
188 t.Fatalf("info notice produced %d notifications, want 0", got)
189 }
190 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "watch out"})
191 if got := len(fn.notifs); got != 1 {
192 t.Fatalf("warn notice produced %d notifications, want 1", got)
193 }
194 u := fn.updateMap(t, 0)
195 if u["sessionUpdate"] != "agent_message_chunk" {
196 t.Errorf("warn update = %v", u["sessionUpdate"])
197 }
198 if c, _ := u["content"].(map[string]any); !strings.Contains(c["text"].(string), "watch out") {
199 t.Errorf("warn content = %v", u["content"])
200 }
201 }
202
203 // approveCall records one approve(id, allow, session, persist) callback.
204 type approveCall struct {
205 id string
206 allow bool
207 session bool
208 persist bool
209 }
210
211 func invalidACPv1PermissionOptionKind(options []PermissionOption) (PermissionOption, bool) {
212 // ACP v1 schema only accepts these four PermissionOptionKind values. ACP hosts
213 // own cross-session persistence, so Reasonix-specific persistent approvals must
214 // not appear in session/request_permission options.
215 valid := map[PermissionOptionKind]bool{
216 OptAllowOnce: true,
217 OptAllowAlways: true,
218 OptRejectOnce: true,
219 OptRejectAlways: true,
220 }
221 for _, opt := range options {
222 if !valid[opt.Kind] {
223 return opt, true
224 }
225 }
226 return PermissionOption{}, false
227 }
228
229 func assertACPv1PermissionOptionKinds(t *testing.T, options []PermissionOption) {
230 t.Helper()
231 if opt, ok := invalidACPv1PermissionOptionKind(options); ok {
232 t.Fatalf("permission option %q uses non-ACP-v1 kind %q", opt.OptionID, opt.Kind)
233 }
234 }
235
236 func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
237 fn := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
238 if method != "session/request_permission" {
239 t.Errorf("request method = %q, want session/request_permission", method)
240 }
241 raw, _ := json.Marshal(params)
242 var p PermissionRequestParams
243 if err := json.Unmarshal(raw, &p); err != nil {
244 t.Fatalf("permission params: %v", err)
245 }
246 if p.SessionID != "sess-1" {
247 t.Errorf("sessionId = %q", p.SessionID)
248 }
249 if p.ToolCall.Kind != "execute" {
250 t.Errorf("kind = %q, want execute", p.ToolCall.Kind)
251 }
252 if p.ToolCall.ToolCallID != "gate-9" {
253 t.Errorf("toolCallId = %q, want gate-9", p.ToolCall.ToolCallID)
254 }
255 assertACPv1PermissionOptionKinds(t, p.Options)
256 res, _ := json.Marshal(PermissionRequestResult{
257 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
258 })
259 return res, nil
260 }}
261 sink := newUpdateSink(fn, "sess-1")
262 got := make(chan approveCall, 1)
263 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
264
265 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "9", Tool: "bash", Subject: "rm -rf /"}})
266
267 select {
268 case c := <-got:
269 if c != (approveCall{id: "9", allow: true, session: true, persist: false}) {
270 t.Errorf("approve = %+v, want {9 true true}", c)
271 }
272 case <-time.After(2 * time.Second):
273 t.Fatal("approve was never called")
274 }
275 }
276
277 func TestUpdateSinkPermissionCarriesStructuredContext(t *testing.T) {
278 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
279 raw, _ := json.Marshal(params)
280 var p PermissionRequestParams
281 if err := json.Unmarshal(raw, &p); err != nil {
282 t.Fatalf("permission params: %v", err)
283 }
284 if string(p.ToolCall.RawInput) != `{"path":"src/main.go","content":"next"}` {
285 t.Fatalf("rawInput = %s", p.ToolCall.RawInput)
286 }
287 if len(p.ToolCall.Locations) != 1 || !strings.HasSuffix(filepath.ToSlash(p.ToolCall.Locations[0].Path), "/src/main.go") {
288 t.Fatalf("locations = %+v", p.ToolCall.Locations)
289 }
290 meta, ok := p.ToolCall.Meta["reasonix.io"].(map[string]any)
291 if !ok || meta["tool"] != "write_file" || meta["approvalId"] != "structured" || meta["reason"] != "write requested by the active goal" {
292 t.Fatalf("metadata = %#v", p.ToolCall.Meta)
293 }
294 var wire map[string]any
295 if err := json.Unmarshal(raw, &wire); err != nil {
296 t.Fatalf("permission wire shape: %v", err)
297 }
298 toolCall, ok := wire["toolCall"].(map[string]any)
299 if !ok {
300 t.Fatalf("toolCall wire shape = %#v", wire["toolCall"])
301 }
302 if _, present := toolCall["reason"]; present {
303 t.Fatalf("ACP v1 toolCall has non-standard root reason: %#v", toolCall)
304 }
305 res, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptRejectOnce)}})
306 return res, nil
307 }}
308 sink := newUpdateSink(fn, "sess-structured")
309 sink.bindCwd(t.TempDir())
310 got := make(chan approveCall, 1)
311 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
312 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
313 ID: "structured", Tool: "write_file", Subject: "src/main.go",
314 Reason: "write requested by the active goal",
315 RawInput: json.RawMessage(`{"path":"src/main.go","content":"next"}`),
316 }})
317 select {
318 case decision := <-got:
319 if decision.allow {
320 t.Fatalf("rejected permission was allowed: %+v", decision)
321 }
322 case <-time.After(2 * time.Second):
323 t.Fatal("permission was never resolved")
324 }
325 }
326
327 func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
328 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
329 raw, _ := json.Marshal(params)
330 var p PermissionRequestParams
331 if err := json.Unmarshal(raw, &p); err != nil {
332 t.Fatalf("permission params: %v", err)
333 }
334 // ACP permission options stay within the official spec kinds, and ACP
335 // mode leaves cross-session persistence to the host.
336 assertACPv1PermissionOptionKinds(t, p.Options)
337 var hasOnce, hasSession, hasReject bool
338 for _, opt := range p.Options {
339 switch opt.OptionID {
340 case string(OptAllowOnce):
341 hasOnce = opt.Kind == OptAllowOnce
342 case string(OptAllowAlways):
343 hasSession = opt.Kind == OptAllowAlways
344 case string(OptRejectOnce):
345 hasReject = opt.Kind == OptRejectOnce
346 default:
347 t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
348 }
349 }
350 if !hasOnce || !hasSession || !hasReject {
351 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
352 }
353 if len(p.Options) != 3 {
354 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
355 }
356 res, _ := json.Marshal(PermissionRequestResult{
357 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
358 })
359 return res, nil
360 }}
361 sink := newUpdateSink(fn, "sess-1")
362 got := make(chan approveCall, 1)
363 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
364
365 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "10", Tool: "bash", Subject: "go test ./..."}})
366
367 select {
368 case c := <-got:
369 want := approveCall{id: "10", allow: true, session: true, persist: false}
370 if c != want {
371 t.Errorf("approve = %+v, want %+v", c, want)
372 }
373 case <-time.After(2 * time.Second):
374 t.Fatal("approve was never called")
375 }
376 }
377
378 func TestPermissionMetaOnlyTrustsForegroundStaticBash(t *testing.T) {
379 cwd := t.TempDir()
380 sink := newUpdateSink(&fakeNotifier{}, "sess-static-command")
381 sink.bindCwd(cwd)
382
383 for _, tc := range []struct {
384 name string
385 rawInput string
386 wantArgv []string
387 }{
388 {name: "static", rawInput: `{"command":"go test ./..."}`, wantArgv: []string{"go", "test", "./..."}},
389 {name: "quoted static", rawInput: `{"command":"node -e 'process.exit(0)'"}`, wantArgv: []string{"node", "-e", "process.exit(0)"}},
390 {name: "expansion", rawInput: `{"command":"go test $PACKAGE"}`},
391 {name: "glob expansion", rawInput: `{"command":"go test ./*.go"}`},
392 {name: "brace expansion", rawInput: `{"command":"printf '%s' {a,b}"}`},
393 {name: "tilde expansion", rawInput: `{"command":"test -f ~/.config/reasonix.toml"}`},
394 {name: "control syntax", rawInput: `{"command":"go test ./... && git status"}`},
395 {name: "background", rawInput: `{"command":"go test ./...","run_in_background":true}`},
396 {name: "preserved descendants", rawInput: `{"command":"go test ./...","preserve_background_processes":true}`},
397 } {
398 t.Run(tc.name, func(t *testing.T) {
399 meta := sink.permissionMeta(event.Approval{
400 ID: "command", Tool: "bash", Subject: "command", RawInput: json.RawMessage(tc.rawInput),
401 })
402 reasonix, ok := meta["reasonix.io"].(map[string]any)
403 if !ok {
404 t.Fatalf("reasonix metadata = %#v", meta)
405 }
406 argv, present := reasonix["argv"]
407 if len(tc.wantArgv) == 0 {
408 if present {
409 t.Fatalf("unsafe command received trusted argv: %#v", argv)
410 }
411 return
412 }
413 got, ok := argv.([]string)
414 if !ok || strings.Join(got, "\x00") != strings.Join(tc.wantArgv, "\x00") {
415 t.Fatalf("argv = %#v, want %#v", argv, tc.wantArgv)
416 }
417 if reasonix["commandSchemaVersion"] != 1 || reasonix["cwd"] != filepath.Clean(cwd) {
418 t.Fatalf("trusted command metadata = %#v", reasonix)
419 }
420 })
421 }
422 }
423
424 func TestUpdateSinkSandboxEscapeApprovalOffersSessionGrant(t *testing.T) {
425 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
426 raw, _ := json.Marshal(params)
427 var p PermissionRequestParams
428 if err := json.Unmarshal(raw, &p); err != nil {
429 t.Fatalf("permission params: %v", err)
430 }
431 assertACPv1PermissionOptionKinds(t, p.Options)
432 var hasOnce, hasSession, hasReject bool
433 for _, opt := range p.Options {
434 switch opt.OptionID {
435 case string(OptAllowOnce):
436 hasOnce = opt.Kind == OptAllowOnce
437 case string(OptAllowAlways):
438 hasSession = opt.Kind == OptAllowAlways && opt.Name == "Use real environment for this session"
439 case string(OptRejectOnce):
440 hasReject = opt.Kind == OptRejectOnce
441 default:
442 t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
443 }
444 }
445 if len(p.Options) != 3 || !hasOnce || !hasSession || !hasReject {
446 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
447 }
448 res, _ := json.Marshal(PermissionRequestResult{
449 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
450 })
451 return res, nil
452 }}
453 sink := newUpdateSink(fn, "sess-1")
454 got := make(chan approveCall, 1)
455 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
456
457 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
458 ID: "11",
459 Tool: control.SandboxEscapeApprovalTool,
460 Subject: "run unconfined once: go test ./...",
461 }})
462
463 select {
464 case c := <-got:
465 want := approveCall{id: "11", allow: true, session: true, persist: false}
466 if c != want {
467 t.Errorf("approve = %+v, want %+v", c, want)
468 }
469 case <-time.After(2 * time.Second):
470 t.Fatal("approve was never called")
471 }
472 }
473
474 func TestUpdateSinkApprovalDenied(t *testing.T) {
475 // Both a "cancelled" outcome and a transport error must deny the call.
476 for _, tc := range []struct {
477 name string
478 resp func() (json.RawMessage, error)
479 }{
480 {"cancelled", func() (json.RawMessage, error) {
481 r, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "cancelled"}})
482 return r, nil
483 }},
484 {"transport error", func() (json.RawMessage, error) {
485 return nil, context.Canceled
486 }},
487 } {
488 t.Run(tc.name, func(t *testing.T) {
489 fn := &fakeNotifier{onReq: func(string, any) (json.RawMessage, error) { return tc.resp() }}
490 sink := newUpdateSink(fn, "sess-1")
491 got := make(chan approveCall, 1)
492 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
493
494 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "3", Tool: "edit_file"}})
495
496 select {
497 case c := <-got:
498 if c.allow || c.session {
499 t.Errorf("approve = %+v, want denied", c)
500 }
501 case <-time.After(2 * time.Second):
502 t.Fatal("approve was never called")
503 }
504 })
505 }
506 }
507
508 func TestUpdateSinkAskRequestUsesPermissionChoices(t *testing.T) {
509 fn := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
510 if method != "session/request_permission" {
511 t.Errorf("request method = %q, want session/request_permission", method)
512 }
513 raw, _ := json.Marshal(params)
514 var p PermissionRequestParams
515 if err := json.Unmarshal(raw, &p); err != nil {
516 t.Fatalf("permission params: %v", err)
517 }
518 if p.SessionID != "sess-1" {
519 t.Errorf("sessionId = %q", p.SessionID)
520 }
521 if p.ToolCall.ToolCallID != "ask-ask-1-q1" {
522 t.Errorf("toolCallId = %q, want ask-ask-1-q1", p.ToolCall.ToolCallID)
523 }
524 if p.ToolCall.Title != "Choose a target" {
525 t.Errorf("title = %q", p.ToolCall.Title)
526 }
527 if len(p.Options) != 3 {
528 t.Fatalf("options = %+v, want two answers plus cancel", p.Options)
529 }
530 assertACPv1PermissionOptionKinds(t, p.Options)
531 if p.Options[0].Name != "Tests - Run the suite" || p.Options[0].Kind != OptAllowOnce {
532 t.Fatalf("first option = %+v", p.Options[0])
533 }
534 res, _ := json.Marshal(PermissionRequestResult{
535 Outcome: PermissionOutcome{Outcome: "selected", OptionID: "q1:2"},
536 })
537 return res, nil
538 }}
539 sink := newUpdateSink(fn, "sess-1")
540 got := make(chan []event.AskAnswer, 1)
541 sink.bindAnswer(func(id string, answers []event.AskAnswer) {
542 if id != "ask-1" {
543 t.Errorf("answer id = %q, want ask-1", id)
544 }
545 got <- answers
546 })
547
548 sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{
549 ID: "ask-1",
550 Questions: []event.AskQuestion{{
551 ID: "q1",
552 Header: "Topic",
553 Prompt: "Choose a target",
554 Options: []event.AskOption{
555 {Label: "Tests", Description: "Run the suite"},
556 {Label: "Docs"},
557 },
558 }},
559 }})
560
561 select {
562 case answers := <-got:
563 if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Docs" {
564 t.Fatalf("answers = %+v, want q1 Docs", answers)
565 }
566 case <-time.After(2 * time.Second):
567 t.Fatal("ask answer was never called")
568 }
569 }
570
571 func TestUpdateSinkAskCancelledReturnsNoAnswers(t *testing.T) {
572 fn := &fakeNotifier{onReq: func(string, any) (json.RawMessage, error) {
573 res, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "cancelled"}})
574 return res, nil
575 }}
576 sink := newUpdateSink(fn, "sess-1")
577 got := make(chan []event.AskAnswer, 1)
578 sink.bindAnswer(func(_ string, answers []event.AskAnswer) { got <- answers })
579
580 sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{
581 ID: "ask-2",
582 Questions: []event.AskQuestion{{
583 ID: "q1",
584 Prompt: "Continue?",
585 Options: []event.AskOption{{Label: "Yes"}, {Label: "No"}},
586 }},
587 }})
588
589 select {
590 case answers := <-got:
591 if answers != nil {
592 t.Fatalf("answers = %+v, want nil on cancelled ask", answers)
593 }
594 case <-time.After(2 * time.Second):
595 t.Fatal("ask cancellation was never returned")
596 }
597 }
598
599 func TestUpdateSinkApprovalUsesTurnContext(t *testing.T) {
600 reqStarted := make(chan struct{})
601 fn := &fakeNotifier{onReqCtx: func(ctx context.Context, _ string, _ any) (json.RawMessage, error) {
602 close(reqStarted)
603 <-ctx.Done()
604 return nil, ctx.Err()
605 }}
606 sink := newUpdateSink(fn, "sess-1")
607 turnCtx, cancel := context.WithCancel(context.Background())
608 sink.setTurnContext(turnCtx)
609 got := make(chan approveCall, 1)
610 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
611
612 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "7", Tool: "bash"}})
613 select {
614 case <-reqStarted:
615 case <-time.After(2 * time.Second):
616 t.Fatal("permission request did not start")
617 }
618 cancel()
619
620 select {
621 case c := <-got:
622 if c.id != "7" || c.allow || c.session || c.persist {
623 t.Fatalf("approve after context cancel = %+v, want denied id=7", c)
624 }
625 case <-time.After(2 * time.Second):
626 t.Fatal("turn context cancellation did not deny permission request")
627 }
628 }
629
630 func TestApprovalOptionsFreshDynamicToolOnlyAllowOnceOrReject(t *testing.T) {
631 options := approvalOptions("extension__wipe", "extension/wipe", true)
632 if len(options) != 2 || options[0].Kind != OptAllowOnce || options[1].Kind != OptRejectOnce {
633 t.Fatalf("fresh dynamic-tool options = %+v, want allow-once/reject", options)
634 }
635 for _, option := range options {
636 if option.Kind == OptAllowAlways {
637 t.Fatalf("fresh dynamic-tool decision offered remembered permission: %+v", options)
638 }
639 }
640 }
641
642 func TestDynamicBashApprovalOptionsUseExactSessionLiteral(t *testing.T) {
643 const command = "git status $(touch /tmp/reasonix-dynamic-approval)"
644 options := approvalOptions("bash", command, false)
645 if len(options) != 3 || options[1].Kind != OptAllowAlways {
646 t.Fatalf("dynamic Bash options = %+v, want ordinary options with session grant", options)
647 }
648 want := "Bash=" + command
649 if !strings.Contains(options[1].Name, want) {
650 t.Fatalf("dynamic Bash session option = %q, want exact rule %q", options[1].Name, want)
651 }
652 }
653
654 func TestClipKeepsValidUTF8(t *testing.T) {
655 text := strings.Repeat("a", maxResultChars-1) + "界" + strings.Repeat("b", 20)
656 got := clip(text)
657 if !utf8.ValidString(got) {
658 t.Fatalf("clip returned invalid UTF-8")
659 }
660 if strings.Contains(got, "\ufffd") {
661 t.Fatalf("clip inserted replacement characters: %q", got[len(got)-40:])
662 }
663 }
664
665 func TestClip(t *testing.T) {
666 if got := clip("short"); got != "short" {
667 t.Errorf("clip(short) = %q", got)
668 }
669 long := strings.Repeat("x", maxResultChars+10)
670 got := clip(long)
671 if !strings.HasPrefix(got, strings.Repeat("x", maxResultChars)) {
672 t.Errorf("clip did not preserve the head")
673 }
674 if !strings.Contains(got, "10 more chars truncated") {
675 t.Errorf("clip note missing: %q", got[len(got)-40:])
676 }
677 }
678
679 // Replay must show the user-authored view, not the persisted wire form:
680 // injected transient blocks and protocol markers stay in history for parsing
681 // but never reach the client (#6882).
682 func TestUpdateSinkReplayStripsInjectedWrappers(t *testing.T) {
683 fn := &fakeNotifier{}
684 sink := newUpdateSink(fn, "sess-1")
685 sink.replay([]provider.Message{
686 {
687 Role: provider.RoleUser,
688 Content: "<response-language>\nFinal answer language preference: use Simplified Chinese.\n</response-language>\n" +
689 "Introduce yourself",
690 },
691 {
692 Role: provider.RoleAssistant,
693 Content: "Here you go.\n[goal:continue]",
694 },
695 })
696
697 u := fn.updateMap(t, 0)
698 content, _ := u["content"].(map[string]any)
699 if content["text"] != "Introduce yourself" {
700 t.Fatalf("replayed user text = %v, want the authored text only", content["text"])
701 }
702 u = fn.updateMap(t, 1)
703 content, _ = u["content"].(map[string]any)
704 if content["text"] != "Here you go." {
705 t.Fatalf("replayed assistant text = %v, want goal marker stripped", content["text"])
706 }
707 }
708
709 // TestUpdateSinkDropsSubagentProgress locks the ACP policy for the reserved
710 // sub-agent progress ToolProgress channels: every body stays out of ACP
711 // notifications, exactly like ordinary ToolProgress (which has no handler).
712 func TestUpdateSinkDropsSubagentProgress(t *testing.T) {
713 fn := &fakeNotifier{}
714 sink := newUpdateSink(fn, "sess-1")
715
716 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
717 ID: "task-1", Name: event.SubagentProgressStatusName, Output: "running",
718 }})
719 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
720 ID: "task-1", Name: event.SubagentProgressReasoningName, Output: "thinking",
721 }})
722 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
723 ID: "task-1", Name: event.SubagentProgressTextName, Output: "answer preview",
724 }})
725 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
726 ID: "task-1", Name: event.SubagentProgressNoticeName, Output: "heads up",
727 Truncated: true,
728 }})
729 if got := len(fn.notifs); got != 0 {
730 t.Fatalf("sub-agent progress produced %d notifications, want 0", got)
731 }
732 }
733
733 lines GO