feat(acp-bridge): cross-client real-time sync completeness (5 fixes)#4484
feat(acp-bridge): cross-client real-time sync completeness (5 fixes)#4484chiga0 wants to merge 1 commit into
Conversation
Audit (cross-client sync, 2026-05-24) of the daemon's per-session EventBus fan-out surfaced gaps where one client's actions did not propagate to other SSE-subscribed clients on the same session. This commit closes five of them — all bridge-layer fixes, no agent-side changes — with regression tests covering the new sentinel frame. ## 1. user_message_chunk echo on the interactive prompt path The agent's `Session#executePrompt` (Session.ts:556+) forwards the prompt straight to the LLM without emitting `user_message_chunk` to the session bus. The cron path (Session.ts:1402) and HistoryReplayer (HistoryReplayer.ts:65) DO emit it; only the interactive path was the outlier. Result: when client A sent a prompt, other clients on the same session saw only the agent's reply, never the input — they had to wait for a session reload to learn what A had asked. Fix: `echoPromptToSessionBus` helper publishes one `user_message_chunk` per content block of the incoming `PromptRequest`, stamped with the envelope-level `originatorClientId` so SDK consumers with `suppressOwnUserEcho: true` filter the echo on the originator's UI. Multi-modal blocks (image / audio / resource) pass through verbatim for future-compat with Core's multi-modal echo work. `_meta.source: 'bridge-echo'` distinguishes bridge-synthesized echoes from agent-emitted content. Used today only for diagnostic visibility; becomes load-bearing once SDK-side dedup matures (deferred follow-up). ## 2. prompt_cancelled broadcast in cancelSession `bridge.cancelSession` forwarded the ACP cancel notification to the agent and resolved pending permissions, but did NOT publish any event on the session bus. Other clients learned that A had cancelled only by absence of further `agent_message_chunk` frames — heuristic and late. Fix: emit a `prompt_cancelled` envelope before the ACP forward so peer clients see the cancel as a first-class event. Envelope-level `originatorClientId` identifies the cancelling client (the one calling `POST /cancel`). Permission-resolution events generated by the subsequent `cancelPendingForSession` continue to omit an originator (those are system-initiated wind-downs, not user-voted). ## 3. replay_complete sentinel in EventBus.subscribe A consumer attaching via `Last-Event-ID: <n>` had no positive signal when the replay loop drained — they had to heuristically time out the catch-up spinner. The state-resync path already had a synthetic `state_resync_required` frame; the success path lacked parity. Fix: emit an id-less `replay_complete` synthetic frame at the end of the replay loop (same pattern as `client_evicted` / `state_resync_required` — no slot in the per-session monotonic sequence). Fires both when replay actually delivered frames AND when there was nothing to replay (empty ring), so the consumer always sees the transition from "catching up" to "live". `data.replayedCount` is the actual count of force-pushed frames (not derived from id arithmetic, which would over-count when the state-resync path leaves a hole before the ring's earliest id). 3 EventBus test cases updated to assert the sentinel frame ordering. ## 4. originatorClientId on session_metadata_updated envelope `updateSessionMetadata` resolved the trusted client id for validation (`resolveTrustedClientId(entry, context.clientId)`) but did not stamp it on the broadcast envelope. UIs couldn't attribute the rename to a specific client. Sibling events (`model_switched`, `approval_mode_changed`) all stamp envelope-level `originatorClientId`; this brings the metadata broadcast to parity. ## 5. originatorClientId on session_closed envelope `session_closed` carried the closing client in `data.closedBy` only, but every other event the bridge publishes uses the envelope-level `originatorClientId` field. Added the envelope-level stamp (kept `data.closedBy` for back-compat) so SDK consumers can read the attribution from the same place across all event types. ## Out-of-scope (deferred to follow-up) The cross-client sync audit also surfaced 3 items that require larger design discussion: - **In-session ACP `setModel` bus emit** — `Session.ts#setModel` calls `config.switchModel` directly without going through the bridge's publish path. Fixing this requires a new ACP sessionUpdate type (`current_model_update`, parallel to existing `current_mode_update`) or a side-channel callback from agent to bridge. - **Workspace-wide broadcast of non-persisted approval-mode changes** — current behavior only broadcasts workspace-wide on `persist=true`; the design intent of the persist flag relative to multi-client visibility needs alignment. - **Serialize `setSessionApprovalMode` through a queue** — analogous to `entry.modelChangeQueue` for `setSessionModel`. Race-condition fix. - **Reconcile `permission_resolved.originatorClientId` semantics** — it currently carries the VOTER's clientId; `permission_request` carries the prompt originator. SDK consumers need to special-case the type. Either change to consistent semantics or add a separate `voterClientId` field. These are tracked as follow-ups, not in this PR. ## Validation | | | |---|---| | Bridge tests | 291/291 pass | | eventBus tests | 105/105 pass (3 updated) | | TypeScript | clean |
📋 Review SummaryThis PR addresses five cross-client real-time sync gaps identified in a daemon_mode_b_main audit, implementing mechanical bridge-layer fixes for multi-client SSE propagation. The changes are well-documented, follow existing patterns, and include appropriate test updates. Overall assessment: solid implementation with excellent documentation, though a few edge cases and consistency improvements should be addressed before merging. 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
Summary
A cross-client real-time sync audit of
daemon_mode_b_main(2026-05-24) surfaced eight gaps where one client's actions did not propagate to other SSE-subscribed clients on the same session. This PR closes the five that are bridge-layer mechanical fixes —user_message_chunkecho on the prompt path,prompt_cancelledbroadcast on cancel, areplay_completesentinel for Last-Event-ID resume, and envelope-leveloriginatorClientIdonsession_metadata_updated+session_closed. The remaining three (in-session ACPsetModelbus emit, workspace-wide non-persisted approval-mode broadcast,permission_resolvedoriginator-vs-voter semantics) need larger design alignment and are tracked as separate follow-ups.What this PR delivers
1. `user_message_chunk` echo on the interactive prompt path
The agent's `Session#executePrompt` forwards the prompt straight to the LLM without emitting `user_message_chunk` to the session bus. The cron path (`Session.ts:1402`) and `HistoryReplayer` (`HistoryReplayer.ts:65`) DO emit it; only the interactive path was the outlier. Result: when client A sent a prompt, other clients on the same session saw only the agent's reply — never the input — until a session reload.
Fix: new `echoPromptToSessionBus` helper in `bridge.ts` publishes one `user_message_chunk` per content block of the incoming `PromptRequest`, stamped with envelope-level `originatorClientId` so SDK consumers with `suppressOwnUserEcho: true` filter the echo on the originator's own UI. Multi-modal blocks pass through verbatim for future-compat with Core's multi-modal echo work (PR #4353 §D).
`_meta.source: 'bridge-echo'` distinguishes bridge-synthesized echoes from agent-emitted content.
2. `prompt_cancelled` broadcast in cancelSession
`bridge.cancelSession` forwarded the ACP cancel notification to the agent and resolved pending permissions, but did NOT publish any event on the session bus. Other clients learned that A had cancelled only by absence of further `agent_message_chunk` frames — heuristic and late.
Fix: emit a `prompt_cancelled` envelope before the ACP forward so peer clients see the cancel as a first-class event. Envelope-level `originatorClientId` identifies the cancelling client.
3. `replay_complete` sentinel in EventBus.subscribe
A consumer attaching via `Last-Event-ID: ` had no positive signal when the replay loop drained — they had to heuristically time out the catch-up spinner. The state-resync path already had a synthetic `state_resync_required` frame; the success path lacked parity.
Fix: emit an id-less `replay_complete` synthetic frame at the end of the replay loop. Fires both when replay actually delivered frames AND when there was nothing to replay (empty ring). `data.replayedCount` is the actual count of force-pushed frames (not derived from id arithmetic, which would over-count when the state-resync path leaves a hole before the ring's earliest id).
4. `originatorClientId` on `session_metadata_updated` envelope
`updateSessionMetadata` resolved the trusted client id for validation but did not stamp it on the broadcast envelope. Sibling events (`model_switched`, `approval_mode_changed`) all stamp envelope-level `originatorClientId`; this brings metadata broadcast to parity.
5. `originatorClientId` on `session_closed` envelope
`session_closed` carried the closing client in `data.closedBy` only, but every other event uses the envelope-level `originatorClientId` field. Added the envelope stamp; kept `data.closedBy` for back-compat.
Out of scope (deferred to follow-ups)
The audit also surfaced 3 items that require larger design discussion. Tracked as separate follow-up PRs:
Deployment coordination note
Downstream products that ship their own gateway with a user-message echo workaround (e.g., `web-terminal-sandboxs` `qwen-gateway`) will produce double frames once this daemon-side echo lands. Recommended rollout: deploy daemon first with the new echo, then flip the gateway's `GATEWAY_ECHO_USER_MESSAGE=false` env flag. The `_meta.source` marker (`'bridge-echo'` from daemon vs `'gateway-echo'` from gateway) lets future SDK-side dedup catch leftover misconfiguration.
Validation
```bash
cd packages/acp-bridge
npx vitest run # 291/291 pass (3 updated for replay_complete)
npx tsc --noEmit # clean
```
Linked