Skip to content

fix(chat): keep interrupted turns in history and hand waits over with a message - #73

Merged
albanm merged 13 commits into
mainfrom
fix-agents-sims
Sep 30, 2026
Merged

albanm merged 13 commits into
mainfrom
fix-agents-sims

Conversation

@albanm

@albanm albanm commented Sep 30, 2026

Copy link
Copy Markdown
Member

Fixes found by the data-fair judged simulation baseline, in the chat and in the simulation harness.

  • Chat: a turn cut short by the person speaking during a wait, or by Stop, now keeps its finished steps in history; the step still open is recorded with an "interrupted" result naming what the wait was for and asking to wait again if still relevant. Since fix(chat): treat an aborted stream as an abort, not a finished turn #70 such turns were dropped entirely, so the assistant denied work it had done and redid it.
  • Chat: wait_for_user_action takes a required message, shown as the reply to the person (after the step's own text unless it already says it); models used to read the waiting chip as their message and hand buttons over in silence.
  • lib-sim: the persona's page outline is pruned (rows by name, tables capped, toolbars folded) and cut on line boundaries with an optional per-root budget; a send the chat never took voids the run; the persona is told what it actually did.
  • Runner: waits for the turn a person's action resumes, lets the persona read it before stopping; new speak-during-wait case.

Why: in data-fair's baseline three of six cases failed on these defects; with them fixed, all seven data-fair cases pass (Sonnet, one pass).

Heads-up:

  • message is now required on wait_for_user_action: hosts that tell the model to "tell the user, then wait" should point at the message instead (data-fair does so in its companion change).
  • After Stop, the stopped turn's steps now stay in history, answered as interrupted.
  • toolResultOutput mirrors the SDK's unexported createToolModelOutput; recheck on ai upgrades.
  • Simulation verdicts are not comparable with runs from before this change (fuller page view, unsent messages now void a run).

albanm and others added 13 commits September 30, 2026 12:04
Since the abort is rethrown (#70), a turn cut short by the person speaking
during a wait, or by Stop, left before its messages reached history: the
next request carried two user messages and nothing between, and the
assistant denied work it had done and redid it. Track the messages of
finished steps and commit them before the next turn pushes its own, with
the still-open step recorded and its pending calls answered as interrupted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wait tool's only argument was described as shown to the user, and
models took the waiting chip for their reply: judged runs handed buttons
over with no text, and the person had to ask whether they could press.
The tool now requires the message itself, shown as the step's text when
the step wrote none, and kept in history as the call's argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a dataset page a data table and a rich-text toolbar filled the head of
the capped outline, the metadata fields below landed in the cut, and the
persona told the assistant a field it had just filled did not exist. Rows
now keep their name without repeating their cells, tables list 20 rows and
count the rest, toolbars fold to one line, and unnamed images, link targets
and dividers are dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sendMessage returned once Send was clicked. In a judged run three persona
messages stayed in the composer while a wait was armed, no request
followed, and the run still counted nine turns and was judged. The chat
empties its composer when it takes a message, so a send now waits for
that and throws otherwise, voiding the run instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each persona turn is a fresh query remembering only the chat, so it could
not tell its own actions from the assistant's instructions: judged runs had
it report reloading a page it never reloaded. The prompt now lists its
clicks and typing so far and says nothing else was done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t stopped

onStepFinish runs on the SDK's side of the stream and can report a step
finished before the loop has read its parts, so the open step could hold
calls already in the finished messages and put the same call id in
history twice. Reset the open step on the finish-step part the loop reads
and drop calls already finished. Results that arrived in the open step go
through their tool's toModelOutput as the SDK would, and Stop gets its own
interruption text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ady says it

A step that wrote a few words ("Voilà.") before declaring the wait hid its
message, and the handover went unsaid again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The persona asks a question before pressing the button it was handed, so
each run interrupts a declared wait and checks that the assistant answers
from what it already did instead of rebuilding it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… wait

An interrupted wait's result said only that the person had written. A
judged run answered the question and never waited again, so it never
learned the list it had prepared was created. The result now names what
the wait was for and asks to declare it again if it is still to come.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The runner ignored whether a turn ended or was holding a declared wait, so
a persona that clicked the handed-over button and stopped ended the run
before the assistant reacted. Port data-fair's hand-over handling, let the
persona read the resumed reply before stopping, and have speak-during-wait
expect to be told of the creation unprompted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cap cut at a fixed character count, and in a judged run it fell inside
a filled textbox: the persona read the start of the field's value as all
of it, told the assistant its terms were missing and never saved. Lines
are now shown whole or not at all, the marker counts the lines left out,
and a root can take its own budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the fix label Sep 30, 2026
@albanm
albanm merged commit a0f401f into main Sep 30, 2026
3 checks passed
@albanm
albanm deleted the fix-agents-sims branch September 30, 2026 15:28
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