Skip to content

fix: send only changed chat history over the stream - #29662

Draft
mafredri wants to merge 69 commits into
mainfrom
fix/chat-stream-history-replay
Draft

mafredri wants to merge 69 commits into
mainfrom
fix/chat-stream-history-replay

Conversation

@mafredri

@mafredri mafredri commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

On main, an edit soft-deletes the edited message and every later one, and the deleted rows stay in the chat. After that, every connection to the chat stream (GET /api/v2/chats/{chat}/stream) reads a deleted row and sends history_reset followed by every visible message, whatever after_id the client sent. No messages page or stream event includes the chat's snapshot_version, so a client cannot order a page relative to stream events or resume a stream where it stopped.

This PR adds snapshot_version to the newest messages page and to the stream's message, queue_update and history_reset events, and lets a client open the stream at the version it holds. It implements H1 to H5 of the RFC Chat stream contract: one copy of each chat in the web client, which describes the design and the alternatives. The RFC is in review, and this PR will change to match its outcome. #30478 changes the web client to use the new stream behavior.

Events the stream sends on connect, up to its first status event, for the three edited chats of the chat bench from #29596. The client loads the newest page, then opens the stream with the cursor from that page:

Chat main at ad85646164, after_id = newest message ID This PR, snapshot_version from the page
1 history_reset and 306 messages, 23.9 MB status, 99 B
2 history_reset and 306 messages, 23.9 MB status, 99 B
3 history_reset and 584 messages, 3.1 MB status, 99 B

The latest commit of each chat changed no message and no queue, so the stream had nothing to send again. A client that does not send snapshot_version gets the same full history from this PR as from main. The open PRs #29808 and #29761 change that behavior, and this PR does not depend on either.

API changes

All changes are additive. A stream opened without snapshot_version sends the same events as on main; only its setup failures change.

Surface Change
GET /chats/{chat}/messages without before_id or after_id Adds snapshot_version. The handler reads it with the chat row, before the messages and the queue, so a change committed between the reads is in the page and the stream sends it again.
GET /chats/{chat}/stream?snapshot_version=S The first read sends each message, edit and queue change with a version of S or greater, so it sends the changes of commit S again. The server ignores after_id. It returns 400 for an S that is not an integer, is below 1, or is above the chat's version.
message, queue_update and history_reset on that stream Include snapshot_version: the message's revision, the chat's queue_version, or the revision of the rows the edit deleted. Each read sends them in version order. Within one version it sends the reset, then the messages by ID, then the queue.
history_reset on that stream One for each edit, with from_message_id, the lowest ID the edit deleted. Only messages written by that edit or a later commit follow it.
Stream setup failure, on every stream HTTP 500 before the WebSocket upgrade. On main, the server upgrades and sends one error event, which the web client shows as a chat error.
Go SDK StreamChatOptions.SnapshotVersion.

The PR also deletes five queries that write messages or the queue outside a chat state machine transition and that no code calls: SoftDeleteContextFileMessages, DeleteChatQueuedMessage, DeleteAllChatQueuedMessages, PopNextQueuedMessage and ReorderChatQueuedMessageToFront. A write outside a transition does not increment snapshot_version, so a stream can miss it.

The API reference of the stream endpoint now describes the order of events, the resend rules and the errors, from a new @Description on streamChat. Step 4 of the Tasks-to-Chats migration guide now lists the client rules. Before this PR, it told clients to reconnect with after_id, which sends the whole history after an edit, and it described the frames as ping, data and error envelopes. The server sends each frame as a JSON array of events, on main too.

Tests

Test Checks
TestStreamChat/EditedChatSyncsOnlyChangedHistory Opening a chat edited earlier sends no reset. An edit while the stream is open sends one reset from the edited message and no message below it. A reconnect at the highest version received sends no reset. A tab that missed the edit and then sent a message gets the reset when it reconnects. A stream without snapshot_version gets the full history, and no event includes a version.
TestStreamChat/ReconnectReceivesMessagesSentElsewhere A reconnect sends a message from another tab whose ID is lower than a message this tab holds.
TestStreamChat/InvalidSnapshotVersionReturns400, TestStreamChat/SnapshotVersionAboveChatReturns400 400 for 0, -1, abc, and a version above the chat's.
TestStreamChat/SetupFailureReturnsHTTPError A failed first read returns 500, and the connection is not upgraded.
TestGetChatMessages_Pagination/SnapshotVersionOnlyOnUncursoredPage Only the page without a cursor includes snapshot_version.
TestStreamLoopSnapshotVersion Resume across two edits, at an edit, at a message, and at a commit that changed no message or queue. In each case, applying the events to the IDs the client holds gives the chat's live IDs.
TestStreamLoopSnapshotVersionOrder, TestStreamLoopSnapshotVersionTurnEvents A queue change sent before a later message. The same turn events with and without snapshot_version.

Lines against main: Go source +204/−35 (+40 of it is the swagger description), the deleted queries and their authorization wrappers −88, tests +769/−67, ARCHITECTURE.md +17/−2, docs +44/−17, generated +201/−229.

Not covered

  • Chat-record fields, such as the title or plan mode, are not part of this change and reach clients as on main.
  • After a database restore lowers a chat's snapshot_version, the stream returns 400 to a client that holds a higher version. The user must reload the page.
Decision log

Implementation choices that the RFC does not state:

  • The loop's history and queue versions start at S − 1, so the first read sends the changes of commit S again. A connection can drop between the events of one commit. A client can apply a change twice, because a message replaces the message with the same ID and queue_update contains the whole queue.
  • With snapshot_version, the server ignores after_id, because a client can hold a message ID without holding every lower one. ReconnectReceivesMessagesSentElsewhere covers that case.
  • The loop sends one reset for each distinct revision among the deleted rows. Only an edit deletes messages, and a deleted row keeps the revision of the edit that deleted it.
  • On a stream opened with snapshot_version, the loop does not read the full history for a reset.
  • The loop builds the events as on main, then sorts them with a stable sort by version. The stable sort keeps the reset, message and queue order within one version.
  • subscribeWithInitialError is deleted. Subscribe returns ok == false for a setup failure, and the handler returns its existing 500 response.

Related: #29596 (rendering performance, where this problem was found), #29661 (interrupted turns stored one part per token, which is why two of the chats above are 24 MB).

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch 2 times, most recently from ad7a0f3 to f9b402f Compare September 22, 2026 13:45
@mafredri mafredri changed the title fix: sync chat history incrementally over the stream fix: start the chat stream sync from the client's page version Sep 22, 2026
@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch 2 times, most recently from a82cfd8 to 381fb14 Compare September 22, 2026 14:53
@linear-code

linear-code Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

CODAGT-93

CODAGT-1115

@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch from 381fb14 to cb8ddd6 Compare October 7, 2026 10:50
@mafredri mafredri changed the title fix: start the chat stream sync from the client's page version fix: send only changed chat history over the stream Oct 7, 2026
@mafredri

mafredri commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

@codex review
/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-10-09 16:04 UTC by @mafredri

Review history
  • R1 (2026-10-07): 13 reviewers, 2 Nit, 7 P3, 5 P4, COMMENT. Review
  • R2 (2026-10-08): 14 reviewers, 6 Nit, 8 P3, 8 P4, COMMENT. Review
  • R3 (2026-10-08): 11 reviewers, 7 Nit, 9 P3, 9 P4, COMMENT. Review
  • R4 (2026-10-09): 9 reviewers, 8 Nit, 9 P3, 11 P4, COMMENT. Review
  • R5 (2026-10-09): 7 reviewers, 8 Nit, 9 P3, 13 P4, COMMENT. Review
  • R6 (2026-10-09): 7 reviewers, 8 Nit, 9 P3, 14 P4, COMMENT. Review
  • R7 (2026-10-09): 5 reviewers, 9 Nit, 9 P3, 14 P4, COMMENT. Review

deep-review v0.13.0 | Round 7 | 782fb30..698f065

Last posted: Round 7, 32 findings (9 P3, 14 P4, 9 Nit), COMMENT. Review

Finding inventory

Finding inventory: PR #29662

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author fixed (d69899f) stream_loop.go:229 requires_action history selection untested for either reset kind R1 Netero Yes
CRF-2 P4 Author fixed (8a8e660) stream_loop.go:205 min loop re-derives lowest ID the query ORDER BY already guarantees R1 Netero Yes
CRF-3 Note Dropped by orchestrator (superseded by CRF-47 at P4) exp_chats.go:3550 after_revision above chat history_version is accepted and suppresses sync R1 Netero No
CRF-4 Note Author fixed (bfdc902) useChatStore.ts:183 Remount reconnects from cached page's original history_version R1 Netero No
CRF-5 P3 Deferred (#30478) useChatStore.ts:664 Reconnect cursor never lowered when a stale REST page rolls the store back R1 Hisoka P3, Melody P3, Mafuuu P3 Yes
CRF-6 P3 Author fixed (f555159) stream_loop.go:102 Mid-turn reconnect on unedited chat resends all steps since last status R1 Pariston Yes
CRF-7 P3 Author fixed (f555159) stream_loop.go:303 status.history_version sent on after_id-only streams and adopted as cursor R1 Razor Yes
CRF-8 P4 Author fixed (99be3f6) codersdk/chats.go:3146 Docs say "set" but after_revision=0 keeps after_id and full resets R1 Mafuuu, Leorio, Razor Yes
CRF-9 P3 Author fixed (f555159) exp_chats_test.go:10251 Live-edit step waits for a status event that may never be emitted R1 Bisky Yes
CRF-10 P3 Author contested; panel closed R3 (1/1 accept) AgentChatPage.tsx:296 No test covers the page passing history_version to useChatStore R1 Bisky Yes
CRF-11 P4 Author fixed (1c07b42) useChatStore.ts:578 commitHistoryReplacement in history_reset branch is unreachable and untested R1 Bisky Yes
CRF-12 P3 Author fixed (d3c3041) codersdk/chats.go:2209 from_message_id has no field doc, API reference is blank R1 Leorio Yes
CRF-13 P4 Author fixed (72d54e4) useChatStore.ts:772 connect() comment says message ID controls what server sends R1 Leorio Yes
CRF-14 P4 Author fixed (99be3f6) exp_chats.go:3522 after_revision name differs from history_version it carries R1 Ryosuke Yes
CRF-15 Nit Author fixed (5068a48) exp_chats_test.go:14013 sortedIDs can be slices.Sorted(maps.Keys(...)) R1 Ging-Go Yes
CRF-16 Nit Author fixed (5068a48) chatStore.test.tsx:839 [...x].reverse() instead of toReversed() R1 Ging-TS Yes
CRF-17 Nit Dropped by orchestrator (repo squash-merges; PR title complies) cb8ddd6..6787013 Commit subject scopes and length R1 Leorio No
CRF-18 Note Note (body) stream_loop.go:100 Cursor advance depends on commitHistoryReplacement running before status handling R1 Ryosuke No
CRF-19 Note Dropped by orchestrator (confirmation, no action) stream_loop.go:387 DeleteFunc covers both reset kinds R1 Ging-Go No
CRF-20 Note Dropped by orchestrator (confirmation, no action) useChatStore.ts:664 Cursor advances after applied messages; store and cache agree R1 Nami No
CRF-21 Note Dropped by orchestrator (confirmation, no action) stream_loop.go:300 Remaining producer/consumer pairs agree R1 Melody No
CRF-22 Note Dropped by orchestrator (confirmation, no action) stream_loop.go:204 Partial-reset bound correct for any deletion pattern R1 Pariston No
CRF-23 Note Dropped by orchestrator (confirmation, no action) exp_chats_test.go:10278 Other new tests check real behavior R1 Bisky No
CRF-24 Note Dropped by orchestrator (confirmation, no action) exp_chats.go:3552 New 400 response well written R1 Leorio No
CRF-25 OOS Out of scope (body) useChatStore.ts:298 In-flight refetch resolving after stream events overwrites cache and store R1 Nami, Hisoka No
CRF-26 OOS Out of scope (body) stream_loop.go:233 requires_action reads full history on every sync R1 Killua No
CRF-27 OOS Out of scope (body) chats.sql:451 No (chat_id, revision) index for GetChatMessagesByRevisionForStream R1 Killua No
CRF-28 P3 Author fixed (d2420ca) chatStore.test.tsx:4083 "page refetch does not advance the version" step is vacuous since preview_reset overwrites unconditionally R2 Bisky Yes
CRF-29 Nit Author fixed (e912c65) exp_chats_test.go:13951 waitForChatStatus uses require.Eventually, ignoring ctx deadline R2 Ging-Go Yes
CRF-30 P4 Author contested; panel closed R3 (1/1 accept) useChatStore.ts:580 Full reset upserts earlier-frame messages then replaces them, firing wasted invalidations R2 Nami Yes
CRF-31 P4 Author fixed (da8d443) ARCHITECTURE.md:1339 Doc says "opened with history_version" where code requires non-zero R2 Mafuuu Yes
CRF-32 P4 Author fixed (af74e4b) stream_loop.go:58 hasHistoryVersion reads as "historyVersion is set" but means client opened with one R2 Gon P3 Yes
CRF-33 Nit Author contested; panel closed R3 (1/1 accept) exp_chats_test.go:10270 Comment repeats the assertion message below it R2 Gon P2 Yes
CRF-34 Nit Author fixed (e22502b) useChatStore.ts:542 Deleted comment still describes retained historyReplacementBuf discard R2 Gon Yes
CRF-35 Nit Author contested; panel closed R3 (1/1 accept) stream_loop_internal_test.go:503 reset(messages int) parameter names an event count "messages" R2 Gon Yes
CRF-36 OOS Out of scope (body) useChatStore.ts:239 replaceCacheMessages does not invalidate chat prompts after another tab's edit R2 Nami No
CRF-37 OOS Out of scope (body) chats.ts:2350 editChatMessage comment refers to nonexistent "FullRefresh" R2 Nami No
CRF-38 Note Dropped by orchestrator (confirmation, no action) stream_loop.go:353 preview_reset producer/consumer pairs agree R2 Melody, Ryosuke, Pariston, Kite, Leorio No
CRF-39 P3 Author fixed (4a3ce86) useChatStore.ts:189 !pageLoaded guard untested; removing it pins cold-opened chats to after_id R3 Bisky Yes
CRF-40 Nit Author fixed (67fea0a) stream_loop.go:57 openedWithHistoryVersion comment clause refers to the version, not the history R3 Leorio Yes
CRF-41 P4 Author fixed (ce67ada) exp_chats_test.go:14008 applyUntil timeout message names neither condition nor events received R3 Chopper Yes
CRF-42 Note Dropped by orchestrator (confirmation, no action) useChatStore.ts:184 8c68ff3 gating and version-0 pinning verified R3 Nami, Ryosuke No
CRF-43 Nit Author fixed (18a1079) useChatStore.ts:771 Comment claims write order matters; the two synchronous cache writes commute R4 Nami Nit, Hisoka Nit, Bisky Note Yes
CRF-44 P4 Author contested; panel closed R5 (1/1 accept) codersdk/chats.go:3163 StreamChatOptions.HistoryVersion pointer adds nil state equal to zero R4 Zoro Yes
CRF-45 P4 Author fixed (c0bcee4) useChatStore.ts:78 writeHistoryVersionToCache duplicates writeQueuedMessagesToCache R4 Zoro Yes
CRF-46 Note Dropped by orchestrator (confirmation, no action) useChatStore.ts:78 bfdc902 resolves CRF-4; remount test fails without the cache write R4 Nami, Bisky, Chopper, Leorio, Zoro No
CRF-47 P4 Author fixed (c7e9f5f) exp_chats.go:3568 history_version above chat's version accepted; rows the client lacks at or below the chat's version are silently never sent R5 Knov (CRF-3 Netero R1) Yes
CRF-48 P4 Author fixed (92bccbf) codersdk/chats.go:972 HistoryVersion doc states the page can be ahead of the version but not that the stream resends it R5 Leorio Yes
CRF-49 OOS Out of scope (body) useChatStore.ts:245 Pre-existing useCallback wrappers in a compiler-memoized hook R5 Ging-React No
CRF-50 Note Dropped by orchestrator (confirmation, no action) stream_loop.go:356 Codex empty-chat thread premise false: insertChat requires initial messages R5 Nami, Hisoka, Knov No
CRF-51 P4 Author fixed (e1cdf14) exp_chats.go:3583 400 detail omits both versions and where a valid history_version comes from R6 Leorio (Hisoka note) Yes
CRF-52 Note Note (body) exp_chats.go:3580 After a DB rollback, a tab retries with a version above the chat's and gets 400 indefinitely R6 Hisoka No
CRF-53 Note Dropped by orchestrator (confirmation, no action) exp_chats.go:3580 CRF-47 fix mutation-verified, > boundary pinned by existing tests R6 Knov, Bisky, Chopper, Ging-Go, Zoro, Netero No
CRF-54 Nit Open useChatStore.ts:41 patchNewestPage comment restates the code; "unchanged" means same reference R7 Gon P2 Yes
CRF-55 OOS Out of scope (body) codersdk/chats.go:3159 AfterID doc says after_id streams can skip full history; a chat with a deleted message resends it all R7 Gon No
CRF-56 Nit Dropped by orchestrator (repo squash-merges; commit body does not reach main) 698f065 Commit body claims preview_reset only on history change; also sent on non-zero generation_attempt R7 Leorio No

Contested and acknowledged

CRF-5 (P3, useChatStore.ts:664) - Reconnect cursor never lowered when a stale REST page rolls the store back

CRF-10 (P3, AgentChatPage.tsx:296) - No test covers the page passing history_version to useChatStore

  • Finding: Nothing tests that AgentChatPage passes pages[0].history_version into useChatStore; deleting the line or reading the wrong page leaves every test passing while the client reverts to full resets. Proposed a page-level story/test asserting watchChat receives the version.
  • Author defense: If the line breaks, the client opens without a version and gets the old protocol, which the server still supports. Covering it needs a page-level WebSocket mock for one prop; watchChat is a module export the stories do not stub.
  • Panel decision (R2, 0/1 accept): Bisky, the agent that raised it (same agent, messaged), re-raised. The defense's premise is wrong: AgentChatPage.stories.tsx already runs under the withWebSocket decorator (meta decorators, line 923), which replaces window.WebSocket and routes events by URL substring, so a story keyed on history_version=5 tests the line. Orchestrator confirmed testHelpers/storybook.tsx:92. Consequence if broken: the 24 MB full replay this PR removes returns with no failing test.

CRF-30 (P4, useChatStore.ts:580) - Full reset upserts earlier-frame messages then replaces them

  • Finding: On a full history_reset, messages received earlier in the frame are upserted into store and cache and immediately replaced, firing prompt/automation invalidations for discarded messages. Proposed applying earlierMessages only when from_message_id is set.
  • Author defense: A history_reset only follows an edit that deleted messages, and an edit changes prompt history. The reset path never invalidates prompts, so the upsert of the earlier messages is what refreshes them; skipping it leaves prompt history stale.

CRF-33 (Nit, exp_chats_test.go:10270) - Comment repeats the assertion message

  • Finding: The step comment "A client without a history version gets a full reset." repeats the assertion message five lines below; delete it.
  • Author defense: Each step of the test opens with such a comment; they are its structure, and removing one leaves that step unmarked.

CRF-35 (Nit, stream_loop_internal_test.go:503) - reset(messages int) parameter name

  • Finding: reset := func(messages int) names an event count "messages" where messages and rows hold []database.ChatMessage; rename to resent.
  • Author defense: No messages slice exists in this test; cases use rows. reset(2) reads as a reset with two messages.

CRF-10 R3 update

  • Author defense (R3): Concedes the URL-routed WebSocket mock exists. Argues a story play assertion is ruled out by site/AGENTS.md FE1 (Pixel screenshots every story; assertions belong in Vitest), no Vitest test renders AgentChatPage, building that harness for one field is out of proportion, and a broken line falls back to correct full resets.

R3 panel closures

  • CRF-10 (R3, 1/1): Bisky, the agent that raised it (R1 agent, messaged), accepted: FE1 in site/AGENTS.md forbids behavior assertions in story play functions (orchestrator confirmed site/AGENTS.md:7), no Vitest harness renders AgentChatPage, and a broken line falls back to correct full resets. Bisky raised the testable part inside useChatStore as CRF-39.
  • CRF-30 (R3, 1/1): Nami, the agent that raised it (R2 agent, messaged), accepted on consequence: full resets reach the new client only from older servers (every chat has history_version >= 1 because CreateChat always inserts a system message and snapshot_version defaults to 1), and the extra refetch is invisible. Nami rejected the author's reasoning that the upsert refreshes prompts after a reset; the missing refresh is CRF-36.
  • CRF-33 (R3, 1/1): Gon (R2 agent, messaged) accepted: all five steps open with a step comment, so the comment is structural.
  • CRF-35 (R3, 1/1): Gon (R2 agent, messaged) accepted: the test has no messages variable, so the round 2 evidence was wrong.

CRF-44 (P4, codersdk/chats.go:3163) - StreamChatOptions.HistoryVersion pointer adds nil state equal to zero

  • Finding: HistoryVersion is *int64 but nil and zero mean the same to the server; the doc must say "nil or zero", the caller zero-checks before taking the address, and typesGenerated gets number | null. Proposed int64, sent when non-zero.
  • Author defense: Keeps *int64 because the sibling AfterID is also *int64, and the server reads both into an int64 cursor field where zero means unset, so the two options behave alike.
  • Panel closure (R5, 1/1): Zoro, the agent that raised it (R4 agent, messaged), accepted: AfterID uses the same *int64 shape with zero meaning unset in StreamCursor, so changing only HistoryVersion would give StreamChatOptions two conventions; the cost is one caller-side check and an unused null in TS.

Law analysis

R1: effective +1163 (head 6787013). Verdict: Don't split. Enforcement: Advisory.

Round log

Round 1

Full panel (round 1). Netero + Law first (Law: don't split, advisory). Panel: ging-go, ging-ts, ging-react, nami, melody, ryosuke, killua (specialists), pariston, mafuuu (core), bisky, hisoka, leorio (pool), razor (wildcard). Gon deferred to next panel round to hold the 12-reviewer target; Knuckle and Takumi not triggered (no query/schema or goroutine changes). CRF-5 is convergent (Hisoka, Melody, Mafuuu; Nami listed the root race as OOS); kept at P3 because on main the same stale state also persisted until a reconnect or refetch, so the PR's delta is the lost repair on reconnect. Reviewed against 5552152..6787013.

Round 2

Restructure round (exported SDK types changed: ChatStreamStatus.HistoryVersion removed, StreamChatOptions.AfterRevision renamed to HistoryVersion, ChatStreamPreviewReset added; wire param renamed). Churn guard PROCEED: 12 addressed, CRF-10 contested, CRF-5 deferred to #30478. Law skipped (+37 effective). Netero: no new findings, CRF-1/CRF-2 verified. Full panel: ging-go, ging-ts, ging-react, nami, melody, ryosuke, killua, pariston, mafuuu, gon (once-per-PR floor), leorio, hisoka, kite (wildcard), plus bisky (dispute check-in, R1 agent reused). CRF-10 re-raised by Bisky (0/1 accept). CRF-12 re-raised by Leorio (event field docs still missing; author declined that part). Gon P2 (redundant comment) downgraded to Nit: keep-argument is noise in test, but no behavior or reader trap. Gon P3 (hasHistoryVersion naming) downgraded to P4: keep-argument is a plausible refactor breaking old clients, but the field comment states the meaning and no current behavior is wrong. Reviewed against c9680e4..0e0a8ec.

Round 3

Fix round (no exported signature changes; SDK edits are comments; one behavior commit 8c68ff3 in useChatStore). Churn guard PROCEED: 6 addressed, 4 contested, CRF-5 deferred. Law skipped (+76 effective since R2). Netero: no findings. Panel: bisky, nami, gon (dispute check-ins, prior agents messaged), leorio, mafuuu, ging-go (fix verification), ging-ts, ging-react, ryosuke, hisoka (files/behavior changed since R2), chopper (wildcard). Workspace agent disconnected mid-round; after restart the 10 reviewers without output files were resumed via message_agent with the same prompt (Gon had already written). All four contested findings closed 1/1. New: 1 P3, 1 P4, 1 Nit. Reviewed against a4b68ac..f19806a.

Round 4

Fix round (no new files or exported signature changes; one behavior commit bfdc902 writes the preview_reset version into cached page 0, resolving CRF-4 and a Codex thread). Churn guard PROCEED: CRF-39, CRF-40, CRF-41 addressed; CRF-5 deferred. Law skipped (+137 effective since R3). Netero: no findings, both new tests mutation-verified. Panel: bisky, leorio, chopper (fix verification), nami, ging-ts, ging-react, ging-go (changed files), hisoka (new cache state), zoro (wildcard). All three fixes verified. New: 2 P4, 1 Nit (convergent). Zoro P4 on the HistoryVersion pointer kept at P4: keep-argument is the generated number|null and caller zero-checks on an experimental API that is cheap to change now; counter-argument is the sibling AfterID *int64, which does not remove the meaningless state. Reviewed against 6bae268..bfdc902.

Round 5

Fix round (refactor and doc comments only since R4). Churn guard PROCEED: CRF-43, CRF-45 addressed; CRF-44 contested; CRF-5 deferred. Netero: no findings. Panel: zoro (dispute check-in, R4 agent messaged), nami, hisoka, leorio, ging-ts, ging-react, knov (wildcard). CRF-44 closed 1/1. Codex thread PRRT_kwDOGkVX1s6q1d9h (empty chat at version 0) refuted by Nami, Hisoka, Knov; orchestrator confirmed insertChat rejects empty InitialMessages (transitions.go:126). Knov raised CRF-3 at P4 claiming revisions between chat version and the client value are permanently lost; orchestrator disproved that mechanism (applyDBSnapshot sets l.state.historyVersion = chat.HistoryVersion unconditionally at stream_loop.go:361, so later syncs use the real version). Kept at P4 as CRF-47 with the residual consequence (rows at or below the chat's version the client never held are silently never sent; public parameter, one-comparison fix). Keep-argument for P3: silent incomplete history on a public API; settled P4 because only a caller sending a version it never read hits it. Reviewed against 782fb30..a73dc4e.

Round 6

Fix round (one validation check, its test, doc text). Churn guard PROCEED: CRF-47, CRF-48 addressed; CRF-5 deferred. Netero: no findings. Panel: knov, leorio (fix verification), bisky, chopper, hisoka, ging-go, zoro (wildcard). Both fixes verified; > vs >= boundary pinned (Bisky, Chopper mutation). New: 1 P4 (error detail), 1 Note (DB rollback retry loop, no change requested). Reviewed against 782fb30..77ab16b.

Round 7

Fix round (CRF-51 detail text, two comments). Churn guard PROCEED: CRF-51 addressed; CRF-5 deferred. Netero: no findings. Panel: leorio, hisoka (raised CRF-51), gon (comments), chopper, kite (wildcard). Gon first reported its prompt missing (read during select); re-sent the same prompt and it completed. CRF-51 verified. Gon P2 on the patchNewestPage comment downgraded to Nit: keep-argument is the misleading reference-vs-equality contract for a future caller; downgrade because the cost of misreading is one redundant setQueryData, and Leorio R5 judged the comment accurate. Gon AfterID doc item is pre-existing text (OOS). Reviewed against 782fb30..698f065.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T16:08:01.190881Z 698f065 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 6787013351

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coder-agents-review coder-agents-review Bot 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.

This PR adds a history-version cursor to the chat stream and a partial history_reset that starts at the lowest changed message. Findings: 7 P3, 5 P4, 2 Nit.

Out of scope (needs a ticket or explicit acceptance by a human):

  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:298: a messages refetch or fetchNextPage that resolves after stream events writes pages read before those events into the cache and store, so durable messages disappear or deleted messages reappear until the next refetch.
  • coderd/x/chatd/stream_loop.go:233: a chat in requires_action reads its whole visible history on every sync while that status persists.
  • coderd/database/queries/chats.sql:451: GetChatMessagesByRevisionForStream has no (chat_id, revision) index and scans every row of the chat on each sync with a history change.

Notes:

  • coderd/exp_chats.go:3550: an after_revision above the chat's history_version is accepted; that stream gets no message events until the chat's version passes it. The web client only sends versions it read.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:183: on remount with cached messages, the stream opens at the cached page's original history_version, which resends more than needed but loses nothing.
  • coderd/x/chatd/stream_loop.go:100: advancing the cursor in the status handler is safe only because every non-message event calls commitHistoryReplacement() first; nothing at either site states that dependency.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/x/chatd/stream_loop.go Outdated
Comment thread coderd/x/chatd/stream_loop.go Outdated
if (!nextStatus) {
continue;
}
const syncedVersion = streamEvent.status?.history_version;

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.

P3 [CRF-5] historyVersionRef only moves forward from stream status events, but REST page responses can roll the store back to older state, and a reconnect no longer repairs it. (Hisoka, Melody, Mafuuu)

Example: deleteChatQueuedMessage awaits invalidateChatMessages during a running turn; the assistant message and status: waiting arrive while the refetch is in flight, the ref advances, and the stale response then drops the message from cache and store (the hydration effect at line 298 sees an ID the previous sync held). A page read before another tab's edit can likewise put deleted rows back. On main the next reconnect sent after_id from the stale tail or a full reset and restored the state; now it sends the newer after_revision and the server sends nothing. Fix: return history_version on every page and lower the ref to min(ref, page.history_version) when a page is written into the cache.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The race is on main, so the fix is its own PR: #30478. Cache writes now cancel a background refetch first, as watch events already do for the chat list. A pagination fetch in flight during a stream write can still overwrite it; #30478 explains why that case is left alone.

🤖 Posted using /amend-review skill via Coder Agents.

Comment thread coderd/x/chatd/stream_loop.go Outdated
Comment thread coderd/x/chatd/stream_loop.go Outdated
Comment thread codersdk/chats.go
Comment thread coderd/exp_chats.go Outdated
Comment thread coderd/exp_chats_test.go
Comment thread site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx Outdated
@mafredri

mafredri commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

@codex review
/coder-agents-review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 0e0a8ec67c

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coder-agents-review coder-agents-review Bot 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.

Round 2. The reconnect version now rides on preview_reset, the stream parameter is renamed to history_version, and most round 1 findings are fixed and verified. New and re-raised findings: 2 P3, 4 P4, 4 Nit.

Verified fixed: the requires_action read, the first-row reset start, the mid-turn resend, the version on after_id streams, the "non-zero" SDK and swagger text, the live-edit test wait, the removed commitHistoryReplacement() call, the from_message_id doc, the connect() comment, the rename, and both Nits. The stale-page rollback is deferred to #30478. The missing page-level test for history_version and the ChatStreamEvent field docs are re-raised on their threads.

Out of scope (needs a ticket or explicit acceptance by a human):

  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:298: a messages refetch or fetchNextPage that resolves after stream events writes older pages into the cache and store (partly addressed by #30478).
  • coderd/x/chatd/stream_loop.go:223: a chat in requires_action reads its whole visible history on every sync while that status persists.
  • coderd/database/queries/chats.sql:451: GetChatMessagesByRevisionForStream has no (chat_id, revision) index and scans every row of the chat on each sync with a history change.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:239: replaceCacheMessages does not invalidate chat prompts, so after another tab edits a prompt, this tab's prompt history keeps the deleted prompt until staleTime expires.
  • site/src/api/queries/chats.ts:2350: the editChatMessage onSettled comment says the WebSocket delivers post-edit messages "via FullRefresh", a mechanism that does not exist.

Notes:

  • coderd/exp_chats.go:3551: a history_version above the chat's version is accepted; that stream gets no message events until the chat's version passes it.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:183: on remount with cached messages, the stream opens at the cached page's original version, which resends more than needed but loses nothing.

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread coderd/x/chatd/ARCHITECTURE.md Outdated
Comment thread coderd/x/chatd/stream_loop.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/x/chatd/stream_loop_internal_test.go Outdated
@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch from 0e0a8ec to f19806a Compare October 8, 2026 11:44
@mafredri

mafredri commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

@codex review
/coder-agents-review

🤖 This review was automatically generated with Coder Agents.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f19806a14c

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx Outdated
@mafredri

mafredri commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

/coder-agents-review

🤖 This review was automatically generated with Coder Agents.

@coder-agents-review coder-agents-review Bot 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.

Round 3. The fixes for the round 2 findings are verified, and the four contested findings are closed. New findings: 1 P3, 1 P4, 1 Nit.

Verified fixed: the ChatStreamEvent field docs, the vacuous page-refetch test step, waitForChatStatus bounded by its context, the ARCHITECTURE.md "non-zero" definition, the openedWithHistoryVersion rename, and the restored reset comment. 8c68ff3 (a chat opened without a version stays on after_id) is correct, and its test fails against the previous code. The stale-page rollback stays deferred to #30478.

Out of scope (needs a ticket or explicit acceptance by a human):

  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:298: a messages refetch or fetchNextPage that resolves after stream events writes older pages into the cache and store (partly addressed by #30478).
  • coderd/x/chatd/stream_loop.go:223: a chat in requires_action reads its whole visible history on every sync while that status persists.
  • coderd/database/queries/chats.sql:451: GetChatMessagesByRevisionForStream has no (chat_id, revision) index and scans every row of the chat on each sync with a history change.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:239: replaceCacheMessages does not invalidate chat prompts, so after another tab edits a prompt, this tab's prompt history keeps the deleted prompt until staleTime expires. The upsert of earlier-frame messages refreshes prompts only when one of them is a user message.
  • site/src/api/queries/chats.ts:2350: the editChatMessage onSettled comment says the WebSocket delivers post-edit messages "via FullRefresh", a mechanism that does not exist.

Notes:

  • coderd/exp_chats.go:3551: a history_version above the chat's version is accepted; that stream gets no message events until the chat's version passes it.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:183: on remount with cached messages, the stream opens at the cached page's original version, which resends more than needed but loses nothing.

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/x/chatd/stream_loop.go Outdated
@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch from f19806a to bfdc902 Compare October 9, 2026 10:35
@mafredri

mafredri commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

@codex review
/coder-agents-review

🤖 This review was automatically generated with Coder Agents.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: bfdc902e40

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coder-agents-review coder-agents-review Bot 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.

Round 4. The three round 3 fixes are verified, and bfdc902 now reopens a remounted chat at its last synchronized version; its test fails when the cache write is removed. New findings: 2 P4, 1 Nit.

Verified fixed: the !pageLoaded test, the openedWithHistoryVersion comment, and the applyUntil failure message. The stale-page rollback stays deferred to #30478.

Out of scope (needs a ticket or explicit acceptance by a human):

  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:298: a messages refetch or fetchNextPage that resolves after stream events writes older pages into the cache and store (partly addressed by #30478).
  • coderd/x/chatd/stream_loop.go:223: a chat in requires_action reads its whole visible history on every sync while that status persists.
  • coderd/database/queries/chats.sql:451: GetChatMessagesByRevisionForStream has no (chat_id, revision) index and scans every row of the chat on each sync with a history change.
  • site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts:239: replaceCacheMessages does not invalidate chat prompts, so after another tab edits a prompt, this tab's prompt history keeps the deleted prompt until staleTime expires.
  • site/src/api/queries/chats.ts:2350: the editChatMessage onSettled comment says the WebSocket delivers post-edit messages "via FullRefresh", a mechanism that does not exist.

Notes:

  • coderd/exp_chats.go:3551: a history_version above the chat's version is accepted; that stream gets no message events until the chat's version passes it.

🤖 This review was automatically generated with Coder Agents.

Comment thread codersdk/chats.go Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
Comment thread site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts Outdated
A chat's snapshot version only grows, so a cursor above it was never
issued for the chat; a stream from it would skip every change until the
chat passed it. Reject it like any other invalid parameter.
A client showed the turn from four events with separate rules: status on
a change, error only when the history also advanced, retry until the
next part, message, status or error, and action_required. Every
checkpoint now carries the turn (status, and the error or pending retry
that status shows), and the newest messages page returns it at its
snapshot version, so a client replaces its turn and needs no rules.

Streams opened with snapshot_version no longer get the status, error,
retry and action_required events, and their syncs skip the full-history
read action_required needed; no client in the repository reads that
event. Streams opened without it keep the events.
Clients cleared their live output on the retry and error events, and the
web client dropped parts after a waiting status. Streams opened with
snapshot_version get neither event, and a client should not have to
infer when an attempt ends. The stream now sends preview_reset when it
retires the current attempt and on the first sync of every connection,
whose parts it replays from the start.

It also retires the attempt when the chat stops running. A turn that
finishes without committing a message keeps its history version and
attempt, so its buffered parts still matched the stream's episode and
reached clients after the turn ended.
When the pubsub subscription or the first read failed, the handler
accepted the socket and sent one error event, the event that carries a
chat's turn error, and the web client showed the chat as errored. The
handler's comment already meant to report setup failures as HTTP
errors, but SubscribeAuthorized never returned its failure branch.

SubscribeAuthorized now returns the error. The handler answers 404 when
the chat was deleted after the route loaded it, writes nothing when the
client went away, and answers 500 otherwise.
Chat actions now set Coder-Chat-Snapshot-Version instead of carrying
snapshot_version in their bodies. Delete-queued and tool-results return
204 again and promote returns 202, as on main, so existing clients,
including the Go SDK SubmitToolResults that requires 204, keep working.
Chat no longer exposes snapshot_version; the newest messages page and
the checkpoint are where a client reads it.
The stream section still described the history_version cursor, which
the stream no longer accepts. It now covers the snapshot_version
parameter, the turn on the checkpoint and the newest page, the action
response header, preview_reset on the first sync and on retirement, and
setup failures as HTTP errors.
applyDBSnapshot treated a history reset as a history change. A reset is
found only among rows read because the chat's history version is above
the stream's, so the check never adds a case since the full reset for a
cursor above the chat was removed.
NonPositiveSnapshotVersionReturns400 only sent 0.
The chatd tests show that SubscribeAuthorized returns setup failures,
but nothing checked what the handler answers. A chat that is gone when
the stream first reads it must get a 404 and any other read failure a
500, both before the upgrade.
The header let a client end an eager update exactly at the commit it
stood for. A client can instead let the next stream update of the same
kind replace the eager item, which needs nothing from the response. The
action handlers and SubmitToolResults, DeleteQueued and PromoteQueued
are as on main again.
A commit that changed no field the stream tracks, such as an archive,
sent a checkpoint built from the cached turn, so every committed
version reached every stream for the action header to match. Without
the header nothing reads that checkpoint, and the cached turn was only
correct while last_error changed together with a tracked field. Such a
hint is a no-op again, as on main.
Only ARCHITECTURE.md said that such a stream sends no status, error,
retry or action_required events. A Go SDK or API client that sets the
version for the smaller first sync would lose them without warning.
The SDK doc said pending dynamic tool calls are read from the chat
watch stream, and the parameter doc said the checkpoint replaces
action_required. Neither carries them: the watch event sets only its
kind and the chat, and the turn has no tool calls.
The comment named a web client behavior that no longer exists: the
client no longer stores send responses.
A stream opened with snapshot_version sends no action_required events,
and its checkpoint turn left out the tool calls, so such a client could
not learn which calls wait for results. The turn now carries them while
the status is requires_action, on the checkpoint and on the newest
messages page, read from the history the same way action_required
reads them.
The added sentences restated the error result.
Clients resume a stream from the version of the last change they
applied, so each message, queue_update and history_reset event needs
the version of the commit that made it.
The checkpoint made clients buffer a sync's events until its end,
though each change already has the version of the commit that made it.
Streams opened with snapshot_version now stamp message, queue_update
and history_reset with that version, send them in version order, and
resume at or after it, since a connection can drop between the events
of one commit. They get main's status, error, retry, action_required
and preview_reset events in place of the checkpoint's turn.

An edit soft-deletes every message from the edited one on, so each
edit becomes one history_reset from the lowest ID it removed, and
every live message at or above that ID was written by the edit or a
later commit. The stream no longer reads the history after a reset.
The stream section still described the checkpoint, its turn, and the
extra preview_reset events, which the stream no longer sends.
@mafredri
mafredri force-pushed the fix/chat-stream-history-replay branch from b60952b to 9a3d397 Compare October 13, 2026 11:21
Nothing calls it, and it soft-deletes rows outside a state machine
transition and from the middle of the history. Streams opened with
snapshot_version rely on every soft-delete removing a message and
every later one, which only EditMessage does.
DeleteChatQueuedMessage, DeleteAllChatQueuedMessages,
PopNextQueuedMessage and ReorderChatQueuedMessageToFront have no
callers. A write through them would change the queue outside a state
machine transition, without a new snapshot version for streams to
resume from.
The setup-failure fix needs only subscribeWithInitialError gone: its
three callers now return false, and the handler already answers false
with 500 before the upgrade. Subscribe keeps main's bool result, and the
snapshot version is a second parameter instead of a cursor struct.

A chat deleted between the route and the subscribe now gets 500, not 404.
The invalid snapshot_version test also covers a non-integer value.
The stream reference listed snapshot_version and the new event fields
but not the rules a client needs to resume from them: where the value
comes from, what the first read sends, the event order, the 400 and
500 cases, and why after_id resends the whole history after an edit.
The parameter text moves into the endpoint description, which the
generator renders above the parameter table.
The migration guide told clients to reconnect with after_id. Once any
message in a chat was edited, a stream opened with after_id sends the
whole history on every connection, so the guide now walks through
resuming with snapshot_version and the client rules that go with it.

The event table gains history_reset, preview_reset and action_required,
and the frame description matches what the server sends: a JSON array
of events per WebSocket message, with no envelope.
The migration test step described JSON envelopes with a data type.
The server writes each batch of events as a bare JSON array.
@github-actions

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

ibetitsmike added a commit that referenced this pull request Oct 19, 2026
…rsor (#29761)

Opening a chat on the Agents page loads its transcript over REST and
then connects the chat stream with `after_id` set to the newest loaded
message. The first sync of the stream ignores that cursor when it
queries the database: it fetches every row of the chat on each
connection, and then sends only the rows above `after_id`.

The first sync now resolves `after_id` to that message's revision and
fetches only rows changed at or after it. The history reset rule is the
one on `main` (#29808): the stream resets only when message `after_id`
is deleted or missing. A deleted cursor still produces one
`history_reset` with the surviving history, because the bounded query
includes the cursor row. An unknown ID keeps the full query. A message
ID from another chat, live or deleted, is ignored, so the client
receives the full history. Live updates after the first sync are
unchanged.

## Performance

Measured on dogfood's database, which runs `main`, with `EXPLAIN
(ANALYZE, BUFFERS, SERIALIZE BINARY)` of the first-sync query. The chats
are the 554 chats updated in the past day, picked by row count. `main`
starts at revision 0. This PR starts at the revision of the newest live
message, which is the `after_id` the web client sends.

| Chat (rows) | `main` output | `main` first / repeat run | This PR
output | This PR time |
|---|---|---|---|---|
| p50 (41) | 189 kB | 21.5 ms / 0.6 ms | 1 kB | 0.1 ms |
| p90 (351) | 2.0 MB | 163 ms / 7.9 ms | 10 kB | 0.5 ms |
| p99 (2,200) | 7.1 MB | 425 ms / 25 ms | 14 kB | 3.0 ms |
| Largest (14,746) | 64.9 MB | 4.8 s / 193 ms | 6 kB | 17.6 ms |

- Across the 554 chats, the bounded query returns 1 row up to p99 and 5
rows at most.
- Both queries visit the same heap pages, because no index covers
`revision`. The saving is the TOAST reads, serialization, transfer, and
decoding of rows that `main` then discards. Most of `main`'s time is
serialization: 153 ms of 193 ms for the largest chat.
- The cursor lookup is one primary-key read of about 0.4 ms, so a median
chat gains almost no latency. The gain grows with chat size.
- Deleted rows are about 0.1% of rows, so they do not change the cost.

## Not changed

A tab whose stream was down can miss an edit, then send a message and
store the response, so its newest ID is above the edit. On reconnect,
message `after_id` is live and the stream sends no reset, so the tab
keeps the deleted messages. `main` has done the same since #29808, and
this PR does not change it (mafredri's reproduction gives `expected [5 6
7 8], actual [3 4 7 8]` on both). An ID cannot show which edits the
client applied. #29662 adds a client-reported version for that.

<details>
<summary>Delivery record</summary>

- Rebased on `main` at `6ebefb83254`, keeping the #29808 reset rule and
#30266 read-only snapshot reads. The cursor-bound mock test is part of
`TestStreamLoopInitialSyncTombstones`.
- Assumption checked on dogfood: no live message has a higher revision
than a later message. Messages are only updated to soft-delete them or
to set search columns, which the revision trigger ignores. 2,861 rows in
29 recently active chats break it, all created between 2026-06-26 and
2026-07-16 and none since. A client cursor cannot sit before them,
because the client sends its newest message.
- Local: `go test ./coderd/x/chatd/...`, `go test ./coderd -run
'^TestStreamChat$'`, `go test ./coderd/database/dbauthz -run
'^TestMethodTestSuite$'`. Mutants of the cursor bound (no `- 1`, no
bound, cursor from another chat kept) each fail `TestStreamLoop*`.
- Remote UAT before the rebase covered deleted, foreign, and unknown
cursors, edits, grouped commits, and reconnects. On a synthetic
5,000-message chat, dial to first snapshot went from 129.6 ms to 15.0
ms.
- Codex: [round
1](#29761 (review))
and [round
2](#29761 (review))
found cross-chat cursor bugs, fixed. Round 3 and the round on
`7bed7855e41` found nothing.

</details>

> Xum opened this PR on behalf of @ibetitsmike.

This branch has not been deployed

No deployments
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.

1 participant