Wire the ai-bot to-device streaming channel end-to-end (ai-bot side)#5558
Merged
Conversation
In `off` mode the responder emits only the initial thinking placeholder and the final consolidated `m.replace` edit at stream end, cutting room events per response from ~600 to 2. Default (`room-edits`) is unchanged. Cancellation and final-event semantics (isStreamingFinished, isCanceled, continuation chaining) route through the same path in both modes — only the throttled onChunk send is gated. Phase 1 of CS-12124. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines APP_BOXEL_RESPONSE_STREAM_EVENT_TYPE and the AppBoxelResponseStreamContent interface for the to-device channel that will carry live streaming previews of the bot's response. The final consolidated state still lands as a room event; to-device previews are ephemeral by design. Wiring senders (ai-bot) and receivers (host) is deliberately deferred to CS-12263 and CS-12264 so those changes stay reviewable in isolation. Part of CS-12124. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds APP_BOXEL_ORIGINATING_DEVICE_ID_KEY and stamps the sending device id onto every boxel user-prompt event (`sendMessage` in matrix-service). Gives the ai-bot a stable target for the coming app.boxel.response-stream to-device previews so a user with multiple devices only gets the live preview on the originating one, not fanned out to every session. No behavior change until the ai-bot reads the field (CS-12263). Older prompt events without the field simply won't receive previews. Part of CS-12124. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Preview deploymentsHost Test Results 1 files 1 suites 2h 58m 2s ⏱️ Results for commit 1661968. Realm Server Test Results 1 files ±0 1 suites ±0 12m 0s ⏱️ +44s Results for commit 1661968. ± Comparison against earlier commit c5ad13b. |
…nse-stream-event-schema
When AI_BOT_STREAMING_MODE=to-device, throttled state changes route to matrixClient.sendToDevice() carrying an app.boxel.response-stream payload targeted at the (userId, deviceId) that composed the prompt, instead of landing as m.replace room edits. The final consolidated event still lands as a room edit (routed via flush → sendMessageEvent, not the throttled internal) so durable state is always in the room — to-device previews are ephemeral. When to-device mode is requested but the prompt lacks the originating device id (older client that predates CS-12262), the responder falls back to `off` behavior: no mid-stream sends, one final room edit. This keeps rollout independent of client version. Preview payloads: full accumulated body/reasoning/toolRequests + a monotonic per-turn sequence. Last-writer-wins by sequence; gaps or reordering are non-fatal because payloads are not deltas. Closes CS-12263. Part of CS-12124. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In off mode (and to-device with no preview target), the mid-turn send is gated off. Previously the terminal chunk still flipped responseState.isStreamingFinished inside onChunk, so finalize saw no transition and skipped its send too — the room only got the thinking placeholder. Defer the flag transition to finalize when we're not streaming mid-turn. Extend the off-mode test with a terminal finish_reason: 'stop' chunk to pin the behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukemelia
commented
Jul 21, 2026
lukemelia
commented
Jul 21, 2026
lukemelia
commented
Jul 21, 2026
lukemelia
commented
Jul 21, 2026
lukemelia
commented
Jul 21, 2026
matrix-js-sdk's sendToDevice takes a Map<userId, Map<deviceId, content>> and iterates it internally, so the plain nested object the Responder passed threw `TypeError: contentMap is not iterable` at runtime — swallowed by the surrounding try/catch, so every preview send failed silently. The `as any` cast hid the type error. Build the real Map instead. Harden the tests so this can't regress silently: - FakeMatrixClient.sendToDevice now rejects a non-Map contentMap (mirroring the real SDK) and normalizes via recursiveMapToObject for assertions, so a plain-object payload fails the unit suite the way it fails in production. - Add a matrix e2e spec that drives a real matrix-js-sdk client against Synapse: it delivers a response-stream preview to the target device and asserts the real SDK rejects a plain-object contentMap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Normalize preview toolRequests through the same toCommandRequest() the room
event uses, so a client reads tool calls identically on both channels
({ id, name, arguments: <object> }) instead of the raw OpenAI
{ function: { name, arguments: <string> } } shape. Adds a unit test asserting
the normalized shape.
- Drop the isFinal schema field: the final state always lands as a room edit,
never a to-device preview, so the field was never set true. The room edit
replacing the thinking placeholder is already the terminal signal.
- Thread the originating device id from the turn's most recent stamped
user-prompt event when the immediate trigger (a tool / code-patch result)
carries none, so multi-step turns keep streaming previews to the device that
started them instead of falling back to off after the first tool call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lukemelia
marked this pull request as ready for review
July 21, 2026 19:40
backspace
approved these changes
Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
APP_BOXEL_RESPONSE_STREAM_EVENT_TYPE = 'app.boxel.response-stream'and theAppBoxelResponseStreamContentinterface (fields:roomId,parentEventId,sequence,body,reasoning,toolRequests) topackages/runtime-common/matrix-constants.ts.APP_BOXEL_ORIGINATING_DEVICE_ID_KEYand stamps the sending device id onto every boxel user-prompt event (sendMessageinpackages/host/app/services/matrix-service.ts). Also adds the optional key to theCardMessageContentinterface inpackages/base/matrix-event.gts.AI_BOT_STREAMING_MODE=to-devicein the ai-bot'sResponder: throttled state changes route tomatrixClient.sendToDevice()targeted at the (userId, deviceId) that composed the prompt, carrying anapp.boxel.response-streampayload. The final consolidated event still lands as a room edit (routed viaflush→sendMessageEvent, not the throttled internal) so durable state is always in the room — to-device previews are ephemeral by design.offmode) as a base commit, since CS-12263 sits on top of thestreamingModegate that CS-12260 introduced. ai-bot: add AI_BOT_STREAMING_MODE=off to skip mid-stream Matrix edits #5556 remains open with the standalone CS-12260 patch; whichever of the two lands first, the other's diff auto-shrinks.Together this is the full ai-bot side of the Improve Synapse Performance by Killing Streaming project's to-device path. The host receiver (CS-12264) is the last remaining wire — until it lands, running with
AI_BOT_STREAMING_MODE=to-deviceproduces to-device previews the browser doesn't yet consume, and the UX falls back to "thinking → complete response arrives at once", identical tooffmode.Design notes
responseState.isStreamingFinishedand routes tosendMessageEvent(room edit) whenever true, regardless of mode.flushinfinalizeusessendMessageEventdirectly. So the leading-edge case where the throttled internal fires duringfinalizestill produces a room edit, not a to-device preview.to-devicebut the prompt lacks the originating device id (older client without CS-12262), the responder falls back tooffbehavior — no mid-stream sends, one final room edit. Deployment can enableto-devicemode without waiting for every user's session to refresh.sequencefor last-writer-wins reconciliation on the client. Not deltas — gaps and reordering are tolerated by design.client.sendToDevice()path today. Acceptable for Cardstack's own Synapse (the operator can see the traffic either way) and for previews that will also land in the room as the final. For federated deployments or a stronger threat model, swap toclient.encryptAndSendToDevice()as follow-up.toolRequestsshape. Previews runtoolCallsthrough the sametoCommandRequest()the room event uses, so both channels carry{ id, name, arguments: <object> };argumentsis{}until the streamed JSON completes. Typedunknown[]on the shared interface (the receiver, CS-12264, validates at the boundary), but the runtime shape now matches the room event.isFinalfield. The final state always lands as a room edit, never a to-device preview, so anisFinalpreview flag would never be set — the room edit replacing the thinking placeholder is already the terminal signal. Dropped from the schema; reintroduce if the receiver ever needs an explicit "discard preview state now" signal.offbehavior.sendToDevicetakes aMap, not a plain object. matrix-js-sdk'ssendToDevice(eventType, contentMap)requiresMap<userId, Map<deviceId, content>>and iterates it internally (recursiveMapToObject); a plain nested object throwsTypeError: contentMap is not iterablebefore anything reaches the wire — swallowed by the preview send'stry/catch, so previews would have failed silently. The Responder builds the realMap.FakeMatrixClient.sendToDevicenow rejects a non-Mapargument so a regression fails the unit suite the way it fails in production.Test plan
Respondingtests pass, including two new ones —\to-device` streaming mode: streams previews to the target device and lands one final consolidated room event, and`to-device` streaming mode without a target device falls back to `off` behavior. The CS-12260off` mode test also still passes.Responder Cancellationtests pass —finalize({isCanceled:true})still routes the final send to the room-edit path.../base/.../@cardstack/boxel-ui/*errors are the pre-existing local-dist issue).FakeMatrixClientMapguard bites: temporarily reverting the Responder to the plain-object payload flips theto-devicestreaming unit test to failing (not ok), because the swallowedTypeErrorleaves no preview sent. Restored, back to green.packages/matrix/tests/response-stream-to-device.spec.ts): drives a real matrix-js-sdk client against the dockerized Synapse — delivers aresponse-streampreview to the target device and asserts the real SDK rejects a plain-objectcontentMap. Typechecks + lints locally; first real run to be watched in CI (needs the full Synapse + realm-server stack, not run locally).AI_BOT_STREAMING_MODE=to-deviceon the ai-bot task after CS-12264 lands. Verify (a) the browser renders the streaming preview, (b) only 2 room events land per turn, (c) other devices in the same account see the final response but no live preview.Commits
f85e59c65e— CS-12261:app.boxel.response-streamschema7dd96110a8— CS-12262: stamp originating device id on prompts91eb30c3de— merge CS-12260 (offmode) as base0ff3c38c15— CS-12263:to-devicestreaming modec5ad13b90b— fix: send previews as aMap; harden the fake + add a real-SDK matrix e2e boundary test16619683b9— review findings: normalize previewtoolRequestsviatoCommandRequest; dropisFinal; thread the originating device id through multi-step turns