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