Skip to content

AI-3209: rewind a conversation kept in Stream Chat - #2012

Merged
Loschcode merged 77 commits into
acceleratefrom
laurentschaffner/ai-3209-session-rewind-is-refused-for-every
Nov 13, 2026
Merged

Loschcode merged 77 commits into
acceleratefrom
laurentschaffner/ai-3209-session-rewind-is-refused-for-every

Conversation

@Loschcode

@Loschcode Loschcode commented Nov 13, 2026 •

Copy link
Copy Markdown

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:

  1. AI-3198: copy a step's approval with its reply #1975, base accelerate
  2. AI-3198: regenerate and edit with branches in kept conversations #1983, base AI-3198: copy a step's approval with its reply #1975's branch
  3. AI-3198: branches in the JS SDK and agentChatTransport #1984, base AI-3198: regenerate and edit with branches in kept conversations #1983's branch
  4. feat(ai-components): regenerate and edit with ‹ i/n › message versions stream-chat-react#3434, base master
  5. AI-3209: rewind a conversation kept in Stream Chat #2012 (this PR), base AI-3198: regenerate and edit with branches in kept conversations #1983's branch. Deploy GetStream/chat#20865 before the router that carries it.

Deploy order

  1. Chat first: GetStream/chat#20865 makes the agent channel type the gate for rewind, instead of the per-app chat.agent_rewind grant. Without it, an app without the grant gets 403 from Chat, and this answers it as not_configured.
  2. Then the router, with AI-3198: copy a step's approval with its reply #1975 and AI-3198: regenerate and edit with branches in kept conversations #1983 under it.

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, and Session.Rewind refused 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:

rewind to R
  ├─ refuse as a branch switch is: thread, dispatched, shared, call, speech-to-speech,
  │  not kept, a turn with no branch mark such as a spoken one ("unmarked"), R off the conversation (409),
  │  an earlier switch unfinished (409 switch_unfinished)
  ├─ C = R's child on the active path        (store.ActiveChild); none -> 204, nothing hidden
  ├─ find C's question in Chat by its support_branch mark
  ├─ #1983's switch, journaled as an edit of C: pause, copy C.. to agent_branch_messages,
  │  Chat rewind inclusive to C's question, purge queued writes of hidden ids
  ├─ leaf = R; model history and documents rebuilt from what Chat now shows
  └─ move the active path to R (rewound_at on C and after)

So the rewound turns are a branch like any other. The next question is an alternative to C (‹ 1/2 › on the question), and getResponseBranch and activateResponse read 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 labelled rewind on router_branch_merged_ranges.

Changes

Change Where Proved by
Session.Rewind rewinds a kept conversation instead of refusing it internal/session/session.go, rewindKept in internal/session/branches.go session BranchesSuite: TestRewindingAConversationKeptInChatHidesTheLaterTurnsAndCarriesOnFromTheKeptOne (Chat, model history and reopen agree), TestTheTurnsARewindHidAreABranchThatCanBeBroughtBack, TestRewindingAKeptConversationToItsNewestResponseHidesNothing, TestARewindToAResponseOffTheConversationIsRefused, TestARewindToAResponseTheSessionDoesNotHaveIsNotFound, TestARewindChatHasTurnedOffIsRefusedAndTheConversationIsLeftAsItWas
store.ActiveChild: the response after another on the active path internal/store/branches.go the session tests above
rewindSession answers branch refusals as the branch endpoints do (404 unknown response, 409, not_configured) internal/api/sessions.go TestResponseBranchesSuite/TestADeviceRewindsAConversationKeptInChat, TestAServerRewindsAConversationKeptInChat; TestSessionVerbsSuite/TestRewindingToATurnNobodyTookIsRefused now 404
Spec and clients regenerated; SDK doc comments and READMEs no longer say to fork instead api/openapi.yaml, sdks/*, plugins/stream each generator's --check
CHANGELOG CHANGELOG.md

Notes for review

Fixes from the pre-ship review

Each has a test that fails without it.

Fix Commit Test
Records are flushed before the tree is read 2f591e5 TestARewindRightAfterATurnNotYetRecordedHidesIt
The question asked after a rewind is marked 2 of 2, from the tree, so it survives a reopen 2f591e5, eb78f8c TestTheTurnsARewindHidAreABranchThatCanBeBroughtBack, TestTheQuestionAskedAfterARewindAndAReopenIsShownBesideTheRewoundOne
A rewind asked again finishes its unfinished switch; any other rewind gets a fresh id, so repeating a rewind after bringing the turn back can't reuse a finished id 2f591e5, 712e35b TestARewindAskedAgainAfterItsAnswerWasLostFinishesIt, TestARewindMadeAgainAfterItsTurnWasBroughtBackIsSettledWhenItsAnswerIsLost
Queued Chat writes are drained before the next turn's messages are read 712e35b TestARewindWaitsForTheNextTurnsMessagesToReachChat
rewindSession carries on an ended kept conversation 8a57e80 TestAnEndedConversationKeptInChatIsRewound
A /respond turn can be rewound to; the unmarked refusal is pinned on a turn with no marks 8197579 TestATurnSentThroughRespondCanBeRewoundTo, TestARewindThatWouldHideAnUnmarkedResponseIsRefused
Rewinds and activations around a regenerate keep the tree's order 2f591e5 TestRewindsAndActivationsAroundARegenerateKeepTheTreesOrder

Validation

With -race -tags integration against 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, except TestChatEpisodesSuite/TestAnIdleChatEpisodeIsSummarizedFromTheMessageThatOpenedIt, which also fails locally on AI-3198: copy a step's approval with its reply #1975 (accelerate plus one commit). Five more (StreamApps, Telnyx and the two chart tests) failed under load and pass when run alone.
  • Clients: 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.
  • Unverified against real Chat: the router's rewind is exercised only against the chattest fake.

🤖 Generated with Claude Code

Laurent Schaffner and others added 30 commits November 12, 2026 16:00
…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>
Laurent Schaffner and others added 3 commits November 13, 2026 19:54
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
getstream-jeffery Bot dismissed their stale review November 13, 2026 18:58

Withdrawing approval until 8197579 is reviewed.

Laurent Schaffner and others added 2 commits November 13, 2026 20:06
…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>
getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery 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.

✅ Approved 8197579b75ce after 2 rounds

Left open, your call:

Not covered: router rewind against real Stream Chat, exercised only against the chattest fake (PR description).

Laurent Schaffner and others added 4 commits November 13, 2026 20:10
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
getstream-jeffery Bot dismissed their stale review November 13, 2026 19:12

Withdrawing approval until e5c6bed is reviewed.

@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery 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.

✅ Approved e5c6bedc05f2 after 3 rounds · 1 fixed · 1 dropped after discussion

Not covered: router rewind against real Stream Chat, exercised only against the chattest fake (PR description).

Laurent Schaffner and others added 2 commits November 13, 2026 20:22
…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
getstream-jeffery Bot dismissed their stale review November 13, 2026 19:23

Withdrawing approval until 8a8709b is reviewed.

@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery

Copy link
Copy Markdown

/jeffery review

Nothing changed since round 3 on 8a8709b7f7b0; comment /jeffery rereview force to redo the full review anyway.

getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery 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.

✅ Approved 8a8709b7f7b0 after 3 rounds · 1 fixed · 1 dropped after discussion

Not covered: router rewind against real Stream Chat, exercised only against the chattest fake (PR description).

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
getstream-jeffery Bot dismissed their stale review November 13, 2026 20:10

Withdrawing approval until 0afa353 is reviewed.

@getstream-jeffery

getstream-jeffery Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

✅ 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.

@getstream-jeffery getstream-jeffery 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.

✅ Approved 0afa3536fc25 after 3 rounds · 1 fixed · 1 dropped after discussion

Not covered: router rewind against real Stream Chat, exercised only against the chattest fake (PR description).

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
Loschcode merged commit bf1ee9c into accelerate Nov 13, 2026
60 of 62 checks passed
@Loschcode
Loschcode deleted the laurentschaffner/ai-3209-session-rewind-is-refused-for-every branch November 13, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant