Repository navigation
fix(ai-client): keep the replay's answer when a rejoin starts with reasoning #1942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e14f91f
a5e1bf1
e0d87e9
280bca9
0056aa3
5d734cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/ai-client': patch | ||
| --- | ||
|
|
||
| Rejoining a run no longer loses the answer when the replay starts with reasoning. A reasoning start event that carries metadata, which `chat()` adds to every adapter event, already creates the replay's assistant message. The client used to drop that message at the first rebuild event. It now drops only the assistant message it hydrated before the replay. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2192,6 +2192,12 @@ export class ChatClient< | |
| const connectTimer = setTimeout(() => { | ||
| if (!attached) controller.abort() | ||
| }, REJOIN_CONNECT_DEADLINE_MS) | ||
| // Captured before the replay: chunks before the first rebuild trigger | ||
| // (reasoning start with metadata) can already append the replay's own | ||
| // assistant message, which must survive the drop. | ||
| const hydrated = this.processor.getMessages().at(-1) | ||
| const hydratedAssistantId = | ||
| hydrated?.role === 'assistant' ? hydrated.id : undefined | ||
| try { | ||
| for await (const chunk of joinRun(runId, controller.signal)) { | ||
| if (controller.signal.aborted) break | ||
|
|
@@ -2201,7 +2207,9 @@ export class ChatClient< | |
| } | ||
| if (!rebuilt && rebuildsAssistantMessage(chunk)) { | ||
| rebuilt = true | ||
| this.dropTrailingInFlightAssistant() | ||
| if (hydratedAssistantId) { | ||
| this.dropHydratedAssistant(hydratedAssistantId) | ||
|
Comment on lines
+2210
to
+2211
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
| this.processIncomingChunk(chunk) | ||
| } | ||
|
|
@@ -2259,17 +2267,15 @@ export class ChatClient< | |
| } | ||
|
|
||
| /** | ||
| * Drop a hydrated, still-in-flight assistant turn so a resume replay can | ||
| * rebuild it cleanly. Only touches a trailing assistant message (the shape a | ||
| * reload-mid-stream leaves); a thread whose last turn is a user message (run | ||
| * never produced, or already settled) is left untouched. | ||
| * Drop the hydrated, still-in-flight assistant turn so a resume replay can | ||
| * rebuild it cleanly. `id` is the trailing assistant message from before the | ||
| * replay (the shape a reload-mid-stream leaves); a thread whose last turn was | ||
| * a user message (run never produced, or already settled) has none to drop. | ||
| */ | ||
| private dropTrailingInFlightAssistant(): void { | ||
| private dropHydratedAssistant(id: string): void { | ||
| const messages = this.processor.getMessages() | ||
| const last = messages[messages.length - 1] | ||
| if (last && last.role === 'assistant') { | ||
| this.processor.setMessages(messages.slice(0, -1)) | ||
| } | ||
| const kept = messages.filter((message) => message.id !== id) | ||
| if (kept.length < messages.length) this.processor.setMessages(kept) | ||
| } | ||
|
|
||
| private processIncomingChunk(chunk: StreamChunk): void { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: TanStack/ai
Length of output: 18747
Script executed:
Repository: TanStack/ai
Length of output: 49441
Script executed:
Repository: TanStack/ai
Length of output: 31954
Script executed:
Repository: TanStack/ai
Length of output: 23909
Script executed:
Repository: TanStack/ai
Length of output: 15028
Script executed:
Repository: TanStack/ai
Length of output: 41252
Script executed:
Repository: TanStack/ai
Length of output: 11045
Script executed:
Repository: TanStack/ai
Length of output: 17887
Preserve the parent assistant during continuation replay.
If hydration ends with a parent tool-call turn and the active continuation starts with reasoning, the first rebuild trigger removes the parent turn and its tool history. Only drop a hydrated assistant when it belongs to the rejoined run.
Script executed:
Repository: TanStack/ai
Length of output: 4513
Length of output: 4177
Script executed:
Repository: TanStack/ai
Length of output: 39044
Script executed:
Repository: TanStack/ai
Length of output: 4558
Script executed:
Repository: TanStack/ai
Length of output: 42868
Script executed:
Repository: TanStack/ai
Length of output: 20416
Script executed:
Repository: TanStack/ai
Length of output: 11372
Script executed:
Repository: TanStack/ai
Length of output: 9493
Keep pre-trigger replay messages out of the persisted transcript.
With browser persistence, a second reload between reasoning start and content can restore both the original partial and the replay-created assistant. The next rejoin removes only the latter, leaving the stale partial beside the reconstructed answer. Preserve the original snapshot until replay reaches a rebuild trigger.
Script executed:
Repository: TanStack/ai
Length of output: 3755
Script executed:
Repository: TanStack/ai
Length of output: 31611
Script executed:
Repository: TanStack/ai
Length of output: 25611
Script executed:
Repository: TanStack/ai
Length of output: 47061
Script executed:
Repository: TanStack/ai
Length of output: 17346
Script executed:
Repository: TanStack/ai
Length of output: 17878
Identify the in-flight assistant before a trailing activity.
If an activity follows a partial assistant when the transcript is saved, rejoin does not remove the partial. A reasoning-first replay can then retain both the stale partial and the reconstructed answer. Identify the in-flight assistant without removing a completed earlier turn.