Repository navigation
fix(chat): harden the wait handover against model slips - #75
Merged
Merged
Conversation
A Haiku run sent wait_for_user_action a message ending in its own other arguments as escaped JSON, with literal \n sequences, and the person read the JSON tail as the handover. The message is cut where the swallowed arguments begin, they are recovered for the wait itself, and literal \n become line breaks, both where the message is shown and where it runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A persona that clicked a chat link and looked straight away read the page before the route changed, told the assistant the link did nothing, and the assistant apologised for a link the application had reported as followed. A look following a click or typing now reads until two consecutive snapshots agree, within 3 seconds; other looks read once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y its action caused Acting on a declared wait and writing happen in one persona pass, so the message was composed before the assistant reacted. Judged runs had the persona ask what had just been created under the reply that said so, and the stale message interrupted the next wait. When the action changed the conversation, the message is dropped and the persona asked again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssage starts The instruction to declare wait_for_user_action again after an interrupted wait lived in that wait's tool result, which sits in history ahead of the person's new message. Judged runs, Sonnet and Haiku alike, answered the question and never waited again, so the action they had handed over went unseen. The reminder now closes the new turn's hidden context. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The schema requires the message but the SDK does not enforce it: a Haiku run called wait_for_user_action with no arguments, the turn went silent, and the person, told nothing, had to ask what to do. The wait is refused with an error the model retries from. Tests and the mock model now send a message, as real calls do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
albanm
added a commit
that referenced
this pull request
Oct 6, 2026
All three PRs changed the in-browser loop and the gateway, which this branch deletes, so this merge resolves conflicts mechanically and the behaviour is carried over onto the server loop in the commits that follow - one per behaviour, each with its tests: - #73/#75 wait handover: `wait_for_user_action` requires a `message`, repairs a small-model slip, refuses a wait with no message. Merged here (it lives in shared/host-events.ts); the server's rendering of the message in the transcript follows. - #73/#75 interrupted turns: an aborted turn's open calls keep a result saying why, and the next turn carries a reminder of what the wait was for. Follows. - #74 self-service consumption, redacted quota refusals, conversation cost. Follows. Resolved here: - Deleted on this branch, modified on main: kept deleted - api/src/gateway/router.ts, ui/src/composables/{use-agent-chat,agent-stream-parts}.ts and their two specs. - New on main and built on the deleted code, set aside to come back adapted: interrupted-turn.ts and its spec, gateway-cost.ts and its spec, the two self-usage specs, the "speaking during a wait" e2e (it reads the browser's /chat/completions request), and two moderation-cost api tests (gateway-shaped, and anonymous). - api/src/usage/enforce.ts: this branch's `checkAccountCreditCap` (re-checked between steps of a long run) and main's `accountViolation` (the cap plus the untrusted pool, also read by the self-usage view) compose - the cap check stays one definition. - shared/host-events.ts: main's `repairWaitInput` moves here from the deleted stream-part builder, beside the tool that uses it; verified byte-identical. - dev/resources/organizations.json: main's `user1` -> `user` role fix, plus this branch's NHI members. Takes effect when simple-directory reloads its fixtures. - The debug dialog shows its Consumption tab only when the chat can report usage, which the #74 port provides; until then its e2e keeps the two-tab expectation. - The wait-tool specs pass the now-required `message`. - ui/dts/auto-imports.d.ts: the running dev server wrote entries for main's set-aside files, and the committed copy already declared 11 modules this branch had deleted; pruned to what exists. host-events.md and quotas-usage.md merged main's text, which describes the browser mechanisms; each port commit rewrites its section for the server. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
albanm
added a commit
that referenced
this pull request
Oct 6, 2026
…port of #73, #75) Main's #73 gave `wait_for_user_action` a required `message` - the model's words to the person, what is ready and what to press - because models read the `expecting` status label as their reply and handed buttons over in silence. The browser loop rendered it; that loop is gone, so the server now adds the message to the step's own text when it records the call (`waitHandover`), just before the call and unless the step already says it, and streams it. Stored, not only streamed, so a reloaded or reviewed thread shows the same handover. Before the call rather than after it, because text after a tool call is replayed to the model as a separate assistant message it never wrote. The rule is a pure function in shared/host-events.ts beside `repairWaitInput` (#75's repair of a small-model slip), unit-tested with main's cases; an api test checks the server streams it, stores it once, and puts it before the call. The first version also cleared the activity label, as the first text-delta does. That erased "Waiting for ...": the SDK starts executing a tool before the loop reads its call from the stream, so the wait had already set its label. The host-events e2e caught it (9 of 13 failed); the handover now leaves the activity alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
albanm
added a commit
that referenced
this pull request
Oct 6, 2026
#73, #75) A turn the person stops - by writing during it, or with Stop - must stay legible to the model. Without that, judged runs saw the person's two messages with nothing between, denied work they had done and redid it. Main fixed it for the browser loop; this is the same behaviour on the server. The server stores every turn as it goes, so most of an aborted turn is already in the record. The gap is the calls still open when it stopped: a call with no result is dropped on replay (`ignoreIncompleteToolCalls`), so the model never learned it had been waiting. The abort path now settles every open call with a result saying why (`settleInterruptedParts`, main's wording): the interrupted wait says what it waited for, any other call that the person spoke, and after Stop that the reply was stopped. The next request carries main's #75 reminder - what the wait was for, and to declare it again after answering - last in the hidden block, just before the person's words, and only for the turn right after (`pendingWaitReminder`, read from the settled wait in history; the conversation lock guarantees it is settled by then). Speaking writes no stop notice: the person's message answers it, and the notice would be replayed as the assistant's own words. Three things the port surfaced, fixed here: - Calls were persisted only on the throttled text clock, so the abort path - which reads the store - could miss an open wait entirely. They are now persisted the moment they open or settle, which also means a page reloaded mid-wait shows the wait. - On a failed or stopped turn, `turn-end` went out BEFORE the message was settled, unlike the success path. Anything reacting to it read the turn half-finished; the new api spec passed alone and failed in sequence. `turn-end` is now last on both paths. - The SDK runs a tool ahead of the loop reading the stream, so the loop's late "Analyzing tool result..." overwrote the wait's "Waiting for ...". While a wait is pending, the wait now owns the activity label. Tested at the api level with a mock seam reporting what the model was sent - the server-side counterpart of main's e2e, which read the browser's /chat/completions request. The module lives in api/src, not shared/: it is server-only, and the shared-contract spec enforces that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardens the
wait_for_user_actionhandover against slips seen in judged simulations, and makes the simulation harness more faithful.Why: judged runs showed silent handovers, a garbled handover message, and assistants answering the person's question then never waiting again, so what they handed over went unseen.
Heads-up:
messageis now refused; hosts and prompts should rely on the message.