Repository navigation
Conversation
…wn approval DecideApproval answers a step's approval in place on the reply being written, while flush may be encoding the queued copy of that reply and the socket may be encoding a published update of it. Both copies shared the approval's pointer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Responses record the response before them on their path and the question they answer. agent_branch_switches journals a move to another branch until it is done; agent_branch_messages keeps copies of the Stream Chat messages a rewind hid. Both cascade with their session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Siblings and BranchTail read a session's response tree across shards; MoveActivePath rewinds the old branch after the fork and brings the new one back on every shard. Snapshots, the switch journal and the merged count of the ranges Chat hid are scoped to their customer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A data move carries agent_branch_switches and agent_branch_messages with their session, since the hidden ranges they describe stay in the Stream app. BranchTail says which fields its rows leave empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake Chat serves POST .../channels/{type}/{id}/rewind with Chat's
checks in Chat's order and its error bodies (reason in exception_fields):
fork_in_progress, rewind_conflict with last_message_id, nothing_to_rewind,
rewind_point_ambiguous, too_many_rewinds with a configurable cap, and the
403 of an app without the feature. Hidden created_at ranges merge as
Chat's rewindstate.Merge does. Channel reads and reads by id leave hidden
messages out, a send into the hole is moved past it, and a channel read
with id_gt/id_gte pages ascending by limit. Channels without ranges read
as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewinder rewinds an agent channel through getstream.MakeRequest and maps Chat's refusals, by exception_fields.reason, to ErrRewindConflict (*RewindConflictError with last_message_id), ErrNothingToRewind, ErrRewindAmbiguous, ErrForkInProgress, ErrTooManyRewinds (*TooManyRewindsError with the limit) and ErrRewindUnavailable (403, code 17). ReadFrom reads a channel's visible messages from a message to the end, 100 a page, and the last id Chat counts, a soft-deleted message included, for expected_last_message_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chat's MessageIDDoesntExist is an input error (400, code 4), not a 404, for a target it does not have or hides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t fake Chat answers a partial or ephemeral update of a message a rewind hid as one of a message it does not have, which is what wedges a write queue that still holds it. The fake also keeps skip_push on a sent message so a test can read what it was sent with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
flush() sent Pending[0] with the lock released and then popped Pending[0] without checking it was the op it sent, so anything taking ops off the queue meanwhile made it drop the next op unsent. A send mutex is now held from reading the head to taking it off, and dropPending takes it too. The finished-reply hook runs outside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A switch copies the messages it is about to hide before asking Chat to rewind, so a crash between the two loses nothing. When Chat refuses the rewind, those copies are of messages that are still visible, and the row would be resumed on every open. AbandonBranchSwitch deletes the row and the copies made under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reply A message now carries custom.support_branch: the response it belongs to, its parent and question responses, and where it stands among its alternatives. It is a key of its own, never part of support_runtime, which an older router decodes strictly; a mark this router cannot read is dropped rather than failing the message. BeginCommandWithTurn writes both messages with the caller's response id from creation. BeginRegenerate writes only a new reply to an existing question under a command id of its own, which commandsIn attributes to the question's author after a reopen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conversation.Rewind quiesces a kept conversation and rewinds its channel for a regenerate or an edit: the reply in flight is finished as interrupted, every queued write is flushed under the send mutex, the tail is copied and the switch journaled before Chat is asked, and only after Chat hid them are the hidden messages' queued writes dropped and their ids kept out of every later write and read. A rewind_conflict is read again and retried once; Chat's refusals map to ErrBranchLimit, ErrBranchConflict and ErrBranchUnavailable and forget the switch with its copies. A queue that cannot be written is ErrConversationUnwritable. Conversation.Activate also sends the chosen branch again from its copies, at most MaxResent messages, under ids derived from the switch and the original, with skip_push and custom.resent_from, the person's message under its request id and the reply's question_id following it. Opening a conversation under WithSession finishes the switches an earlier router left unfinished and leaves out every message they hid. Thread, dispatched, other-channel and shared conversations are refused with BranchesUnsupportedError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Snapshots of messages a rewind hid hold the user's words, so the forget flow deletes them on every shard, counts them in a dry run, reports the count and records it in the audit row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MergedRangeCount skipped nothing: an activation at the end of the conversation journals a row that copied no message, and was counted as a range Chat keeps. MoveActivePath now takes an empty leaf for a first question asked again, and SessionResponse and ActiveLeaf read one response and the conversation's end for the session layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t again A session switching branches needs the person's message a response answers, the question shown after a response, and the history of a page read with the ctx it opened with. Remarked writes the place each copy of an activated branch holds now, not the one it was hidden with, and three reasons name a call, a speech-to-speech model and an unmarked response as conversations that cannot have branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerate, Edit and Activate quiesce the agent, rewind the session's Chat channel, move the store's active path and hand the model the active path again, the way a reopen does. Every turn is now recorded after the conversation's newest response, and a kept conversation's messages carry the response id allocated before they are written. A reopen carries on from the newest response on the active path, the titler and review read the active path, TruncateMemories deletes the user's branch copies, and router_branch_merged_ranges counts the hidden ranges after each switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…conversation Activating a response below the first one a switch hid forked after the response's own parent, which the channel does not show, and sent the branch from the middle. The activation now climbs to the first hidden response and sends the path from there through the one asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createResponse takes replaces: no text regenerates the replaced
response's reply, text edits its question. Its answer carries
parent_response_id, branch {of, index, count} and rewound_to, so the
acting device prunes its view at once. listResponseSiblings,
getResponseBranch and activateResponse read and bring back the
branches a switch leaves, owner-only and open to devices. The session
refusals map to branch_limit_reached, branch_too_long and
branch_unavailable, 409, 400, 404, 503 and not_configured.
session.BranchShown reads a hidden branch from the store the way an
activation would send it again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Responses.Regenerate and Edit send createResponse with replaces, Siblings and Branch page through a response's alternatives and a hidden branch's messages, and Activate brings a branch back. Tested against the client package's stand-in router. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ersation documents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s on A regenerate, an edit or an activation moved the conversation to another branch but left the model pinned with the documents it had: an abandoned branch's, or none of an activated one's. Each now pins the documents of the new active path. A regenerate and an edit read them before anything is rewound; an activation reads them once its branch is on the conversation. A regenerate keeps the question, so the files sent with it are copied to the new response (store.CopyResponseDocuments), written on this router's shard beside the turn it records. The replaced response keeps its own, so activating its branch finds them. An edit replaces the question, and its files stay with the branch it leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A regenerate or an edit takes text alone. Images, audio, videos and ui_action were refused with replaces, but a file was silently dropped. The rendered spec also carries the branch endpoints, fields and error codes that 695a8880 rendered before the rebase onto accelerate, which did not keep it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng from a hidden message Chat reads a rewound message as missing, so a read whose cursor names it is a 400 "Message with id ... doesn't exist". An edit or an inclusive activation hides the message it rewinds to, so landed() could never decide such a switch and the next open left it snapshotted for good. landed() now reads the channel from its newest message back, page by page with id_lt on a message Chat showed, until it passes the switch's hidden_from. tailFrom names Chat's 400 as a branch conflict, and a retried switch's errors go through branchRefusal rather than becoming a 500. The fake now refuses a cursor naming a hidden or unknown message as Chat does, and pages id_lt and limit reads newest first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…have rewound A regenerate, edit or activation that failed after its snapshot left the live session on the old branch: its leaf, model history and hidden ids still held what Chat no longer showed, so the next question was answered after a hidden parent. On reopen the Switched hook then moved the path to the replaced response's parent, taking off turns asked since. - On an error that leaves it unknown whether Chat rewound, the session reads Chat again (Conversation.SettleSwitch): a rewind that landed is carried on and the request answers as if nothing failed; one that never landed is forgotten and the error stands. - The leaf, history and documents follow Chat before the path is moved. - A switch that cannot be settled, or whose path cannot be moved, refuses every other turn with ErrSwitchUnfinished (a 409) until its own request is retried or the conversation reopens. A retried activation finishes the earlier one where it rewound to. - Moving the path takes off only responses created before the switch began (store.MoveActivePathUntil), here and in the reopen hook. - A switch that cannot be marked done is left for the next open, which the bound makes harmless. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An edit hides the question it replaces. A retry of one whose first try failed after the rewind landed looked the question up in the channel, found nothing and was refused as unmarked. A regenerate retried after its path moved was refused as off the conversation the same way. A retry now looks for its request's unfinished switch first and carries it on from the message it rewound to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with A regenerate rebuilt the question from the text-only history and handed the model no images, so a question that came with photos was answered without them, and a photo sent alone was answered as an empty question. The photos are read back from the question's Chat message, by the URL Chat serves each from, and handed to the model with its words, as the first turn handed them. A model that cannot see them refuses the regenerate before anything is hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as written in RewindTarget.TurnResponses was never filled in, so a hidden message with no support_branch mark, as a customer's backend writes into the channel or an older release wrote, was copied with an empty response id and dropped when its branch came back. Such a message is now copied under the response of the newest marked message before it, the message rewound to included, and the unused field is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unmarked refusal and the siblings description named a turn sent through /respond as one without branch marks. A text session's /respond turn is written with its response's marks like any other (nextTurn): it looked unmarked only while its messages were still queued for Chat, which a19feb2 drains first. What carries no mark is a turn opened without a response claimed for it, such as one spoken on a call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ai-3209-session-rewind-is-refused-for-every Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turn sent through /respond is written with its response's marks, so it can be rewound to: the test that expected it to be refused passed only while its messages were still queued for Chat, which rewind now drains first. That test now checks the rewind succeeds, and a new one pins the real refusal: a rewind that would hide a response whose messages carry no mark, as a turn spoken on a call writes them. The rewind description and the changelog say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getstream-jeffery
Bot
dismissed
their stale review
November 13, 2026 18:58
Withdrawing approval until 8197579 is reviewed.
…vals The live tick copied the reply's parts with a shallow copy, so the update it encodes after unlocking shared each step's approval with the live reply: answering the approval meanwhile changed it mid-encode, the race this PR fixes for queued writes and published updates. The tick now sends liveCopy, whose parts are cloned like theirs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds meanwhile An edit retried while the rewind its first try sent landed got this rewind refused: Chat no longer had the question to rewind to. The refusal abandoned the switch, which deleted the copies of what the first rewind hid, so the branch was lost. A refused rewind now checks whether the switch's copied messages are already hidden, and carries on if so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ Approved 8197579b75ce after 2 rounds
Left open, your call:
- consider: Doc comment for opens sits above the wrong function (
acceleration/internal/api/branches_test.go:359) - consider: Failed question count marks the new question as the only one (
acceleration/internal/session/session.go:2105)
Not covered: router rewind against real Stream Chat, exercised only against the chattest fake (PR description).
getResponseBranch resumed a page at the first message whose copy is newer than the cursor's. A branch is in the tree's order, and a message an earlier activation sent again has a newer copy than the messages after it, so the next page came back empty or repeated messages. The cursor's message is now found by id and the page starts after it; a cursor whose message is gone falls back to time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nches-router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ai-3209-session-rewind-is-refused-for-every Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getstream-jeffery
Bot
dismissed
their stale review
November 13, 2026 19:12
Withdrawing approval until e5c6bed is reviewed.
Author
|
/jeffery review |
…t read Chat The check that keeps a refused rewind's copies when an earlier try landed dropped its own error: a failed channel read left the refusal in place, and the switch was abandoned with its copies though Chat may have hidden those messages. It now returns the read's error, leaving the switch for the next open. The retry test also checks what the rescued rewind reports as hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ai-3209-session-rewind-is-refused-for-every Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getstream-jeffery
Bot
dismissed
their stale review
November 13, 2026 19:23
Withdrawing approval until 8a8709b is reviewed.
Author
|
/jeffery review |
Nothing changed since round 3 on |
Loschcode
added a commit
that referenced
this pull request
Nov 13, 2026
## Merge order This PR is one of a stack. Merge in this order, each after the one before: 1. #1975 (this PR), base `accelerate` 2. #1983, base #1975's branch 3. #1984, base #1983's branch 4. GetStream/stream-chat-react#3434, base `master` 5. #2012, base #1983's branch. Deploy GetStream/chat#20865 before the router that carries it. ## Why Answering a held tool call's approval (`DecideApproval`) changes the approval in place on the reply being written. At the same moment the router can be encoding a copy of that reply in two other goroutines: the queued Chat write that `flush` sends, and the `Updated` it published to the conversation's socket. Both copies share the step's `*Approval` pointer, because `enqueue` and `publish` copy the parts slice but not the pointers inside it. That's a data race, and a write can carry a half-changed approval. `go test -race -tags integration ./internal/api/` caught it while building AI-3198, whose flush changes made the timing likely. The bug itself is on `accelerate` today. ## How it works ``` reply being written (c.data.Current) Parts[i].Approval ──┐ before: one *Approval shared by all three ├──> queued write (enqueue) -> flush encodes it └──> published update (publish) -> socket encodes it after: clonedParts gives each copy an Approval of its own ``` | Change | Result | Test | |---|---|---| | `clonedParts` (`internal/conversation/parts.go`) copies each part's approval | the queued write and the published update keep the approval they were made with | `TestDisplaySuite/TestQueuedAndPublishedCopiesKeepTheirOwnApproval` | | `enqueue` and `publish` use it (`internal/conversation/conversation.go`) | `DecideApproval` changes only the live reply | same test fails without the fix: `Should be empty, but was allowed` for both copies | `Authorization`, `StartedAt` and `FinishedAt` are always replaced with a new pointer rather than changed in place, so they need no copy. ## Validation - `go test -race ./internal/conversation/` passes. - Mutation check: with `clonedParts` reduced to a shallow copy, the new test fails on both the queued write and the published update. Part of AI-3198 (regenerate and edit with branches), split out because it stands alone. The branches PR stacks on this one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Loschcode
added a commit
that referenced
this pull request
Nov 13, 2026
Ticket: [AI-3198](https://linear.app/stream/issue/AI-3198/regenerate-and-edit-in-place-with-branches-12-in-kept-conversations). Stacked on #1975 (approval race fix). PR 3/3 (JS SDK) and GetStream/stream-chat-react#3434 (UI) build on this. ## Merge order This PR is one of a stack. Merge in this order, each after the one before: 1. #1975, base `accelerate` 2. #1983 (this PR), base #1975's branch 3. #1984, base #1983's branch 4. GetStream/stream-chat-react#3434, base `master` 5. #2012, base #1983's branch. Deploy GetStream/chat#20865 before the router that carries it. ## Why In a conversation kept in Stream Chat, which is every non-incognito session, `responses.rewind` is refused, so StreamGPT regenerates by forking. Each regenerate becomes a new chat in the history list, an edit isn't possible, and there's no ‹ 1/2 ›. This PR lets a person regenerate any reply or edit any question in place, and keeps the earlier versions as branches they can switch between. ## How it works The router keeps every version as a tree of responses. The Chat channel only ever shows the branch the person is on: the others are hidden with Chat's agent-channel rewind (`POST /channels/{type}/{id}/rewind`), and the router keeps a copy of each hidden message. ``` regenerate reply k / edit question k ├─ refuse: shared, thread, dispatched, live call, speech-to-speech, │ not kept in Chat, a turn not asked through createResponse │ (such as one spoken on a call: "unmarked") ├─ pause: interrupt the reply, flush the Chat write queue ├─ copy every visible message from the point on -> agent_branch_messages ├─ journal the switch -> agent_branch_switches ├─ Chat rewind (expected_last_message_id = last id read; retry once on conflict) ├─ drop queued writes of hidden ids, remember them as hidden ├─ move the active path (rewound_at = off the path) across shards └─ ask again: new response, sibling of the replaced one browse ‹ i/n › -> GET .../siblings, GET .../branch (no Chat write) send on another branch -> POST .../activate: rewind at the fork point, re-send the branch's copies (<= 100), then send ``` ```mermaid sequenceDiagram participant Device participant Router participant Postgres participant Chat Device->>Router: createResponse replaces=r2 (regenerate) Router->>Chat: read messages from the question on Router->>Postgres: save copies, journal switch Router->>Chat: rewind to the question (exclusive) Router->>Postgres: move active path to r2's parent Router->>Chat: new reply, support_branch reply 2 of 2 Router-->>Device: AgentResponse branch, rewound_to Chat-->>Device: channel.rewound (other devices) ``` Each message carries `custom.support_branch = {response_id, parent_response_id, question_response_id, of, index, count}`, a new key rather than a field of `support_runtime`, whose strict decode would break the previous release on rollback. | Limit | What happens | Error | |---|---|---| | 100 hidden stretches per channel (Chat's cap): about 100 distinct regenerated or edited turns | the regenerate or edit is refused; asking and reading still work | 409 `branch_limit_reached` | | A branch brought back is over 100 messages | the activation is refused | 409 `branch_too_long` | | A branch's copies were deleted (memory truncate) | it can't be read or brought back | 410 `branch_unavailable` | | Merged hidden ranges per conversation | histogram `router_branch_merged_ranges`, warning log past 80 | none | ## Changes | Change | Where | Proved by | |---|---|---| | Migration `20261227100000_response_branches`: `parent_response_id` and `question_response_id`, copies table, switch journal | `migrations/`, `internal/store/branches.go` | `StoreSuite`, `ShardedStoreSuite` (path move across 2 shards, cascade, cross-customer isolation) | | Chat rewind client and paged tail read, through getstream-go v5's `MakeRequest` | `internal/conversation/rewind.go` | `rewind_test.go`; the chattest fake models Chat's hidden ranges and errors | | `flush` holds a send mutex from reading the queue head to popping it | `internal/conversation/conversation.go` | `TestFlushSuite`; fails without the mutex | | Pause, copy, rewind, purge, re-send and resume of an unfinished switch on reopen | `internal/conversation/branches.go` | `TestBranchesSuite` (27 tests: streaming reply, unwritable queue, every journal state) | | Regenerate, edit and activate, plus leaf tracking, history and titler reset, and documents following the active path | `internal/session/branches.go` | session `BranchesSuite` (model history equals what Chat shows; marks; reopen) | | `replaces` on createResponse; siblings, branch and activate endpoints | `internal/api/branches.go`, `responses.go` | `TestResponseBranchesSuite` (posture, owner-only, paging, 410, cap) | | `forget-user` and memory truncate delete the copies | `cmd/router/usage.go`, `session/manager.go` | `TestForgetUserSuite`, session tests | | Go SDK: `Regenerate`, `Edit`, `Siblings`, `Branch`, `Activate`; every client regenerated | `sdks/` | each SDK's `--check` and unit tests | ## Notes for review - **`rewound_at` now means "not on the active path".** Sharding rules out one recursive query, so the existing filters (`store/sessions.go`) stay unchanged. The non-persistent rewind keeps working on the same column. - **Re-sent messages are new Chat messages:** new ids, `skip_push`, and `custom.resent_from = <original id>`. Customer webhooks see `message.new` again, which is documented. - **What isn't cleaned up:** - Memories from abandoned branches are kept (AI-3203). - A server-side delete of a hidden Chat message doesn't reach the router (AI-3226). - **Prerequisite:** on a chat release before GetStream/chat#20865, the Chat app needs the `chat.agent_rewind` grant, and without it the request gets `not_configured`. From #20865 on, every agent channel can be rewound, and only the `chat.agent_rewind.kill` switch stops it. - **A switch whose rewind answer was lost is kept, not forgotten.** A rewind that timed out can still be committed by Chat after the router reads the channel. While the channel's newest message is still the switch's newest copy (the `expected_last_message_id` it sent), the journal row and its copies are kept and nothing is counted as hidden. Once anything newer is shown, Chat would refuse that rewind, so the switch is forgotten as before. A kept switch never refuses the next turn. - **Kept conversations can now be rewound** by `rewindSession` too, in #2012 (AI-3209), stacked on this PR. ## Fixes from the pre-ship review Each has a test that fails without it. | Fix | Commit | Test | |---|---|---| | A branch brought back is sent in the tree's order, not by its copies' times. A question above it that an earlier activation re-sent sorted after the branch: `[Q2, A2, Q1, A1]` | 348a49f | `TestABranchBroughtBackUnderAnAncestorSentAgainKeepsTheTreesOrder` (Chat, model history, reopen) | | A switch whose rewind may still land is marked unsettled, so the next turn checks again | 1f88f61 | `TestARewindThatLandsLateIsFoundByTheNextQuestion` | | An edit's text and id are checked before Chat hides anything | b8a87c8 | `TestAnEditThatCannotBeAskedIsRefusedBeforeAnythingIsHidden` | | A hidden request retried after a router restart is a duplicate, with no orphan reply | 87adc70 | `TestARequestAnEditHidRetriedAfterARestartIsADuplicate` | | Regenerate and edit flush the session's records, and drain the conversation's queued Chat writes, before reading. A turn sent through `/respond` is marked; it only looked unmarked while still queued (06a761e) | 9d04436, a19feb2 | `TestARegenerateRightAfterATurnNotYetRecordedRegeneratesIt`, `TestARegenerateWaitsForTheTurnsMessagesToReachChat` | | The PHP client is regenerated for `parent_response_id` | dddc9f2 | `composer generate-check` | `origin/accelerate` is merged in through #1975. ## Validation - `go test -race` passes on `internal/{store,conversation,session,api}`. The integration suites for store, conversation, session and api pass with `-tags integration`. - `TestKnowledgeFilesSuite/TestAChartOnAPageIsSummarisedAfterThePagesText` also fails on untouched `accelerate` (same assertion, `knowledgefiles_test.go:246`). It's unrelated. - The mutation checks below each fail as expected: removing the send mutex, the hidden-id skip, or the document fixes. - Every generated client's `--check` passes, as do Rust fmt and clippy, PHPStan, and each SDK's unit tests. `uv run dev.py check` passes. - Unverified against real Chat: - the rewind integration test skips without credentials; - the 404 wording Chat gives when updating a rewound message is assumed in the fake. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from
laurent/ai-3198-branches-router
to
accelerate
November 13, 2026 20:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getstream-jeffery
Bot
dismissed
their stale review
November 13, 2026 20:10
Withdrawing approval until 0afa353 is reviewed.
|
✅ Nothing new to review. The latest push didn't change the code I reviewed (it was a rebase or a merge from the base branch), so my last review stands. See the checks tab for its current state. |
Loschcode
added a commit
that referenced
this pull request
Nov 13, 2026
Ticket: [AI-3198](https://linear.app/stream/issue/AI-3198/regenerate-and-edit-in-place-with-branches-12-in-kept-conversations). Stacked on #1983 (router), which is stacked on #1975. The UI side is GetStream/stream-chat-react#3434. ## Merge order This PR is one of a stack. Merge in this order, each after the one before: 1. #1975, base `accelerate` 2. #1983, base #1975's branch 3. #1984 (this PR), base #1983's branch 4. GetStream/stream-chat-react#3434, base `master` 5. #2012, base #1983's branch. Deploy GetStream/chat#20865 before the router that carries it. ## Why #1983 adds regenerate, edit and branches to the router. This brings them to `@stream-io/stream-agents`: methods for any app, and `agentChatTransport` support. With that, an `AIConversationController` gets ‹ i/n ›, regenerate on any reply and edit on any question without app code. It also keeps the device's view true when Chat hides messages: stream-chat neither handles `channel.rewound` nor drops messages on a re-query. ## How it works ``` agentChatTransport send(regenerate, message) -> responses.regenerate(response of message) send(edit, message, text) -> responses.edit(response of the shown reply, text) switchBranch(message, to) -> siblings(of) -> sibling at index +/- 1 ├─ the active one -> back to the channel's messages └─ another -> branch(sibling): channel up to the fork, then its copies (display only, nothing written to Chat) send(message) while another version shows -> activate(newest shown) -> send session channel rewound_to in an answer -> drop the hidden messages the device held, at once channel.rewound / notification.channel_rewound -> drop them, reload, drop again (Chat's reads can lag) ``` | Change | Result | Test | |---|---|---| | `responses.regenerate`, `edit`, `siblings`/`unwindSiblings`, `branch`/`unwindBranch`, `activate`; `branchErrorOf`, `supportBranchOf` | typed calls for the new endpoints and error codes | `tests/session.test.ts` "Regenerate, edit and branches" | | Hidden messages dropped from channel state (`src/branches.ts`) | the acting device never shows old and new answers together; other devices update without a reload | prune tests fail with removal disabled | | `agentChatTransport` branches, `switchBranch`, `AgentBranchError` | the controller path works end to end | `tests/agent-chat-transport.test.ts` | | stream-chat 10: nanosecond `created_at`, `reload()` not `query()`, flushing the 500 ms message-list publish | the prune works on 10 too | the suite on 10.0.2; each fix fails its test when reverted | | A missing `of`/`index`/`count` reads as 1 of 1 | first answers can be branched | `tests/stream.test.ts` | ## Notes for review - **stream-chat 9 merges a re-query into state rather than replacing it** (`channel.ts` `_initializeState`). That's why the prune removes messages explicitly instead of relying on a reload. - **A question keeps the mark of the response that first answered it**, which a regenerate hides. So an edit goes through the reply shown after the question. - **The test harness moved onto a shared real-client helper** (`tests/stream-chat.ts`). CI's `js-stream-chat-10` job no longer compiles against 9-only APIs (`baseURL`, `addMessageSorted`). ## Fixes from the pre-ship review - `agentChatTransport` no longer treats a `switch_unfinished` 409 as the remembered response being gone (76c1aca). Tested by "keeps the response remembered for a target while the router finishes an unsettled switch". - The fake router's test token is built at run time (eff2da4). ## Validation - `npm run types -- --check` is in step with the spec, and `tsc --noEmit` is clean. - `npm test` passes 613 of 613 on stream-chat 9.54.0 and 613 of 613 on 10.0.2. - GitGuardian flagged the unsigned test token in `tests/stream-chat.ts` (and in `tests/session.test.ts` before the helper moved). It is now built at run time (eff2da4). The incident stays on the earlier commits of this PR until it is resolved as a false positive in the GitGuardian dashboard. - Unverified: that real Chat sends nanosecond numbers on the wire with stream-chat 10. That's inferred from its types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Loschcode
deleted the
laurentschaffner/ai-3209-session-rewind-is-refused-for-every
branch
November 13, 2026 20:42
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.
Ticket: AI-3209. Stacked on #1983 (AI-3198 branches router), which is stacked on #1975. Chat side: GetStream/chat#20865.
Merge order
This PR is one of a stack. Merge in this order, each after the one before:
acceleratemasterDeploy order
agentchannel type the gate for rewind, instead of the per-appchat.agent_rewindgrant. Without it, an app without the grant gets 403 from Chat, and this answers it asnot_configured.Why
POST /v1/agents/sessions/{id}/rewind(responses.rewind) answered 400 for every session a caller can open. The sessions API keeps every non-incognito conversation in Stream Chat, andSession.Rewindrefused any such session, because reopening it would read the rewound turns back from the channel.How it works
A rewind to response R in a kept conversation is #1983's edit of the turn after R, without the new question:
So the rewound turns are a branch like any other. The next question is an alternative to C (‹ 1/2 › on the question), and
getResponseBranchandactivateResponseread and bring C back rather than answering 410. A router that stops partway is finished by the next open, exactly as an edit is. It counts toward the 100 edit and regenerate points (branch_limit_reached), and is labelledrewindonrouter_branch_merged_ranges.Changes
Session.Rewindrewinds a kept conversation instead of refusing itinternal/session/session.go,rewindKeptininternal/session/branches.goBranchesSuite:TestRewindingAConversationKeptInChatHidesTheLaterTurnsAndCarriesOnFromTheKeptOne(Chat, model history and reopen agree),TestTheTurnsARewindHidAreABranchThatCanBeBroughtBack,TestRewindingAKeptConversationToItsNewestResponseHidesNothing,TestARewindToAResponseOffTheConversationIsRefused,TestARewindToAResponseTheSessionDoesNotHaveIsNotFound,TestARewindChatHasTurnedOffIsRefusedAndTheConversationIsLeftAsItWasstore.ActiveChild: the response after another on the active pathinternal/store/branches.gorewindSessionanswers branch refusals as the branch endpoints do (404 unknown response, 409,not_configured)internal/api/sessions.goTestResponseBranchesSuite/TestADeviceRewindsAConversationKeptInChat,TestAServerRewindsAConversationKeptInChat;TestSessionVerbsSuite/TestRewindingToATurnNobodyTookIsRefusednow 404api/openapi.yaml,sdks/*,plugins/stream--checkCHANGELOG.mdNotes for review
conversation/rewind.go, chattest rewind handler and anagent_responses.chat_message_idcolumn with migration20261228100000_response_chat_message.sql. AI-3198: regenerate and edit with branches in kept conversations #1983 already has a Chat rewind client, a fake that models Chat's hidden ranges, andsupport_branchmarks that find a response's messages. The merge resolves every file this branch touched to AI-3198: regenerate and edit with branches in kept conversations #1983's version. The migration is dropped, so this PR adds no migration of its own.responses.rewindon a kept session rewinds it, as this PR does.createResponseorPOST /respondcarry the marks. A turn spoken on a call doesn't, and a rewind that would hide it is refused as unmarked.responses.rewindon a kept session does not returnrewound_to, so the acting device relies onchannel.rewound(AI-3198: branches in the JS SDK and agentChatTransport #1984's prune) to drop the hidden messages.Fixes from the pre-ship review
Each has a test that fails without it.
TestARewindRightAfterATurnNotYetRecordedHidesItTestTheTurnsARewindHidAreABranchThatCanBeBroughtBack,TestTheQuestionAskedAfterARewindAndAReopenIsShownBesideTheRewoundOneTestARewindAskedAgainAfterItsAnswerWasLostFinishesIt,TestARewindMadeAgainAfterItsTurnWasBroughtBackIsSettledWhenItsAnswerIsLostTestARewindWaitsForTheNextTurnsMessagesToReachChatrewindSessioncarries on an ended kept conversationTestAnEndedConversationKeptInChatIsRewound/respondturn can be rewound to; the unmarked refusal is pinned on a turn with no marksTestATurnSentThroughRespondCanBeRewoundTo,TestARewindThatWouldHideAnUnmarkedResponseIsRefusedTestRewindsAndActivationsAroundARegenerateKeepTheTreesOrderValidation
With
-race -tags integrationagainst local Postgres and Redis:./internal/conversation/,./internal/store/: pass../internal/session/: pass, except six timing tests (voice notes, a retiring conversation, a spoken tool call) that failed under CPU contention and pass when run alone../internal/api/: pass, exceptTestChatEpisodesSuite/TestAnIdleChatEpisodeIsSummarizedFromTheMessageThatOpenedIt, which also fails locally on AI-3198: copy a step's approval with its reply #1975 (accelerateplus one commit). Five more (StreamApps, Telnyx and the two chart tests) failed under load and pass when run alone.npm run types -- --check(js),uv run sdks/swift/generate.py --check; Go, Python and Kotlin regenerated with their generators. .NET, Rust, PHP, Ruby and Dart carry no rewind description; their hand-written doc comments were edited.🤖 Generated with Claude Code