Skip to content

rooms: the agent's reply appears as it is written - #132

Merged
udaychandra merged 18 commits into
mainfrom
agent/mast-live-room-member
Oct 13, 2026
Merged

udaychandra merged 18 commits into
mainfrom
agent/mast-live-room-member

Conversation

@udaychandra

@udaychandra udaychandra commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Spec: mast-live-room-member. Depends on sail-live-room-member (sail 0.47, merged).

A room showed an agent's reply only once the whole message existed. A live member streams its turn, so the room now draws it as it is written: a draft bubble growing under the member's name, a line per tool step, and the posted message taking the draft's place in the same render.

What changed

  • liveTurns.ts — the pure reducer. One draft per (run_id, turn), named by the event's principal; agent_tool_call is one line from summary, running until its finished; agent_turn_ended names the message that replaces the draft. Every payload field is validated, clipped and drawn as text; a room keeps at most eight drafts.
  • The room stream — EventStream gains a room option and a per-instance backfill switch. Gateway.openRoomStream(roomId) opens /v1/events/stream?room=<id>&type=agent_text_delta,agent_tool_call,agent_turn_ended with backfill off; SpecRoom opens one per room and closes it on leaving. No Rust change: validate_stream_path already admits the query.
  • SpecRoom (which ChatRoomPane reuses) — the draft row below the groups, tool lines, replacement on turn end, the typing row yielding to the draft, the error bubble. Drafts live in component state only.
  • App-wide stream — the three types join the telemetry and non-record sets: no refresh, no activity, no event row. SpecDetail's debounced reload skips them too.
  • Demo gateway — a post in a room with a live member schedules a scripted streamed turn through emit, one turn at a time, paced by an injectable scheduler; live events are never added to its stored history.
  • Tests — the fakes get openRoomStream from one helper, test/roomStreams.ts.

The rules a reviewer should hold this to

How a draft leaves (liveTurns.ts):

The turn The draft
ends naming a message is replaced by it, in the render it arrives in; held until then, across later turns
ends clean with no message is removed at once
ends early with no message stays as the muted bubble until another turn begins
has no end told, and another turn begins is dropped
has no end told, the stream dropped and nothing of the turn arrived since yields to a reply its principal wrote at or after the last event of the turn the room saw; never to a question

The last row is the only inference, and it reads the messages the room holds: first load, live fetch, recovery page, load-earlier and gap-fill all pass through applySources, which settles the drafts against them. Six review rounds each found one more ordering of gap, fetch and event wrong while that row was event-by-event bookkeeping with a guard per path; it is now one predicate (replacementOf).

Where a draft is (Draft.behind): a draft has the place a message arriving when its turn began would have. Drawn if the reader followed then; behind the "N new" pill and counted by it if they were away, until they return. The message of a draft the reader can see is drawn where the draft was.

Following (onScroll, keepPlace, and the layout effect beside it):

What happens A reader who follows A reader who is away
They move up at all stops following stays away
They move down to within 48px of the end — follows again; everything behind the pill is released
The room draws something taken to the end in the next frame, if still following in that frame not moved
The engine moves them while laying out what the room drew (a reply shorter than its draft) not read as their move same; and if left resting at the very end, follows again
The room is not on screen nothing is scrolled or remembered nothing

A scroll is told a frame after it happens, when the end may have moved again. So the room takes the reader's place from every layout it draws, and reads a scroll as a move up only against that. The timeline's own scrolls are instant.

One limit of that: a move of the reader's own that no scroll has told of yet when the room draws is taken as part of the layout. In Chromium that never happened across every real input tried. In WebKitGTK it happened once in about 430 trials: one arrow-key step, taken within a millisecond of a delta, was undone, and the next press left. macOS WKWebView scrolls on its own thread and could not be tested here; if it tells scrolls later than WebKitGTK does, a single step during a stream is undone more often. A gesture of several events always leaves.

Departures from the spec, and decisions for Uday

  1. Live events do carry an id. The spec says none does; sail stamps the bus id on every frame, and that counter starts at 1 again whenever sail starts. A stream with backfill off therefore keeps no cursor at all, or an open room would draw nothing after a box restart. The app-wide stream has the same exposure and predates this PR: draft mast-event-cursor-survives-a-box-restart.
  2. R2's last sentence, "when the reader is not at the bottom the draft is not drawn". Implemented literally (hidden whenever the reader is away) the row's presence changed the geometry it was derived from, and the room strobed about fifteen times a second in a real engine. It is decided once, when the turn begins. A reader who followed when a turn began and then scrolled away keeps the draft, and gets no pill when its reply lands: the reply is drawn where the draft was.
  3. The timeline no longer glides. scroll-behavior: smooth is gone from .room-timeline. A glide restarted on every delta swallowed the wheel during a fast stream and let the view fall more than 1,000px behind. The pill's jump and a new message's arrival are instant too.
  4. R4 holds on the first turn of a run only. The typing row shows while the member's newest reply is older than its run's start, and a live member is one run across many turns: from the second turn on, nothing is drawn between the send and the first delta. This is inherited and unchanged; fixing it needs sail to say when a turn begins. Draft mast-draft-yields-to-its-own-reply (revised) has sail tell a turn's beginning and, on connect, which turn is open, which also retires the inference above and its limits (listed there).
  5. An error turn's bubble. When the box posted the partial text, the draft is replaced by that message, which carries the muted style and "the agent's turn ended early · " while the room is open. With nothing posted, the draft itself is the muted bubble until the next turn begins.
  6. Reaching the bottom releases the pill, as clicking it does (scenario 14). One existing scroll test now moves the reader up from the bottom, as a real reader does, since only moving up leaves.
  7. Older defects found on the way, not touched here: draft mast-room-fetches-follow-the-room (a room swapped in place keeps asking the old room; a failed post forgets a deferred reply; nothing settles the reader's place when only the window or the composer changes size, which in an engine that anchors scrolls can leave a reader who follows stranded after an idle resize).

Verification

  • bun run typecheck, bun test (1,378 pass, 0 fail), bun run build:tauri-web green locally; every non-test line this PR adds is executed by a test.
  • Scenarios 1–14 and 3b are tests: the reducer for every event order, SpecRoom and ChatRoomPane against a fake room stream, the demo's scripted turn, the Tauri wire, and the stream's room/backfill options.
  • happy-dom has no layout, so the scroll and placement rules were driven against the real component and CSS in headless Chromium (51 scenarios, with and without scroll anchoring) and, for the ones that matter most, in WebKitGTK 2.52 with real wheel and key input: no row removed or re-added while a turn streams; the view within 39–83px of the end at 83–646px/s of growth; a wheel, a trackpad-sized step and an arrow key each leaving at every stream rate; the reply replacing a draft with no frame showing neither; a reader who never moved still following when a reply shrinks the room and anything grows it in the same frame (0 of 96 runs in Chromium, 0 of 48 in WebKitGTK); a window dragged taller during a stream; a 60 s soak at 20 events a second with no frame breaking the table above. The scripts are in ~/workspace/sail-audit/2026-10-13-mast-room-harness/ on the devbox.
  • 59 mutants over the reducer, the scroll code, the stream cursor and the demo: 58 killed; the survivor only changes how often a no-op runs.
  • Six independent reads of the takeover's changes. The last, on the commit before the final comment-only one, found nothing high and nothing at medium that this PR introduces.
  • Not exercised: macOS WKWebView, and the real app against a live box. tauri dev is macOS-only and this was built on Linux.

A room showed an agent's reply only once the whole message existed: three
pulsing dots, then the text. A live member (sail 0.47) streams its turn, so
the room now opens one stream of its own beside the app-wide one,
/v1/events/stream?room=<id>&type=agent_text_delta,agent_tool_call,agent_turn_ended,
and draws the turn as it happens: a draft bubble growing under the member's
name, a line per tool step, and the posted message taking the draft's place
in the same render, so a turn is never drawn twice and never not at all.

- liveTurns.ts is the pure reducer: one draft per (run_id, turn), named by
  the event's principal, every payload field clipped and drawn as text.
- EventStream gains a `room` filter and a per-instance backfill switch, off
  for the room's stream: the box never stores what it carries.
- A stream gap keeps what arrived and marks where text went missing; if the
  gap swallowed the turn's end, the message its principal posted still
  replaces the draft.
- A turn that ended early is told on the draft, and on the message it
  became, with the box's reason.
- The typing row yields to the draft; a reader scrolled up sees neither, the
  "N new" pill counts the turn, and reaching the bottom releases what the
  pill held.
- On the app-wide stream the three types are telemetry: no refresh, no
  activity, no row, and no spec reload.
- The demo gateway scripts a streamed turn after a post in a room with a
  live member, paced by an injectable scheduler.
…o a question

A turn end and its message's event can both be lost when the room's stream and
the app-wide stream drop together. The reconnect gap-fill recovered the event
from the spec's lane but never told the live turns, so the final message was
drawn beside the stale draft. The gap-fill now folds a recovered message event
into the live turns as the live handler does.

The gap-fill's watermark trails the message events the live handler took, so
the drain tells them again. A message the room held before the gap is skipped
there: without that, the reply of the turn before would take the place of a
draft still being written.

A question the member asks mid-turn is not the turn's end. It was recorded as
the message that replaces the draft, so a later stream drop removed the partial
reply while the turn went on. A message event marked as a question no longer
counts.
… being written

The app-wide handler folded a message event into the live turns before it
rejected one the room already held, so a replay of the turn before's reply
after a room-stream gap was recorded as the open draft's replacement and
removed it. The held check now runs first, as it does in the gap-fill.
…aft whose end was lost

A message fetch started by an earlier message could load the member's reply before the
reply's own event arrived. The event was then dropped as one for a message the room holds,
so nothing recorded the reply against the gapped draft and both stayed drawn.

Every message enters the room through applySources, so a live arrival is told to the live
turns there, by the same rule the event follows: the principal's own message, never a
question, never an optimistic one, never one the room held or paged back to.
A fetch can bring a reply of an earlier turn the room never held: the recovery
page reaching below the loaded window, or the first load after one that failed.
With a gapped draft open, that reply took the draft's place and the partial text
was lost. A draft now remembers the time of the first event that opened it, and
a message is its reply only when written at or after that, whether it came by
event or by fetch. A time that cannot be read replaces nothing.
A draft whose end was lost stays open beside the turn after it while its
message is fetched. The next turn's reply was recorded on that older draft,
so with both ends lost the newer draft stayed under its own posted reply.
The reply now goes to the newest open draft whose first event is not after
it; an earlier reply still reaches the older draft.
Six review rounds each found one more ordering of gap, fetch and event in
which a draft whose turn end was lost either stayed beside its reply or
left for an earlier turn's. The room recorded, event by event, which
message a turn had posted, and every path a message can take into the
room needed its own guard.

The draft now reads the room instead. A turn's end names its message, as
before. A draft a stream gap touched, with no end told, yields to a reply
its principal wrote since the turn's first event, whenever and however
the room came to hold it: first load, live fetch, recovery page, gap
fill. The live turns are settled against the room's messages on every
change to either, so no ordering is special.

Gone with it: the `posted` hint on a draft, `messagePosted`, the message
event's branch in the reducer, and the three per-path guards in the room.
A gapped draft now stays beside the turn after it until a reply takes its
place, rather than only when a message event had been seen for it.
…s it

A review of the whole change in a real engine and against the reducer
found five things.

The draft row strobed. It was drawn only for a reader at the bottom, and
the bottom was measured with the row in it: within a draft's height of
the end the row left and came back about fifteen times a second until
the turn's message arrived. A draft is now drawn at the end of the
timeline wherever the reader is, as the typing row is, and the pill
counts messages only.

The room's own glide to a bottom that had just moved passed through
places that were not the bottom, so a burst of large deltas left the
reader behind for the rest of the turn. A reader now stops following
only by moving up.

A room stream kept a cursor of event ids, and the box's ids count from
one again whenever it starts: after a box restart an open room drew no
turn until it was left and entered again. A stream with backfill off
keeps no cursor.

A draft a gap had touched outlived the turn after it until its principal
replied, which a member that died with its box never does. A turn that
begins now ends every draft with no end told; one whose end named a
message still waits for it.

A note the member posted mid-turn took the draft's place once the stream
had dropped, even when the turn had gone on since. An event of a turn
now says the turn is still open, and a reply is one written since the
last of the turn the room saw.

Also: an event that writes nothing opens no draft, a room keeps at most
eight drafts, and the demo member writes one turn at a time.
A second read in a real engine found the room still moving a reader who
had not moved, and a reader unable to move.

A reader scrolled up inside a draft was thrown to the bottom when its
turn ended: the reply went behind the pill, the draft left, and what was
below them shrank to nothing. A draft now has one place for its whole
life, decided when its turn begins as a message's is when it arrives:
drawn if the reader followed then, behind the pill and counted by it if
they were away, until they return. The message of a draft the reader can
see is drawn where the draft was, wherever they are.

Each delta began another glide to the bottom, and a glide in flight
swallowed the wheel: during a fast stream the reader could not leave,
and above about 400px a second the view fell behind without bound. The
timeline no longer animates its own scrolls. A reader follows until they
move up at all, told from the room's own last move, and again once back
within reach of the latest; the latest moving away is never leaving.

Also: the demo member that owes a turn answers only while still in the
room, and its tests cannot spin.
…ey follow

A fourth read found the room pulling back a reader who had just left.
Its move to the latest was queued when a delta was drawn and ran a frame
later whatever the reader had done in between, so a single wheel notch
during a stream was undone, and near sixty deltas a second the reader
could not leave at all. The room now looks again in that frame: a reader
who still follows is taken to the latest, one who has left is let be.

The same step settles two cases no scroll tells of. A reader left
resting at the very end, because what was below them shrank until the
room fit its window, follows again; before, the next turn waited behind
a pill that no scrolling could release. And a room that is not on screen
keeps no place, so its first move up after it returns is still read as
one.
…ey came to be there

Following again released the drafts that waited behind the pill but left
the buffered messages there unless the reader had scrolled to the end,
so a reader the room found resting at the end of a room that now fits
its window followed with a pill still showing. Following is now what
releases both, in one place, and the scroll handler has no case of its
own for it.
…ts scroll is told

A fifth read found a reader who never moved dropped out of following.
When a reply shorter than its draft takes the draft's place, the engine
rests a reader who was past the new end on it, lower than they were, and
tells of that scroll a frame later. If anything grew the room in
between, the scroll read as the reader moving up to somewhere short of
the end. The frame after used to pull them back by accident; since the
room stopped moving a reader who has left, nothing did.

The room now reads its own layouts as it makes them, and its window's
as they are told: a reader resting at the very end did not move up to
get there, and follows. So the scroll that arrives later is no move up,
whatever the end has done since. That is also where a reader left at
the end of a room that now fits its window follows again, which the
frame did before; and a window that grows taller, or a room that comes
back on screen, is settled the same way.

Following releases what is behind the pill only on the return, so a
scroll at the latest draws nothing again.
Reading a shrink off the reader resting at the very end missed the case
where the engine leaves them somewhere else: with the next turn's first
delta already drawn below, an engine that anchors scrolls put a reader
who never moved short of the end when a reply took the draft's place,
and the scroll that told of it read as a move up.

The room now notes where the reader is before it draws and where they
are once it is laid out, and takes exactly that difference off what it
remembers of their place. Whatever the engine did, and wherever it left
them, the scroll told later is a move up only if the reader made one.
A window's resize has no before to read, so it keeps the rule it had.
…old from

Measuring a layout's effect needed the reader's place before the room
drew, and reading it while rendering made the engine lay out there and
then: a window being dragged taller clamped the reader at that read,
where nothing took it off, and a reader who never scrolled stopped
following. The room now looks only after it has drawn. Wherever that
layout left the reader is their place, so the scroll told later for it
is no move at all. An engine tells a reader's own scroll before anything
else runs in the frame it learns of it, so there is none of theirs
waiting to be taken for the layout's.
The observer was added for a window dragged taller during a stream. Now
that the room takes its place from every layout it draws, a turn being
written covers that case by itself: ten of ten runs in the engine still
follow without it. What it alone did was older and outside this change,
a pill left over a room that a growing window made fit and a follower
not taken to the end when a hidden room returns, each healed by the
next thing drawn, as on main.
@udaychandra
udaychandra merged commit e8530f6 into main Oct 13, 2026
2 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.

1 participant