| 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 |