A cancellation boundary in a multi-agent chat can leave a user message saved, an in-flight reply visible, and later agents uncalled. Those are three different outcomes.
I built OpenClaw Launch, which includes a group chat where one agent can pass work to another. While reading the queue runner, I found a useful reminder for anyone building a “Stop” button around asynchronous work: the location of the cancellation check determines what stops. It does not erase effects that happened before the check.
Consider a group turn with one user message, a front-door agent, and a peer. The runner first copies the previous transcript. Every new line follows this sequence:
append to the turn result
append to the working transcript
notify the live UI through onLine
await the persistence write
Only after it emits the user's line does the runner plan agent steps and enter its queue loop. At the top of each iteration, it checks whether the signal is aborted. This ordering produces an outcome that can surprise a reader of the interface: if the signal is already aborted, no agent send starts, but the user line has still been emitted and written. “Stopped before any bot replied” does not mean “the turn never existed.”
An in-flight send has a different boundary
The runner checks cancellation before taking the next queued step, then awaits the injected send function. It does not check the signal again immediately after that await.
Imagine the front-door send is pending when the user presses Stop. If that send nevertheless resolves successfully, the runner can append and persist its reply. It will stop before dispatching the next queued peer. The resulting transcript can legitimately contain the user message and one bot answer, even though the user pressed Stop while the request was in flight.
That is a narrower claim than “Stop cancels the network request.” The queue runner's send dependency receives a step and transcript, not an abort signal. A caller may close over the signal and pass it to an HTTP adapter, but this runner does not guarantee that wiring. The adapters in this implementation can pass a signal to fetch and propagate an abort, yet an aborted fetch still cannot prove that a remote service undid work it had already performed.
Visible is not the same as durable
The line-emission sequence has a second consequence. onLine fires before the persistence promise resolves. If that write fails, a viewer may have seen a line that was never confirmed saved. The runner does not catch the persistence rejection and return a normal result; the function rejects. Earlier successful writes are not rolled back. A synchronous failure inside onLine can even prevent the persistence call for that line.
This is not a reason to hide streaming output. It is a reason to name its states accurately. An interface could distinguish received, shown, and saved instead of treating a visible line as proof of a durable one. Whether to expose all three states depends on the product, but the implementation should not blur them.
How I would test the boundary
These cases need controlled promises, not sleeps:
- Start with an already-aborted signal. Assert that the user line is persisted and
sendis never called. - Pause a fake send, abort the signal, then resolve that send successfully. Assert that its reply is emitted and the next queued send never starts.
- Let the user write succeed, then reject persistence for the bot reply. Assert that
onLinesaw the bot reply before the write rejected, and that the runner rejected rather than returning a successful result. - Separately wire the signal through the actual adapter and test the abort-rejection path. That checks transport behavior without confusing it with the queue loop's behavior.
The practical rule is to define “Stop” at each boundary: future dispatch, in-flight transport, UI display, persistence, and remote side effects. A single abort flag cannot make all five atomic.
