Skip to content

fix(chat): harden the wait handover against model slips - #75

Merged
albanm merged 6 commits into
mainfrom
chore-agents-sim-wait
Oct 1, 2026
Merged

albanm merged 6 commits into
mainfrom
chore-agents-sim-wait

Conversation

@albanm

@albanm albanm commented Oct 1, 2026

Copy link
Copy Markdown
Member

Hardens the wait_for_user_action handover against slips seen in judged simulations, and makes the simulation harness more faithful.

  • Chat: wait arguments a model serialized into the message (a Haiku run sent the JSON tail as text) are repaired for display and for the wait itself.
  • Chat: a wait declared without a message is refused instead of waited on, so the model retries; the schema required it but the SDK does not enforce it.
  • Chat: after the person interrupts a wait, the reminder to declare it again now closes the new turn's hidden context instead of sitting in the interrupted wait's tool result, ahead of the person's message in history.
  • lib-sim (0.9.0): a look after a click or typing reads until the page settles (max 3 s), so a persona no longer reports a link "did nothing" before the route changed.
  • Runner: a persona message composed before the reply its own action caused is dropped and the persona asked again.

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:

  • A wait without message is now refused; hosts and prompts should rely on the message.
  • With the reminder, Sonnet re-declared the wait after 5/5 interruptions, Haiku after 1/5: for small models it is not enough on its own.
  • The mock model now sends a message with its waits.

albanm and others added 6 commits September 30, 2026 20:30
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>
@github-actions github-actions Bot added the fix label Oct 1, 2026
@albanm
albanm merged commit a96415c into main Oct 1, 2026
4 checks passed
@albanm
albanm deleted the chore-agents-sim-wait branch October 1, 2026 13:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant