Skip to content

Tell a Bot a person has the wheel, not that its refs are stale - #164

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
beardthelion:fix/control-refusal-reaches-the-model
Aug 22, 2026
Merged

Tell a Bot a person has the wheel, not that its refs are stale#164
davidmckayv merged 3 commits into
CopilotKit:mainfrom
beardthelion:fix/control-refusal-reaches-the-model

Conversation

@beardthelion

Copy link
Copy Markdown
Contributor

Closes #163.

The computer refuses an action with 409 { error, humanHasControl: true } when somebody has taken the browser, and the surface already branches on that flag: with it the model is told a person has control, without it it gets staleRefs, which the snapshot tool's own description turns into "the page changed, call this again and use the new refs". The flag did not survive the server, so the branch was unreachable and a refused Bot was sent back round the same action against the person who had just taken the browser.

What it does

A takeover is its own condition through the transport, told apart by the flag rather than by the message, because the message is the computer's prose and matching on it breaks the moment the wording changes. Only the computer's own true counts: a string, a false, an absent flag or an empty body stays the ordinary stale-ref condition, so a Bot with genuinely stale refs is never parked waiting for a person who is not coming.

The status is unchanged, 409 either way, because nothing is broken in either case. What differs is what the caller should do next, and that is the only thing the distinction is for.

The flag is rendered in one place rather than at the route that happened to need it. The first version of this carried it inside the shared acting helper, which fixed the acting path and left navigate answering as before: navigate renders its own error, and it is the second place the computer flags a takeover. Nine call sites render an error here, so the rendering moved into errorBody and a route can no longer answer without carrying what the caller needs from it.

Verification

control-refusal-end-to-end.test.ts drives the real router through the real gateway and transport against a listener answering exactly what the computer answers, over a socket, because the property is that a field survives being serialised, thrown as an error, and re-serialised. Four cases: the flag reaches the caller on the acting path and on navigate, a stale 409 gains no flag, and the refusal is still audited as a refusal.

Five in computer-client.test.ts for the transport, including the four non-flag shapes and a 409 with no body at all.

Three in app/tests/computer-tool-refusals.test.ts for the end of the wire, where the distinction is actually drawn for the model: a takeover reports humanHasControl and never staleRefs, a stale snapshot still reports staleRefs, and a policy refusal is neither. callComputer is exported for it, since standing up the tool registrations and the runtime around them to reach three lines of mapping would test less and cost more.

Each guard was checked by removing it: dropping the flag from the renderer turns two red, loosening the transport's equality check turns one red, and making the surface ignore the flag turns one red.

Suite is 886 passing against 877 on main with a database, no failures either side. Typecheck clean across app, server and worker; biome clean.

What this does not close

The computer's other control refusal, TAKE_CONTROL_FIRST for a person driving before they have taken the wheel, carries no flag. That is the person's own UI rather than the model's path, and its client discards the error regardless (app/src/lib/computers/control.ts:91), so it wants a different answer than this one.

statusFor's branch for a control refusal arriving as a ComputerUnavailableError with /control/i in the message is left alone. It looks unreachable for the shipped computer now that both control refusals are 409, but proving that belongs with whatever change removes it.

The computer refuses an action with `409 { error, humanHasControl: true }`
when somebody has taken the browser, and the surface already branches on that
flag: with it the model is told a person has control, without it it is told
`staleRefs`, which its own tool description turns into "the page changed, call
this again and use the new refs". The flag did not survive the server. Every
409 became a `StaleSnapshotError`, which carries only a message, so the branch
in the surface was unreachable and a Bot refused by a takeover was sent back
round the same action against the person who had just taken the browser.

A takeover is now its own condition through the transport and the route. The
status is unchanged, 409 either way, because nothing is broken in either case;
what differs is what the caller should do next, and that is the only thing the
distinction is for. An ordinary stale-ref 409 still maps as it did.

Told apart by the flag rather than by the message, because the message is the
computer's prose and matching on it would break the moment the wording changed.
@davidmckayv
davidmckayv merged commit 71fe47a into CopilotKit:main Aug 22, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A Bot refused because a person took the wheel is told its refs are stale, and tries again

2 participants