streaming: server-authoritative message identity + idempotent client reducer#32466
streaming: server-authoritative message identity + idempotent client reducer#32466TirmanSidhu wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 36a80a28f1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| subscriptionTask?.cancel() | ||
| subscriptionTask = Task { [weak self] in | ||
| guard let self else { return } | ||
| let stream = self.eventStreamClient.subscribeChatEvents() |
There was a problem hiding this comment.
Filter reducer events to the active conversation
Because subscribeChatEvents() now delivers the global chat category, this reducer applies assistant lifecycle/delta events from every conversation without the belongsToConversation guard that the legacy ChatActionHandler still uses. When another conversation streams (for example a pop-out window, background task, or another active VM), its message_open/delta events are inserted into this VM's messageStore, and renderedMessages will append those snapshots into the wrong transcript.
Useful? React with 👍 / 👎.
| let (stream, continuation) = AsyncStream<ServerMessage>.makeStream( | ||
| bufferingPolicy: .bufferingNewest(eventStreamSubscriberBufferLimit) | ||
| ) |
There was a problem hiding this comment.
Do not drop buffered streaming deltas
Using .bufferingNewest(256) silently discards older chat events whenever the main-actor consumer falls behind a long or fast assistant/tool stream. Chat messages use incremental assistant_text_delta and tool_input_delta payloads rather than snapshots, so dropping an older buffered event permanently removes part of the transcript (and no reconnect/replay is triggered because the SSE connection itself stayed healthy).
Useful? React with 👍 / 👎.
Summary
Re-architects the daemon→macOS streaming pipeline so the same-message-rendered-twice mid-stream bug is structurally unrepresentable instead of chased through ad-hoc dedupe.
Before: daemon emitted anonymous
assistant_text_deltaevents; client used a lazy "find the bubble forcurrentAssistantMessageId, else create a new one" path. Three failure modes (double subscriber window during loop restart, stale-id forking after handoff, reconnect replay) all routed through that "else create new" branch.After:
messageId(UUIDv7) at message open, not at persist. Every event carries(messageId, blockIndex, seq). Events are persisted to a durable SQLite log; SSE supportsLast-Event-Idreplay on reconnect.MessageStreamReducerkeyed bymessageId, idempotent via per-(messageId, blockIndex) seq watermarks. Renderer reads fromMessageStore.EventStreamClientfan-out collapsed into 11 typed per-domain dispatchers with bounded buffers.startMessageLoop/messageLoopTask/messageLoopGenerationremoved.PRs merged into this branch
Where the fix actually lands
PR #32454. The renderer now reads from
MessageStore;renderedMessagesdrops legacy lazy-created bubbles that lack adaemonMessageId. The MessageStore snapshot is authoritative.messageId).Last-Event-Id) + PR streaming: add MessageStreamReducer and MessageStore #32445 (idempotent reducer).Scope notes
TranscriptProjectordedupe-by-id and updated wire-contract docs, but did NOT deletecurrentAssistantMessageId,streamingDeltaBuffer,partialOutputBuffer,clearCurrentTurnTracking,appendTextToCurrentMessage, or the legacymessagesarray. The agent judged those entanglements (~160 call sites; load-bearing semantics beyond text streaming — tool result anchoring, surface widget routing, send-coordination, watchdog recovery) too risky for a single PR. Daemon-sidemessage_completeemission was also kept because CLI/voice/Slack consumers still need it.Test plan
generation_handoff— second turn's text should not fork into a new bubble.🤖 Generated with Claude Code