128 lines
4.0 KiB
Go
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")
|
|
}
|
|
}
|