返回 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.LevelInfo, Code: event.NoticeCodeCompletionUncertain, Text: "completion could not be confirmed"})
191 if got := len(fn.notifs); got != 1 {
192 t.Fatalf("completion uncertainty produced %d notifications, want 1", got)
193 }
194 if text := chunkText(t, fn.updateMap(t, 0)); !strings.Contains(text, "completion could not be confirmed") || strings.Contains(text, "[warning]") {
195 t.Fatalf("completion uncertainty notice = %q, want informational text", text)
196 }
197 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "watch out"})
198 if got := len(fn.notifs); got != 2 {
199 t.Fatalf("warn notice produced %d notifications, want 2 total", got)
200 }
201 u := fn.updateMap(t, 1)
202 if u["sessionUpdate"] != "agent_message_chunk" {
203 t.Errorf("warn update = %v", u["sessionUpdate"])
204 }
205 if c, _ := u["content"].(map[string]any); !strings.Contains(c["text"].(string), "watch out") {
206 t.Errorf("warn content = %v", u["content"])
207 }
208 }
209
210 // approveCall records one approve(id, allow, session, persist) callback.
211 type approveCall struct {
212 id string
213 allow bool
214 session bool
215 persist bool
216 }
217
218 func invalidACPv1PermissionOptionKind(options []PermissionOption) (PermissionOption, bool) {
219 // ACP v1 schema only accepts these four PermissionOptionKind values. ACP hosts
220 // own cross-session persistence, so Reasonix-specific persistent approvals must
221 // not appear in session/request_permission options.
222 valid := map[PermissionOptionKind]bool{
223 OptAllowOnce: true,
224 OptAllowAlways: true,
225 OptRejectOnce: true,
226 OptRejectAlways: true,
227 }
228 for _, opt := range options {
229 if !valid[opt.Kind] {
230 return opt, true
231 }
232 }
233 return PermissionOption{}, false
234 }
235
236 func assertACPv1PermissionOptionKinds(t *testing.T, options []PermissionOption) {
237 t.Helper()
238 if opt, ok := invalidACPv1PermissionOptionKind(options); ok {
239 t.Fatalf("permission option %q uses non-ACP-v1 kind %q", opt.OptionID, opt.Kind)
240 }
241 }
242
243 func TestUpdateSinkApprovalAllowAlways(t *testing.T) {
244 fn := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
245 if method != "session/request_permission" {
246 t.Errorf("request method = %q, want session/request_permission", method)
247 }
248 raw, _ := json.Marshal(params)
249 var p PermissionRequestParams
250 if err := json.Unmarshal(raw, &p); err != nil {
251 t.Fatalf("permission params: %v", err)
252 }
253 if p.SessionID != "sess-1" {
254 t.Errorf("sessionId = %q", p.SessionID)
255 }
256 if p.ToolCall.Kind != "execute" {
257 t.Errorf("kind = %q, want execute", p.ToolCall.Kind)
258 }
259 if p.ToolCall.ToolCallID != "gate-9" {
260 t.Errorf("toolCallId = %q, want gate-9", p.ToolCall.ToolCallID)
261 }
262 assertACPv1PermissionOptionKinds(t, p.Options)
263 res, _ := json.Marshal(PermissionRequestResult{
264 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
265 })
266 return res, nil
267 }}
268 sink := newUpdateSink(fn, "sess-1")
269 got := make(chan approveCall, 1)
270 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
271
272 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "9", Tool: "bash", Subject: "rm -rf /"}})
273
274 select {
275 case c := <-got:
276 if c != (approveCall{id: "9", allow: true, session: true, persist: false}) {
277 t.Errorf("approve = %+v, want {9 true true}", c)
278 }
279 case <-time.After(2 * time.Second):
280 t.Fatal("approve was never called")
281 }
282 }
283
284 func TestUpdateSinkPermissionCarriesStructuredContext(t *testing.T) {
285 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
286 raw, _ := json.Marshal(params)
287 var p PermissionRequestParams
288 if err := json.Unmarshal(raw, &p); err != nil {
289 t.Fatalf("permission params: %v", err)
290 }
291 if string(p.ToolCall.RawInput) != `{"path":"src/main.go","content":"next"}` {
292 t.Fatalf("rawInput = %s", p.ToolCall.RawInput)
293 }
294 if len(p.ToolCall.Locations) != 1 || !strings.HasSuffix(filepath.ToSlash(p.ToolCall.Locations[0].Path), "/src/main.go") {
295 t.Fatalf("locations = %+v", p.ToolCall.Locations)
296 }
297 meta, ok := p.ToolCall.Meta["reasonix.io"].(map[string]any)
298 if !ok || meta["tool"] != "write_file" || meta["approvalId"] != "structured" || meta["reason"] != "write requested by the active goal" {
299 t.Fatalf("metadata = %#v", p.ToolCall.Meta)
300 }
301 var wire map[string]any
302 if err := json.Unmarshal(raw, &wire); err != nil {
303 t.Fatalf("permission wire shape: %v", err)
304 }
305 toolCall, ok := wire["toolCall"].(map[string]any)
306 if !ok {
307 t.Fatalf("toolCall wire shape = %#v", wire["toolCall"])
308 }
309 if _, present := toolCall["reason"]; present {
310 t.Fatalf("ACP v1 toolCall has non-standard root reason: %#v", toolCall)
311 }
312 res, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptRejectOnce)}})
313 return res, nil
314 }}
315 sink := newUpdateSink(fn, "sess-structured")
316 sink.bindCwd(t.TempDir())
317 got := make(chan approveCall, 1)
318 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
319 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
320 ID: "structured", Tool: "write_file", Subject: "src/main.go",
321 Reason: "write requested by the active goal",
322 RawInput: json.RawMessage(`{"path":"src/main.go","content":"next"}`),
323 }})
324 select {
325 case decision := <-got:
326 if decision.allow {
327 t.Fatalf("rejected permission was allowed: %+v", decision)
328 }
329 case <-time.After(2 * time.Second):
330 t.Fatal("permission was never resolved")
331 }
332 }
333
334 func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
335 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
336 raw, _ := json.Marshal(params)
337 var p PermissionRequestParams
338 if err := json.Unmarshal(raw, &p); err != nil {
339 t.Fatalf("permission params: %v", err)
340 }
341 // ACP permission options stay within the official spec kinds, and ACP
342 // mode leaves cross-session persistence to the host.
343 assertACPv1PermissionOptionKinds(t, p.Options)
344 var hasOnce, hasSession, hasReject bool
345 for _, opt := range p.Options {
346 switch opt.OptionID {
347 case string(OptAllowOnce):
348 hasOnce = opt.Kind == OptAllowOnce
349 case string(OptAllowAlways):
350 hasSession = opt.Kind == OptAllowAlways
351 case string(OptRejectOnce):
352 hasReject = opt.Kind == OptRejectOnce
353 default:
354 t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
355 }
356 }
357 if !hasOnce || !hasSession || !hasReject {
358 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
359 }
360 if len(p.Options) != 3 {
361 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
362 }
363 res, _ := json.Marshal(PermissionRequestResult{
364 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
365 })
366 return res, nil
367 }}
368 sink := newUpdateSink(fn, "sess-1")
369 got := make(chan approveCall, 1)
370 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
371
372 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "10", Tool: "bash", Subject: "go test ./..."}})
373
374 select {
375 case c := <-got:
376 want := approveCall{id: "10", allow: true, session: true, persist: false}
377 if c != want {
378 t.Errorf("approve = %+v, want %+v", c, want)
379 }
380 case <-time.After(2 * time.Second):
381 t.Fatal("approve was never called")
382 }
383 }
384
385 func TestPermissionMetaOnlyTrustsForegroundStaticBash(t *testing.T) {
386 cwd := t.TempDir()
387 sink := newUpdateSink(&fakeNotifier{}, "sess-static-command")
388 sink.bindCwd(cwd)
389
390 for _, tc := range []struct {
391 name string
392 rawInput string
393 wantArgv []string
394 }{
395 {name: "static", rawInput: `{"command":"go test ./..."}`, wantArgv: []string{"go", "test", "./..."}},
396 {name: "quoted static", rawInput: `{"command":"node -e 'process.exit(0)'"}`, wantArgv: []string{"node", "-e", "process.exit(0)"}},
397 {name: "expansion", rawInput: `{"command":"go test $PACKAGE"}`},
398 {name: "glob expansion", rawInput: `{"command":"go test ./*.go"}`},
399 {name: "brace expansion", rawInput: `{"command":"printf '%s' {a,b}"}`},
400 {name: "tilde expansion", rawInput: `{"command":"test -f ~/.config/reasonix.toml"}`},
401 {name: "control syntax", rawInput: `{"command":"go test ./... && git status"}`},
402 {name: "background", rawInput: `{"command":"go test ./...","run_in_background":true}`},
403 {name: "preserved descendants", rawInput: `{"command":"go test ./...","preserve_background_processes":true}`},
404 } {
405 t.Run(tc.name, func(t *testing.T) {
406 meta := sink.permissionMeta(event.Approval{
407 ID: "command", Tool: "bash", Subject: "command", RawInput: json.RawMessage(tc.rawInput),
408 })
409 reasonix, ok := meta["reasonix.io"].(map[string]any)
410 if !ok {
411 t.Fatalf("reasonix metadata = %#v", meta)
412 }
413 argv, present := reasonix["argv"]
414 if len(tc.wantArgv) == 0 {
415 if present {
416 t.Fatalf("unsafe command received trusted argv: %#v", argv)
417 }
418 return
419 }
420 got, ok := argv.([]string)
421 if !ok || strings.Join(got, "\x00") != strings.Join(tc.wantArgv, "\x00") {
422 t.Fatalf("argv = %#v, want %#v", argv, tc.wantArgv)
423 }
424 if reasonix["commandSchemaVersion"] != 1 || reasonix["cwd"] != filepath.Clean(cwd) {
425 t.Fatalf("trusted command metadata = %#v", reasonix)
426 }
427 })
428 }
429 }
430
431 func TestUpdateSinkSandboxEscapeApprovalOffersSessionGrant(t *testing.T) {
432 fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
433 raw, _ := json.Marshal(params)
434 var p PermissionRequestParams
435 if err := json.Unmarshal(raw, &p); err != nil {
436 t.Fatalf("permission params: %v", err)
437 }
438 assertACPv1PermissionOptionKinds(t, p.Options)
439 var hasOnce, hasSession, hasReject bool
440 for _, opt := range p.Options {
441 switch opt.OptionID {
442 case string(OptAllowOnce):
443 hasOnce = opt.Kind == OptAllowOnce
444 case string(OptAllowAlways):
445 hasSession = opt.Kind == OptAllowAlways && opt.Name == "Use real environment for this session"
446 case string(OptRejectOnce):
447 hasReject = opt.Kind == OptRejectOnce
448 default:
449 t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
450 }
451 }
452 if len(p.Options) != 3 || !hasOnce || !hasSession || !hasReject {
453 t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
454 }
455 res, _ := json.Marshal(PermissionRequestResult{
456 Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
457 })
458 return res, nil
459 }}
460 sink := newUpdateSink(fn, "sess-1")
461 got := make(chan approveCall, 1)
462 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
463
464 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
465 ID: "11",
466 Tool: control.SandboxEscapeApprovalTool,
467 Subject: "run unconfined once: go test ./...",
468 }})
469
470 select {
471 case c := <-got:
472 want := approveCall{id: "11", allow: true, session: true, persist: false}
473 if c != want {
474 t.Errorf("approve = %+v, want %+v", c, want)
475 }
476 case <-time.After(2 * time.Second):
477 t.Fatal("approve was never called")
478 }
479 }
480
481 func TestUpdateSinkApprovalDenied(t *testing.T) {
482 // Both a "cancelled" outcome and a transport error must deny the call.
483 for _, tc := range []struct {
484 name string
485 resp func() (json.RawMessage, error)
486 }{
487 {"cancelled", func() (json.RawMessage, error) {
488 r, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "cancelled"}})
489 return r, nil
490 }},
491 {"transport error", func() (json.RawMessage, error) {
492 return nil, context.Canceled
493 }},
494 } {
495 t.Run(tc.name, func(t *testing.T) {
496 fn := &fakeNotifier{onReq: func(string, any) (json.RawMessage, error) { return tc.resp() }}
497 sink := newUpdateSink(fn, "sess-1")
498 got := make(chan approveCall, 1)
499 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
500
501 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "3", Tool: "edit_file"}})
502
503 select {
504 case c := <-got:
505 if c.allow || c.session {
506 t.Errorf("approve = %+v, want denied", c)
507 }
508 case <-time.After(2 * time.Second):
509 t.Fatal("approve was never called")
510 }
511 })
512 }
513 }
514
515 func TestUpdateSinkAskRequestUsesPermissionChoices(t *testing.T) {
516 fn := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
517 if method != "session/request_permission" {
518 t.Errorf("request method = %q, want session/request_permission", method)
519 }
520 raw, _ := json.Marshal(params)
521 var p PermissionRequestParams
522 if err := json.Unmarshal(raw, &p); err != nil {
523 t.Fatalf("permission params: %v", err)
524 }
525 if p.SessionID != "sess-1" {
526 t.Errorf("sessionId = %q", p.SessionID)
527 }
528 if p.ToolCall.ToolCallID != "ask-ask-1-q1" {
529 t.Errorf("toolCallId = %q, want ask-ask-1-q1", p.ToolCall.ToolCallID)
530 }
531 if p.ToolCall.Title != "Choose a target" {
532 t.Errorf("title = %q", p.ToolCall.Title)
533 }
534 if len(p.Options) != 3 {
535 t.Fatalf("options = %+v, want two answers plus cancel", p.Options)
536 }
537 assertACPv1PermissionOptionKinds(t, p.Options)
538 if p.Options[0].Name != "Tests - Run the suite" || p.Options[0].Kind != OptAllowOnce {
539 t.Fatalf("first option = %+v", p.Options[0])
540 }
541 res, _ := json.Marshal(PermissionRequestResult{
542 Outcome: PermissionOutcome{Outcome: "selected", OptionID: "q1:2"},
543 })
544 return res, nil
545 }}
546 sink := newUpdateSink(fn, "sess-1")
547 got := make(chan []event.AskAnswer, 1)
548 sink.bindAnswer(func(id string, answers []event.AskAnswer) {
549 if id != "ask-1" {
550 t.Errorf("answer id = %q, want ask-1", id)
551 }
552 got <- answers
553 })
554
555 sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{
556 ID: "ask-1",
557 Questions: []event.AskQuestion{{
558 ID: "q1",
559 Header: "Topic",
560 Prompt: "Choose a target",
561 Options: []event.AskOption{
562 {Label: "Tests", Description: "Run the suite"},
563 {Label: "Docs"},
564 },
565 }},
566 }})
567
568 select {
569 case answers := <-got:
570 if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Docs" {
571 t.Fatalf("answers = %+v, want q1 Docs", answers)
572 }
573 case <-time.After(2 * time.Second):
574 t.Fatal("ask answer was never called")
575 }
576 }
577
578 func TestUpdateSinkAskCancelledReturnsNoAnswers(t *testing.T) {
579 fn := &fakeNotifier{onReq: func(string, any) (json.RawMessage, error) {
580 res, _ := json.Marshal(PermissionRequestResult{Outcome: PermissionOutcome{Outcome: "cancelled"}})
581 return res, nil
582 }}
583 sink := newUpdateSink(fn, "sess-1")
584 got := make(chan []event.AskAnswer, 1)
585 sink.bindAnswer(func(_ string, answers []event.AskAnswer) { got <- answers })
586
587 sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{
588 ID: "ask-2",
589 Questions: []event.AskQuestion{{
590 ID: "q1",
591 Prompt: "Continue?",
592 Options: []event.AskOption{{Label: "Yes"}, {Label: "No"}},
593 }},
594 }})
595
596 select {
597 case answers := <-got:
598 if answers != nil {
599 t.Fatalf("answers = %+v, want nil on cancelled ask", answers)
600 }
601 case <-time.After(2 * time.Second):
602 t.Fatal("ask cancellation was never returned")
603 }
604 }
605
606 func TestUpdateSinkApprovalUsesTurnContext(t *testing.T) {
607 reqStarted := make(chan struct{})
608 fn := &fakeNotifier{onReqCtx: func(ctx context.Context, _ string, _ any) (json.RawMessage, error) {
609 close(reqStarted)
610 <-ctx.Done()
611 return nil, ctx.Err()
612 }}
613 sink := newUpdateSink(fn, "sess-1")
614 turnCtx, cancel := context.WithCancel(context.Background())
615 sink.setTurnContext(turnCtx)
616 got := make(chan approveCall, 1)
617 sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
618
619 sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "7", Tool: "bash"}})
620 select {
621 case <-reqStarted:
622 case <-time.After(2 * time.Second):
623 t.Fatal("permission request did not start")
624 }
625 cancel()
626
627 select {
628 case c := <-got:
629 if c.id != "7" || c.allow || c.session || c.persist {
630 t.Fatalf("approve after context cancel = %+v, want denied id=7", c)
631 }
632 case <-time.After(2 * time.Second):
633 t.Fatal("turn context cancellation did not deny permission request")
634 }
635 }
636
637 func TestApprovalOptionsFreshDynamicToolOnlyAllowOnceOrReject(t *testing.T) {
638 options := approvalOptions("extension__wipe", "extension/wipe", true)
639 if len(options) != 2 || options[0].Kind != OptAllowOnce || options[1].Kind != OptRejectOnce {
640 t.Fatalf("fresh dynamic-tool options = %+v, want allow-once/reject", options)
641 }
642 for _, option := range options {
643 if option.Kind == OptAllowAlways {
644 t.Fatalf("fresh dynamic-tool decision offered remembered permission: %+v", options)
645 }
646 }
647 }
648
649 func TestDynamicBashApprovalOptionsUseExactSessionLiteral(t *testing.T) {
650 const command = "git status $(touch /tmp/reasonix-dynamic-approval)"
651 options := approvalOptions("bash", command, false)
652 if len(options) != 3 || options[1].Kind != OptAllowAlways {
653 t.Fatalf("dynamic Bash options = %+v, want ordinary options with session grant", options)
654 }
655 want := "Bash=" + command
656 if !strings.Contains(options[1].Name, want) {
657 t.Fatalf("dynamic Bash session option = %q, want exact rule %q", options[1].Name, want)
658 }
659 }
660
661 func TestClipKeepsValidUTF8(t *testing.T) {
662 text := strings.Repeat("a", maxResultChars-1) + "界" + strings.Repeat("b", 20)
663 got := clip(text)
664 if !utf8.ValidString(got) {
665 t.Fatalf("clip returned invalid UTF-8")
666 }
667 if strings.Contains(got, "\ufffd") {
668 t.Fatalf("clip inserted replacement characters: %q", got[len(got)-40:])
669 }
670 }
671
672 func TestClip(t *testing.T) {
673 if got := clip("short"); got != "short" {
674 t.Errorf("clip(short) = %q", got)
675 }
676 long := strings.Repeat("x", maxResultChars+10)
677 got := clip(long)
678 if !strings.HasPrefix(got, strings.Repeat("x", maxResultChars)) {
679 t.Errorf("clip did not preserve the head")
680 }
681 if !strings.Contains(got, "10 more chars truncated") {
682 t.Errorf("clip note missing: %q", got[len(got)-40:])
683 }
684 }
685
686 // Replay must show the user-authored view, not the persisted wire form:
687 // injected transient blocks and protocol markers stay in history for parsing
688 // but never reach the client (#6882).
689 func TestUpdateSinkReplayStripsInjectedWrappers(t *testing.T) {
690 fn := &fakeNotifier{}
691 sink := newUpdateSink(fn, "sess-1")
692 sink.replay([]provider.Message{
693 {
694 Role: provider.RoleUser, Origin: provider.MessageOriginHost,
695 Content: "<pinned_context_revision>private pinned body</pinned_context_revision>",
696 },
697 {
698 Role: provider.RoleUser,
699 Content: "<response-language>\nFinal answer language preference: use Simplified Chinese.\n</response-language>\n" +
700 "Introduce yourself",
701 },
702 {
703 Role: provider.RoleAssistant,
704 Content: "Here you go.\n[goal:continue]",
705 },
706 })
707
708 u := fn.updateMap(t, 0)
709 content, _ := u["content"].(map[string]any)
710 if content["text"] != "Introduce yourself" {
711 t.Fatalf("replayed user text = %v, want the authored text only", content["text"])
712 }
713 u = fn.updateMap(t, 1)
714 content, _ = u["content"].(map[string]any)
715 if content["text"] != "Here you go." {
716 t.Fatalf("replayed assistant text = %v, want goal marker stripped", content["text"])
717 }
718 }
719
720 // TestUpdateSinkDropsSubagentProgress locks the ACP policy for the reserved
721 // sub-agent progress ToolProgress channels: every body stays out of ACP
722 // notifications, exactly like ordinary ToolProgress (which has no handler).
723 func TestUpdateSinkDropsSubagentProgress(t *testing.T) {
724 fn := &fakeNotifier{}
725 sink := newUpdateSink(fn, "sess-1")
726
727 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
728 ID: "task-1", Name: event.SubagentProgressStatusName, Output: "running",
729 }})
730 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
731 ID: "task-1", Name: event.SubagentProgressReasoningName, Output: "thinking",
732 }})
733 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
734 ID: "task-1", Name: event.SubagentProgressTextName, Output: "answer preview",
735 }})
736 sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{
737 ID: "task-1", Name: event.SubagentProgressNoticeName, Output: "heads up",
738 Truncated: true,
739 }})
740 if got := len(fn.notifs); got != 0 {
741 t.Fatalf("sub-agent progress produced %d notifications, want 0", got)
742 }
743 }
744
744 lines GO