Skip to content

Client: hydrate ai-bot to-device streaming previews into room messages#5570

Merged
lukemelia merged 3 commits into
mainfrom
cs-12264-client-subscribe-to-appboxelresponse-stream-to-device-events
Jul 22, 2026
Merged

Client: hydrate ai-bot to-device streaming previews into room messages#5570
lukemelia merged 3 commits into
mainfrom
cs-12264-client-subscribe-to-appboxelresponse-stream-to-device-events

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

Consume ai-bot's app.boxel.response-stream to-device streaming previews on the client so the browser renders streaming UX when ai-bot runs in AI_BOT_STREAMING_MODE=to-device — without a Matrix room event per throttle window.

What this does

  • Central binding (matrix-service). All raw-client event bindings live in matrix-service, so it binds ClientEvent.ToDeviceEvent centrally, filters for app.boxel.response-stream, and fans payloads out to RoomResources that register via a new onResponseStreamPreview(handler) (returns a disposer). Each handler self-filters by roomId. This deviates from the ticket's literal "matrixClient.on in room.ts" because the raw client is private and centralizing bindings is the established pattern.
  • Reconciliation (room.ts). RoomResource registers in modify() / disposes in teardown(). hydrateResponseStreamPreview() shapes each preview as the CardMessage edit it mirrors and runs it through the existing MessageBuilder.updateMessage path, reusing its tool-request chunk merging.
  • Ordering. The synthetic event's origin_server_ts is pinned to the message's creation time so a preview never advances past — and thus never suppresses (via applyToolRequestChunk) — the real, later final room edit. A per-message sequence gate drops out-of-order / duplicate previews; payloads carry full accumulated state, so last-writer-wins is safe.
  • Stall timeout. No room-message.gts change was needed: updateMessage stamps a fresh updated on 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.ToDeviceEvent on 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 the app.boxel.response-stream event schema this consumes.

Test plan

  • Typecheck (lint:types) clean for all changed files.
  • ESLint / template-lint clean.
  • Host integration suite — not run locally (needs the full realm + Matrix server harness); relying on CI.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   3h 2m 18s ⏱️ + 26m 8s
3 593 tests +1  3 578 ✅ +1  15 💤 ±0  0 ❌ ±0 
3 612 runs  +1  3 597 ✅ +1  15 💤 ±0  0 ❌ ±0 

Results for commit 4a5ff8b. ± Comparison against earlier commit 4377f26.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   13m 2s ⏱️ +10s
1 919 tests ±0  1 919 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 998 runs  ±0  1 998 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 4a5ff8b. ± Comparison against earlier commit 4377f26.

@lukemelia
lukemelia changed the base branch from cs-12261-response-stream-event-schema to main July 22, 2026 03:18
@lukemelia lukemelia assigned jurgenwerk and FadhlanR and unassigned jurgenwerk and FadhlanR Jul 22, 2026
@lukemelia
lukemelia requested review from a team, FadhlanR and jurgenwerk July 22, 2026 03:20
Comment thread packages/host/app/resources/room.ts Outdated
Comment thread packages/host/app/resources/room.ts
Comment thread packages/host/app/resources/room.ts Outdated
Comment thread packages/host/app/resources/room.ts Outdated
Comment thread packages/host/app/resources/room.ts
…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 habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.ToDeviceEvent binding in matrix-service and fanning out via onResponseStreamPreview disposers matches the existing binding pattern and unbinds cleanly in unbindEventListeners / teardown — no listener leak.
  • Reusing updateMessage by shaping the preview as the CardMessage edit it mirrors is the right call: pinning origin_server_ts to message.created genuinely makes applyToolRequestChunk's ts guard protect the final edit's tool args from a late preview (verified — the guard blocks created < finalTs).
  • The "stall timeout resets for free" claim checks out: streamingTimeout reads Number(this.message.updated) and updateMessage stamps a fresh updated, so no room-message.gts change 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

  1. (Blocking) Re-check message.isStreamingOfEventFinished at the top of applyResponseStreamPreview — inline on room.ts.
  2. Add a tool-request preview test that would fail without #previewApplyChain, and tighten the weak updated assertion — inline on the test file.
  3. (Follow-up) Verify the to-device sender is the ai-bot in onToDeviceEvent — inline on matrix-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

Comment thread packages/host/app/resources/room.ts
Comment thread packages/host/app/services/matrix-service.ts
@lukemelia
lukemelia marked this pull request as ready for review July 22, 2026 17:22
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>
@lukemelia
lukemelia requested a review from a team July 22, 2026 17:41
@lukemelia
lukemelia merged commit 745e0bd into main Jul 22, 2026
71 of 73 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.

4 participants