Skip to content

fix(turn): keep a resumed session stream live for a running turn - #237

Merged
modacker merged 2 commits into
webuifrom
fix/agent-transcript-queue-drain
Oct 23, 2026
Merged

modacker merged 2 commits into
webuifrom
fix/agent-transcript-queue-drain

Conversation

@modacker

Copy link
Copy Markdown
Collaborator

What broke

packages/webui/test/integration/agent-transcript.test.ts asserts that a message queued behind an active turn is delivered afterwards, in order, and that the queue row disappears once it drains. On CI that case fails roughly half the time, and it fails on the unmodified base commit as well — it is not a regression of any open change, it is a product defect that Jiaxiaoyuan's 97e33fd8 ("resolve queue drain race after turn terminal release") did not actually resolve.

The browser ends up with no trace of the drained turn at all: the queued reply is never rendered and the queue row stays on screen. Two independent defects produce that.

1. The runtime decides "replay only" from a racy projection. resumeSession used the session's persisted status to decide whether a resumed stream should be live or replay-only. That status is an asynchronous projection of state events, so while a turn is running it still reads idle — and for a drained turn especially so. The runtime therefore served a replay-only stream and closed it before the queued reply was committed, so that reply never reached the browser. The runtime contradicts itself in the same breath: the client's getActiveTurn answered busyReason: "turn" (a turn is active) while resumeSession answered status="idle".

2. The browser loses every event published before its event channel opens. The event channel is a separate socket from the request channel. In the failing runs the server completed the handshake in 11ms while the browser's open event only fired 3.4s later; every event published in that window — including session.start for the drained turn and three session.queue.updated events — was lost. The page therefore never learned the turn had started, and the queue row it was already showing never got told to go away.

What this changes

  • packages/local-runtime-v2/src/application/conversation/conversation-application.ts — the liveness of a resumed stream now comes from the authoritative active turn (turnInspection.activeTurnId, the same answer the client's getActiveTurn gives) instead of the racy persisted status.
  • packages/local-runtime-v2/src/service/session-system/stream/session-stream-service.ts — the service remembers which turn published the last terminal event, so a resumed stream that follows an already-finished turn ends at the replay instead of waiting forever for a terminal that will never come. This closes the hang window the first change would otherwise open.
  • packages/webui/src/client/application/create-application.ts — when the event channel becomes ready the client converges on the authoritative state: it re-reads the transcript head (a turn that finished inside the window is in no probe result) and re-reads the queue (an item drained inside the window leaves no residual row).

The guarantee only gets stronger: a queued message is delivered in order and the queue is emptied. The failing test file is not touched — git diff 97e33fd8 -- packages/webui/test/ is empty. No skip, no retry, no widened timeout, no weakened expectation.

How it was checked

The defect was reproduced locally on the pristine base commit (the macOS form of the failure: getByText('Queued follow-up delivered.') — element not found), and instrumented in three layers to separate "the runtime never published it" from "the browser never received it":

observation result
runtime published session.queue.updated x3, session.start (queued drain), session.finish all present
global events the browser actually received none of them
event channel socket server handshake done in 11ms, browser open fired 3.4s later
getActiveTurn vs resumeSession busyReason: "turn" vs status="idle"

With the fix in place:

  • the target case, run repeatedly: 14 consecutive passes (the base fails about half the time);
  • test:webui whole group, CI-equivalent (213 files): 213 files, 3264 passed, 34 skipped;
  • test:capability (covers local-runtime-v2): 237 files, 5815 passed;
  • policy / status-contract / sandbox: green (142 / 9 / 48); typecheck, typecheck:webui, check:source, check:webui-boundary, check:webui-dependency: green;
  • deterministic red: the new session-stream-service.test.ts disables the end-of-stream logic on purpose and fails with Error: the resumed stream did not end — a condition failure, not a timeout — and passes again once restored.

Not addressed here

  • Why the browser's event channel opens seconds late. The server's handshake completes immediately; the delay is on the browser side (suspected: one websocket per request contending for the per-origin connection budget). This PR does not change the transport. It makes the page converge on the authoritative state once the channel is ready, which is the lowest-risk fix — but treating the cause would mean a transport change (reusing a single connection), which is a larger piece of work.
  • The activeTurnId half has no unit test. The new unit test covers the stream-service end-of-stream semantics; the runtime's active-turn lookup is covered only end to end by the integration case.
  • The refresh uses the selected session id. channelReady refreshes the transcript for store.getSelectedSessionId(). If the view ever presents a session other than the selected one, that could in principle switch the presentation slot. The two agree on every path exercised here, and no counter-example was constructed.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable. — release/public-source.json was regenerated with node scripts/source-inventory.mjs --write and test/vitest-suites.json declares the new test file.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. — No document describes the old behaviour, so none needed updating.

Maintainer handoff

Publication scope or license changes (if any): none.

Shared-source port: not needed.

worker and others added 2 commits October 23, 2026 13:08
The agent-transcript integration case was red on CI about half the time: a
queued message that drained while the page was reattaching never showed its
answer, and the queue row it left behind never cleared.

Two product defects, both about the page missing what it was not told.

The runtime decided whether a resumed session stream is live or a replay
from the session's persisted status. That status is an asynchronous
projection of the runtime's status events, so it still read "idle" while a
turn ran — for a queue-drained turn that is exactly what happens. The
client had already asked whether a turn was running (`getActiveTurn`), the
runtime said yes, and the stream it was then given replayed the history and
closed, dropping the answer the drained turn committed a moment later. The
runtime now reads the authoritative active turn as well, so the attach and
the stream agree.

A stream that waits for a terminal must not wait for one that can no longer
arrive: the stream service records the turn of the last terminal it
published, and a resume that follows that turn ends at its replay instead.

The browser's event channel is a separate socket that can take seconds to
open, and every event published before it opens is lost — the runtime
publishes them, the page never sees them. The page therefore re-reads the
two things those events would have told it when the channel becomes ready:
the transcript head (a turn that ran and settled in that window is in no
probe's answer) and the queue (an item that drained in it leaves a stale
row). The guarantee is unchanged and strengthened: a queued message is
delivered in order, and the queue empties.
The macOS CI leg of this gate failed at the first screen: `#webui-root` was
still the empty mount div when the default 5s deadline expired, so the case
reported a red about a shell that had not booted yet.

The client boots by asking the host for its version and rendering the shell
when the answer arrives, and that request carries the wire's own 30s
deadline (`DEFAULT_REQUEST_TIMEOUT_MS`): past it the client renders the
startup fallback instead of the shell, so no shell can arrive later than
that. The wait therefore moves to the composer — the page's own input
surface, which can only be on screen once the shell has rendered — and is
given that same deadline. The mount node's own assertion stays.

The macOS CI run of the whole 213-file webui group measured this case at
26.7s with the shell still not up when the 5s deadline expired, against
12.0s for the same case on ubuntu; the group itself measured 160.9s on
macOS against 76.9s on ubuntu. The case's own 120s budget still has room
for the rest of the case: about 21.6s of setup ran before that wait, and
the rest of the case needs about 25s at the same ratio, so a wait that runs
to its deadline still ends the case around 77s — the budget is never the
thing that catches the failure.

The reloaded page is a second cold start of the same client, so it waits on
the same condition with the same deadline. No expectation changes: the queue
drain this case is about is asserted exactly as before.
@modacker
modacker force-pushed the fix/agent-transcript-queue-drain branch from 7c26141 to c4c0997 Compare October 23, 2026 05:41
@modacker
modacker merged commit 2d13779 into webui Oct 23, 2026
8 checks passed
@modacker
modacker deleted the fix/agent-transcript-queue-drain branch October 23, 2026 06:01
modacker added a commit that referenced this pull request Oct 23, 2026
…de packages/webui (#240)

The webui branch is a downstream client of MiniMax-AI/minimax-code. Changes
to the upstream packages do not flow back upstream and turn into unreviewed
divergence at the next upstream sync, so they do not belong here.

This removes the upstream-package half of the work merged today:

- packages/local-runtime/src/turns/file-changes.ts (#232, #234)
- packages/agent-tools/** (#238)
- packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts (#238)
- packages/local-runtime-v2/src/application/conversation/conversation-application.ts
  and .../stream/session-stream-service.ts (#237)

Everything under packages/webui stays: the queue-drain client half, the
regenerated visual baselines, the browser test budgets and the CJK font gate.

The inventories follow the files: eight removed paths leave
release/public-source.json and six removed suites leave
test/vitest-suites.json.

Co-authored-by: probe <probe@local>
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.

1 participant