Files
2026-07-13 13:00:08 +08:00

128 lines
4.0 KiB
Go

package bot
import (
"context"
"io"
"log/slog"
"testing"
"time"
"reasonix/internal/control"
)
// approvalBlockingController is a botController whose RunTurn blocks the way a
// real turn does when it hits interactive tool approval: it parks inside
// RunTurn until Approve is called. Every other method is a harmless stub so the
// gateway's turn/approve path can drive it without a real controller.
type approvalBlockingController struct {
botController // embedded nil interface: unused methods panic if ever called
started chan struct{}
released chan struct{}
approved chan struct{}
}
func newApprovalBlockingController() *approvalBlockingController {
return &approvalBlockingController{
started: make(chan struct{}, 1),
released: make(chan struct{}),
approved: make(chan struct{}, 1),
}
}
func (c *approvalBlockingController) RunTurn(ctx context.Context, input string) error {
// Signal the turn is in-flight, then block as if waiting for ctrl.Approve.
select {
case c.started <- struct{}{}:
default:
}
select {
case <-c.released:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *approvalBlockingController) Approve(id string, allow, session, persist bool) {
select {
case c.approved <- struct{}{}:
default:
}
// Unblock the parked RunTurn, mirroring the real approval handoff.
select {
case <-c.released:
default:
close(c.released)
}
}
// Methods the turn/approve path touches but whose behavior is irrelevant here.
func (c *approvalBlockingController) SessionPath() string { return "" }
var _ botController = (*approvalBlockingController)(nil)
var _ control.Approvals = (*approvalBlockingController)(nil)
// TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine guards the contract
// fixed in this PR: a turn that blocks inside RunTurn waiting for approval must
// not wedge the per-adapter dispatch loop. handleSlashCommand (the only caller
// of ctrl.Approve) runs on that same dispatch goroutine, so if runTurn ran
// inline the loop could never deliver the /approve reply that unblocks the turn
// (#4402, #4701, #4863). Running the turn on its own goroutine keeps the loop
// free to deliver it.
func TestGatewayApprovalReplyUnblocksTurnOffDispatchGoroutine(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
binding := AdapterBinding{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}
ctrl := newApprovalBlockingController()
msg := InboundMessage{
Platform: PlatformWeixin,
ConnectionID: "weixin",
ChatType: ChatDM,
ChatID: "chat",
UserID: "user",
}
key := BuildSessionKey(msg.Session())
// Pre-seed the session so runTurn reuses this fake controller instead of
// building a real one via boot.Build.
gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go gw.dispatchLoop(ctx, binding)
// First message: a normal turn that will block on approval inside RunTurn.
turn := msg
turn.Text = "do something that needs approval"
adapter.msgCh <- turn
select {
case <-ctrl.started:
case <-time.After(2 * time.Second):
t.Fatal("turn never started; dispatch loop did not run the turn")
}
// Second message: the /approve reply. If the turn ran inline on the dispatch
// goroutine, the loop is parked in RunTurn and can never read this — the
// session would wedge until restart. With the turn off the dispatch loop,
// this reply is delivered and unblocks the turn.
approve := msg
approve.Text = "/approve some-id"
adapter.msgCh <- approve
select {
case <-ctrl.approved:
case <-time.After(2 * time.Second):
t.Fatal("approval reply was never delivered: dispatch loop is wedged on the blocked turn")
}
// And the parked turn actually unblocks.
select {
case <-ctrl.released:
case <-time.After(2 * time.Second):
t.Fatal("turn did not unblock after approval")
}
}