Client: hydrate ai-bot to-device streaming previews into room messages#5570
Conversation
When ai-bot runs in AI_BOT_STREAMING_MODE=to-device it streams in-flight response state as ephemeral `app.boxel.response-stream` to-device messages targeted at the originating device, landing only the final consolidated state as a room edit. Consume those previews on the client so the browser renders streaming UX without a room event per throttle window. matrix-service owns all raw-client event bindings, so it binds ClientEvent.ToDeviceEvent centrally and fans `app.boxel.response-stream` payloads out to RoomResources that register via onResponseStreamPreview; each handler self-filters by roomId. RoomResource shapes each preview as the CardMessage edit it mirrors and runs it through the existing MessageBuilder.updateMessage path, reusing its tool-request chunk merging. Pinning the synthetic event's origin_server_ts to the message creation time keeps a preview from suppressing the later, authoritative final room edit; a per-message sequence gate drops out-of-order or duplicate previews (payloads are full accumulated state, so last-writer-wins). updateMessage stamps a fresh `updated` on every applied preview, which is the value room-message.gts compares against for the streaming stall timeout, so long responses reset the timeout for free. Adds mock-matrix to-device support (simulateToDeviceEvent) and an integration test covering hydration, the sequence gate, and unknown-parent / cross-room no-ops. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 3h 2m 18s ⏱️ + 26m 8s Results for commit 4a5ff8b. ± Comparison against earlier commit 4377f26. Realm Server Test Results 1 files ±0 1 suites ±0 13m 2s ⏱️ +10s Results for commit 4a5ff8b. ± Comparison against earlier commit 4377f26. |
…tracking Address self-review findings on to-device streaming preview hydration: - Serialize preview applies per streaming message (parentEventId) so a new tool id's async build resolves tool args in sequence order rather than promise-completion order. All synthetic events pin the same origin_server_ts, so applyToolRequestChunk's timestamp guard cannot reorder two overlapping previews. - Wrap the apply in try/catch and log at debug. The handler is fire-and-forget, so an updateMessage throw (async skill/loader resolve for a new tool id) would otherwise become an unhandled rejection; a dropped preview is harmless since the final room edit reconciles. - Prune #lastPreviewSequence / #previewApplyChain opportunistically once a message finalizes, bounding per-turn growth in long-lived rooms. - Document that a preview intentionally owns only body/reasoning/tool requests; other fields updateMessage writes reset until finalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Reviewed with a focus on the concurrency contract between the new to-device preview path and the existing room-event path, since that's where this change is load-bearing and where the self-review threads concentrated. Checked out at 4377f26; traced hydrateResponseStreamPreview / applyResponseStreamPreview against MessageBuilder.updateMessage, applyToolRequestChunk, the Message streaming-finished / updated getters, and room-message.gts's stall-timeout getter. CI (host / realm / matrix suites) is green on the head commit.
Bottom line: one blocking correctness issue — a preview whose apply is delayed past the final room edit un-finishes and regresses the completed message (details inline on applyResponseStreamPreview). The rest is a test-coverage gap on the two trickiest behaviors and one non-blocking hardening suggestion. The overall design is sound and the self-review already resolved the highest-value issues.
What lands right.
- Centralizing the
ClientEvent.ToDeviceEventbinding in matrix-service and fanning out viaonResponseStreamPreviewdisposers matches the existing binding pattern and unbinds cleanly inunbindEventListeners/teardown— no listener leak. - Reusing
updateMessageby shaping the preview as theCardMessageedit it mirrors is the right call: pinningorigin_server_tstomessage.createdgenuinely makesapplyToolRequestChunk's ts guard protect the final edit's tool args from a late preview (verified — the guard blockscreated < finalTs). - The "stall timeout resets for free" claim checks out:
streamingTimeoutreadsNumber(this.message.updated)andupdateMessagestamps a freshupdated, so noroom-message.gtschange was needed. - Per-message serialization is the correct shape for the tool-arg ordering problem given the shared
origin_server_ts, and isolating apply failures behind try/catch is right for a fire-and-forget handler.
On the resolved threads. The serialization, failure-isolation, pruning, and field-clobber-documentation fixes all landed correctly and I agree with them. The one declined thread (isStreamingFinished: false) is where the blocking issue lives — the decline is half right (omitting the key doesn't fix it) but the residual race is still open and needs the apply-time re-check, not a change to the literal. Detail in the inline comment.
Recommendations
- (Blocking) Re-check
message.isStreamingOfEventFinishedat the top ofapplyResponseStreamPreview— inline onroom.ts. - Add a tool-request preview test that would fail without
#previewApplyChain, and tighten the weakupdatedassertion — inline on the test file. - (Follow-up) Verify the to-device sender is the ai-bot in
onToDeviceEvent— inline onmatrix-service.ts.
Adjacent, out of scope. applyResponseStreamPreview calls upsertRoomMember for aiBotUserId on every apply, but updateMessage never reads builderContext.author (only buildMessage does) — so that's dead work per preview. Harmless; not asking you to change it here.
Generated by Claude Code
Re-check isStreamingOfEventFinished at preview-apply time, not only at the synchronous enqueue gate: an apply queued behind an awaiting predecessor could run after the final room edit finalized the message and un-finish it with stale body/reasoning, leaving it stuck "streaming" until the stall timeout tripped. Verify the to-device sender is the ai bot before hydrating a preview — a to-device message can be delivered by any Matrix user, unlike the trusted in-room room-event path. Cover the tool-request path through the per-message apply chain, assert an applied preview strictly advances the message updated timestamp, and drop a spoofed-sender preview. Thread an optional sender through the to-device mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consume ai-bot's
app.boxel.response-streamto-device streaming previews on the client so the browser renders streaming UX when ai-bot runs inAI_BOT_STREAMING_MODE=to-device— without a Matrix room event per throttle window.What this does
matrix-service, so it bindsClientEvent.ToDeviceEventcentrally, filters forapp.boxel.response-stream, and fans payloads out toRoomResources that register via a newonResponseStreamPreview(handler)(returns a disposer). Each handler self-filters by roomId. This deviates from the ticket's literal "matrixClient.onin room.ts" because the raw client is private and centralizing bindings is the established pattern.RoomResourceregisters inmodify()/ disposes inteardown().hydrateResponseStreamPreview()shapes each preview as theCardMessageedit it mirrors and runs it through the existingMessageBuilder.updateMessagepath, reusing its tool-request chunk merging.origin_server_tsis pinned to the message's creation time so a preview never advances past — and thus never suppresses (viaapplyToolRequestChunk) — the real, later final room edit. A per-messagesequencegate drops out-of-order / duplicate previews; payloads carry full accumulated state, so last-writer-wins is safe.room-message.gtschange was needed:updateMessagestamps a freshupdatedon every applied preview, which is exactly the value the streaming stall-timeout getter compares against — so long responses reset the timeout for free.Test coverage
mock-matrix had no to-device support, so this adds
simulateToDeviceEvent(mock client +ClientEvent.ToDeviceEventon the mock SDK +MockUtils), plus an integration test covering preview hydration (body + reasoning), the sequence gate dropping stale previews, and unknown-parent / cross-room no-ops.Base
Stacked on
cs-12261-response-stream-event-schema, which defines theapp.boxel.response-streamevent schema this consumes.Test plan
lint:types) clean for all changed files.🤖 Generated with Claude Code