返回 DeepSeek-Reasonix
turn_dispatch_test.go
根目录 / internal / bot / turn_dispatch_test.go
1 package bot
2
3 import (
4 "context"
5 "io"
6 "log/slog"
7 "testing"
8 "time"
9
10 "reasonix/internal/control"
11 "reasonix/internal/sandbox"
12 )
13
14 // approvalBlockingController is a botController whose RunTurn blocks the way a
15 // real turn does when it hits interactive tool approval: it parks inside
16 // RunTurn until Approve is called. Every other method is a harmless stub so the
17 // gateway's turn/approve path can drive it without a real controller.
18 type approvalBlockingController struct {
19 stubBotController
20 started chan struct{}
21 released chan struct{}
22 approved chan struct{}
23 }
24
25 func newApprovalBlockingController() *approvalBlockingController {
26 return &approvalBlockingController{
27 started: make(chan struct{}, 1),
28 released: make(chan struct{}),
29 approved: make(chan struct{}, 1),
30 }
31 }
32
33 func (c *approvalBlockingController) RunTurn(ctx context.Context, input string) error {
34 // Signal the turn is in-flight, then block as if waiting for ctrl.Approve.
35 select {
36 case c.started <- struct{}{}:
37 default:
38 }
39 select {
40 case <-c.released:
41 return nil
42 case <-ctx.Done():
43 return ctx.Err()
44 }
45 }
46
47 func (c *approvalBlockingController) ResolveApproval(id string, allow bool, scope sandbox.ApprovalScope) error {
48 c.Approve(id, allow, scope != sandbox.ApprovalScopeOnce, scope == sandbox.ApprovalScopeProject)
49 return nil
50 }
51
52 func (c *approvalBlockingController) Approve(id string, allow, session, persist bool) {
53 select {
54 case c.approved <- struct{}{}:
55 default:
56 }
57 // Unblock the parked RunTurn, mirroring the real approval handoff.
58 select {
59 case <-c.released:
60 default:
61 close(c.released)
62 }
63 }
64
65 // Methods the turn/approve path touches but whose behavior is irrelevant here.
66 func (c *approvalBlockingController) SessionPath() string { return "" }
67
68 var _ botController = (*approvalBlockingController)(nil)
69 var _ control.Approvals = (*approvalBlockingController)(nil)
70
71 // TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine guards the contract
72 // fixed in this PR: a turn that blocks inside RunTurn waiting for approval must
73 // not wedge the per-adapter dispatch loop. handleSlashCommand (the only caller
74 // of ctrl.Approve) runs on that same dispatch goroutine, so if runTurn ran
75 // inline the loop could never deliver the /approve reply that unblocks the turn
76 // (#4402, #4701, #4863). Running the turn on its own goroutine keeps the loop
77 // free to deliver it.
78 func TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine(t *testing.T) {
79 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
81
82 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
83 binding := AdapterBinding{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}
84
85 ctrl := newApprovalBlockingController()
86 msg := InboundMessage{
87 Platform: PlatformWeixin,
88 ConnectionID: "weixin",
89 ChatType: ChatDM,
90 ChatID: "chat",
91 UserID: "user",
92 }
93 key := BuildSessionKey(msg.Session())
94 // Pre-seed the session so runTurn reuses this fake controller instead of
95 // building a real one via boot.Build.
96 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
97
98 ctx := t.Context()
99 go gw.dispatchLoop(ctx, binding)
100
101 // First message: a normal turn that will block on approval inside RunTurn.
102 turn := msg
103 turn.Text = "do something that needs approval"
104 adapter.msgCh <- turn
105
106 select {
107 case <-ctrl.started:
108 case <-time.After(2 * time.Second):
109 t.Fatal("turn never started; dispatch loop did not run the turn")
110 }
111
112 // Second message: the /approve reply. If the turn ran inline on the dispatch
113 // goroutine, the loop is parked in RunTurn and can never read this — the
114 // session would wedge until restart. With the turn off the dispatch loop,
115 // this reply is delivered and unblocks the turn.
116 approve := msg
117 approve.Text = "/approve some-id"
118 adapter.msgCh <- approve
119
120 select {
121 case <-ctrl.approved:
122 case <-time.After(2 * time.Second):
123 t.Fatal("approval reply was never delivered: dispatch loop is wedged on the blocked turn")
124 }
125
126 // And the parked turn actually unblocks.
127 select {
128 case <-ctrl.released:
129 case <-time.After(2 * time.Second):
130 t.Fatal("turn did not unblock after approval")
131 }
132 }
133
133 lines GO