You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(chat): stop losing a user message that arrived mid-turn (#4795)
Follow-up to
[#4644](#4644), now
rebased onto main so the diff is just these three commits.
## Summary
Two ways a chat could lose a user message, both pre-existing and both
raised while reviewing #4644.
A message arriving while a turn was streaming was handed to that turn's
push handler and parked in an in-memory array. The router counts a
record handed to a handler as terminally decided, so it stopped holding
the resume floor behind it, and the turn boundary published a cursor
past a message that existed only in that process. A crash before the
next turn lost it, silently. Measured: with the message at sequence 1,
the boundary published `session-in-event-id: 1`, so a resume skipped it.
Separately, a message the agent declined to inject was discarded with
the turn. Never injected, never written to the wire buffer, never
answered. That was also the documented default, since a
`pendingMessages` config without `shouldInject` declines every batch.
## Design
Notification and consumption are now separate concerns on the router.
`observe` reports that a record arrived without taking it, so the record
stays queued and keeps holding the floor. It is rejected on an
`at-arrival` route: an observer there would either have to count as a
listener, which would stop an unconsumed stop being discarded and bring
back a wedged mailbox, or watch records it cannot affect. `take` removes
exactly one queued record.
The managed loop and the `chat.createSession()` iterator now only
subscribe when there is a steering config to feed, and injection is the
point of consumption. A declined batch never reaches the take, so its
records stay queued and become later turns. Both in-memory wire buffers
are gone, so a message waiting for its turn is durable rather than
living in whichever worker received it.
The floor doubles as the wake cursor: `awaitWake` registers with it and
the server completes the waitpoint immediately if anything sits after
that sequence. An over-advanced floor was therefore also a missed wake.
It is now recorded on the wait span so a run that never woke can be
diagnosed from its trace.
## Verification
Both fixes have a red and green pair, each checked against the
unmodified source rather than only observed to pass:
- the resume cursor test fails on the parent branch and passes here
- the declined-message test fails without the second commit and passes
with it
Also 8 new router tests for `observe` and `take`. Suites green at 385
for the SDK and 886 for core.
## Not addressed
A `pendingMessages` config with no `chat.toStreamTextOptions()` spread
still swallows messages, because nothing drains the queue at all. Same
shape, different trigger, tracked separately.
Fixes a message sent while the agent was mid-answer being lost if the run then crashed. The cursor written at the end of each turn could point past a message that had arrived during that turn but had not been answered yet, so the next boot skipped it and no error was raised anywhere. Such a message is now held until a turn actually takes it.
6
+
7
+
This also removes the in-memory buffer those messages used to sit in, on both `chat.agent` and `chat.createSession()`, so a message waiting for its turn is durable rather than only present in the worker that received it.
A message that arrives mid-turn and is not injected into that turn is now answered as the next turn, instead of being dropped. This is what the `pendingMessages` docs have always described, and it applies to the default too: configuring `pendingMessages` without a `shouldInject` declines every batch, which previously meant every mid-turn message was lost with no error at either end.
// Only interrupt once the agent has started calling tools.
14
+
shouldInject: ({ steps }) =>steps.length>0,
15
+
},
16
+
run: async ({ messages, signal }) =>
17
+
streamText({
18
+
model,
19
+
messages,
20
+
abortSignal: signal,
21
+
// Required for injection. Without it nothing injects, and every
22
+
// mid-turn message is answered as the next turn instead.
23
+
...chat.toStreamTextOptions(),
24
+
}),
25
+
});
26
+
```
27
+
28
+
A declined message keeps its place in the queue, so it survives a crash and is answered by whichever run picks the conversation up. An injected one is consumed at the moment it is injected, so it is never also answered as a later turn.
Copy file name to clipboardExpand all lines: docs/ai-chat/client-protocol.mdx
+4-1Lines changed: 4 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -953,9 +953,12 @@ You can send messages while the agent is still streaming a response. These are *
953
953
954
954
The wire format is identical to a normal `kind: "message"` send — same `.in` channel, single `message` field. The difference is timing. What happens depends on the agent's `pendingMessages` configuration:
955
955
956
-
- **With `pendingMessages.shouldInject`**: the message is injected into the model's context at the next `prepareStep` boundary. The agent sees it and can adjust its behavior mid-response.
956
+
- **With `pendingMessages.shouldInject` returning `true`**: the message is injected into the model's context at the next `prepareStep` boundary. The agent sees it and can adjust its behavior mid-response.
957
+
- **With a `pendingMessages` config that declines it**, either because `shouldInject` returned `false` or because it is absent: the message stays queued on the backend and is answered as the next turn.
957
958
- **Without `pendingMessages` config**: the message queues for the next turn.
958
959
960
+
In every case the message is answered. A declined message keeps its place in the queue, so it also survives a crash and is picked up by whichever run continues the conversation.
961
+
959
962
See [Pending Messages](/ai-chat/pending-messages) for how to configure the agent side.
Copy file name to clipboardExpand all lines: docs/ai-chat/pending-messages.mdx
+5-3Lines changed: 5 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,9 @@ When an AI agent is executing tool calls, users may want to send a message that
10
10
11
11
By default (without `pendingMessages`), a message sent while the agent is responding never interrupts the in-flight response: it's buffered and processed as its own turn once the current turn completes, with multiple messages running sequentially in arrival order.
12
12
13
-
The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. If there are no more step boundaries (single-step response or final text generation), the message becomes the next turn automatically.
13
+
The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. A message that is not injected becomes the next turn instead, whether that is because `shouldInject` returned `false` or because there were no more step boundaries (single-step response or final text generation). The backend handles that, so no client-side re-send is involved.
14
+
15
+
Injection is what needs wiring: the `pendingMessages` options only reach `streamText` if you spread `chat.toStreamTextOptions()` (or pass `prepareStep`). Without that, nothing injects, so every mid-turn message is answered as the next turn. Deferral does not depend on it.
14
16
15
17
## How it works
16
18
@@ -20,7 +22,7 @@ The `pendingMessages` option enables steering instead, injecting user messages b
20
22
4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called
21
23
5. If it returns `true`, the message is injected into the LLM's context
22
24
6. A `data-pending-message-injected` stream chunk confirms injection to the frontend
23
-
7. If `prepareStep` never fires (no tool calls), the message becomes the next turn
25
+
7. If `shouldInject` returns `false`, or `prepareStep` never fires (no tool calls), the message stays queued on the backend and is answered as the next turn
-**Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected (no more step boundaries), they auto-send as the next turn when the response finishes.
315
+
-**Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected, the backend answers them as the next turn once the response finishes; the client does not need to re-send them.
314
316
315
317
-**Queued messages** stay client-side until the turn completes, then auto-send as the next turn via `sendMessage()`. They can be promoted to steering mid-stream by clicking "Steer instead".
|`shouldInject`|`(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>`| No | Decide whether to inject the batch between tool-call steps. If absent, no injection. |
399
+
|`shouldInject`|`(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>`| No | Decide whether to inject the batch between tool-call steps. If absent, nothing is injected and the messages are answered as the next turn. Only consulted when `chat.toStreamTextOptions()` (or `prepareStep`) reaches `streamText`; without that nothing is injected and every mid-turn message becomes the next turn.|
400
400
|`prepare`|`(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise<ModelMessage[]>`| No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. |
401
401
|`onReceived`|`(event: PendingMessageReceivedEvent) => void \| Promise<void>`| No | Called when a message arrives during streaming (per-message). |
402
402
|`onInjected`|`(event: PendingMessagesInjectedEvent) => void \| Promise<void>`| No | Called after a batch is injected via prepareStep. |
`Route "${name}" is at-arrival, which cannot be observed: an observer must not decide whether a record is discarded, and cannot be offered one that already was`
391
+
);
392
+
}
393
+
state.observers.add(observer);
394
+
return{
395
+
off: ()=>{
396
+
state.observers.delete(observer);
397
+
},
398
+
};
399
+
}
400
+
401
+
/**
402
+
* Remove one queued record, identified by sequence.
403
+
*
404
+
* For a consumer that decided to take a record it had only observed. Returns
405
+
* whether it was still queued, so a caller can tell a real take from a record
/** Whether an `at-arrival` route currently has anywhere to deliver. */
360
433
hasHandler(name: string): boolean{
361
434
returnthis.#stateOrThrow(name).handlers.size>0;
@@ -417,6 +490,11 @@ export class SessionChannelRouter {
417
490
*/
418
491
clearRoute(name: string): void{
419
492
conststate=this.#stateOrThrow(name);
493
+
if(state.route.replayable){
494
+
thrownewError(
495
+
`Route "${name}" is replayable, so its queue cannot be cleared: anything queued on it is still owed to a later boot, and discarding it would lose records the resume floor is holding back`
496
+
);
497
+
}
420
498
state.queue.length=0;
421
499
for(constwaiterofstate.waiters){
422
500
if(waiter.timer)clearTimeout(waiter.timer);
@@ -430,6 +508,7 @@ export class SessionChannelRouter {
0 commit comments