Skip to content

Commit add0a7d

Browse files
authored
fix(sdk,core): stop chat sessions dropping messages that arrive during a turn (#4176)
## Summary Sending a message to a chat whose run had ended could make the message vanish: the continuation run replayed already-answered messages, never processed the new one, and a page refresh lost it entirely. Chasing that report surfaced four composing message-loss bugs in the chat session runtime; this PR fixes all of them, each with a regression test. ## The fixes 1. **Stale resume cursor.** Records delivered while a run was suspended (the waitpoint path) advanced the SSE resume counter but not the committed-consume cursor, so the `session-in-event-id` header stamped on turn-completes went stale by one record per suspended turn. Continuation boots seed from that header, which is what made them replay already-processed messages. `session.in.wait()` now advances both cursors. 2. **Only the first buffered message dispatched.** Messages arriving during a turn are consumed into a buffer whose end-of-turn pickup dispatched only the first entry; the buffer was recreated each turn, so the rest were discarded, and since consuming a record commits the cursor the loss was permanent. A continuation boot's replay delivers several records back-to-back, which put the user's new message at index 1 or later. The buffer now outlives the turn and drains one message per turn in both `chat.agent` and `chat.createSession` (whose equivalent buffer was never read at all). 3. **Post-stop window in `chat.createSession`.** The turn's message listener stayed attached through the stopped turn's post-stream work, so a message sent shortly after stopping a turn was consumed into the dead steering queue and lost. The listener now detaches when the stream settles, matching the `chat.agent` loop. 4. **Handler leak on errored turns.** A turn that threw outside the streaming section (for example from an `onTurnStart` hook) leaked its message listener. Previously that silently lost mid-turn messages; with the loop-level buffer it would have duplicated them instead. The subscription handle is now detached by the turn's catch/finally, and `chat.createSession` defensively detaches its prior turn's listener when user code exits a turn without `complete()`/`done()`. ## Verification Reproduced end-to-end with the ai-chat reference project before the fix (message consumed but never answered, two replayed turns, gone on refresh) and verified after (single clean turn, survives refresh, turn-complete cursors strictly advancing). Regression tests in `packages/trigger-sdk/test/pending-message-drain.test.ts` cover all four, each verified red against the unfixed behavior. A smoke sweep of the standard chat scenarios (basic send, multi-turn, suspend/resume, mid-stream refresh, stop, steering, cancel + continue, and the `createSession` variant) passes on the final branch state.
1 parent 1a033b6 commit add0a7d

9 files changed

Lines changed: 384 additions & 37 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix chat turns that throw (for example from an `onTurnStart` hook) leaking their message listener, which lost or duplicated messages sent during later turns.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix `chat.agent` and `chat.createSession` permanently dropping user messages when several arrived during a single turn: every buffered message is now dispatched as its own turn instead of only the first.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix chat continuation runs replaying already-answered messages: turns delivered while the run was suspended now advance the session.in resume cursor, so a new run picks up exactly where the previous one left off.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix `chat.createSession` swallowing a message sent shortly after stopping a turn: the turn's message listener now detaches when the stream settles, so those messages run as the next turn.

docs/ai-chat/pending-messages.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ description: "Inject user messages mid-execution to steer agents between tool-ca
88

99
When an AI agent is executing tool calls, users may want to send a message that **steers the agent mid-execution** — adding context, correcting course, or refining the request without waiting for the response to finish.
1010

11-
The `pendingMessages` option enables this by 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.
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+
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.
1214

1315
## How it works
1416

packages/core/src/v3/test/test-session-stream-manager.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export class TestSessionStreamManager implements SessionStreamManager {
3333
private onceWaiters = new Map<string, OnceWaiter[]>();
3434
private buffer = new Map<string, unknown[]>();
3535
private seqNums = new Map<string, number>();
36+
private dispatchedSeqNums = new Map<string, number>();
3637

3738
on(sessionId: string, io: SessionChannelIO, handler: Handler): { off: () => void } {
3839
const key = keyFor(sessionId, io);
@@ -150,15 +151,20 @@ export class TestSessionStreamManager implements SessionStreamManager {
150151
this.seqNums.set(keyFor(sessionId, io), seqNum);
151152
}
152153

153-
lastDispatchedSeqNum(_sessionId: string, _io: SessionChannelIO): number | undefined {
154-
// The test harness drives records via `__sendFromTest` without seq
155-
// numbers, so the committed-consume cursor stays undefined. Tests
156-
// that need cursor behaviour exercise it via the real manager.
157-
return undefined;
154+
lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
155+
// `__sendFromTest` carries no seq numbers, so this only reflects
156+
// explicit `setLastDispatchedSeqNum` calls (e.g. the waitpoint
157+
// delivery path). Full cursor behaviour is exercised via the real
158+
// manager.
159+
return this.dispatchedSeqNums.get(keyFor(sessionId, io));
158160
}
159161

160-
setLastDispatchedSeqNum(_sessionId: string, _io: SessionChannelIO, _seqNum: number): void {
161-
// no-op — see comment on `lastDispatchedSeqNum`.
162+
setLastDispatchedSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
163+
const key = keyFor(sessionId, io);
164+
const current = this.dispatchedSeqNums.get(key);
165+
if (current === undefined || seqNum > current) {
166+
this.dispatchedSeqNums.set(key, seqNum);
167+
}
162168
}
163169

164170
setMinTimestamp(
@@ -202,6 +208,7 @@ export class TestSessionStreamManager implements SessionStreamManager {
202208
this.handlers.clear();
203209
this.buffer.clear();
204210
this.seqNums.clear();
211+
this.dispatchedSeqNums.clear();
205212
}
206213

207214
disconnect(): void {

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5569,6 +5569,14 @@ function chatAgent<
55695569
// `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
55705570
const bootInjectedQueue: ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>[] =
55715571
[];
5572+
// Messages consumed by a turn's `messagesInput.on` handler, dispatched
5573+
// one per turn by the end-of-turn pickup. Loop-level on purpose:
5574+
// consuming a record advances the committed `.in` cursor, so entries
5575+
// dropped with a turn-local buffer are lost permanently.
5576+
const pendingWireMessages: ChatTaskWirePayload<
5577+
TUIMessage,
5578+
inferSchemaIn<TClientDataSchema>
5579+
>[] = [];
55725580
const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
55735581

55745582
// `.in` resume cursor, computed at most once per boot. The boot
@@ -6378,6 +6386,9 @@ function chatAgent<
63786386
}
63796387

63806388
for (let turn = 0; turn < maxTurns; turn++) {
6389+
// Declared here so the finally can detach it — a handler leaked past
6390+
// its turn duplicates every mid-stream message into the shared buffer.
6391+
let turnMsgSub: { off: () => void } | undefined;
63816392
try {
63826393
// Extract turn-level context before entering the span. Slim
63836394
// wire: at most one delta message per record. `headStartMessages`
@@ -6479,11 +6490,6 @@ function chatAgent<
64796490
const cancelSignal = runSignal;
64806491
const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
64816492

6482-
// Buffer messages that arrive during streaming
6483-
const pendingMessages: ChatTaskWirePayload<
6484-
TUIMessage,
6485-
inferSchemaIn<TClientDataSchema>
6486-
>[] = [];
64876493
const pmConfig = locals.get(chatPendingMessagesKey);
64886494
const msgSub = messagesInput.on(async (msg) => {
64896495
// If pendingMessages is configured, route to the steering queue
@@ -6532,10 +6538,11 @@ function chatAgent<
65326538
}
65336539

65346540
// No pendingMessages config — standard wire buffer for next turn
6535-
pendingMessages.push(
6541+
pendingWireMessages.push(
65366542
msg as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>
65376543
);
65386544
});
6545+
turnMsgSub = msgSub;
65396546

65406547
// Track new messages for this turn (user input + assistant response).
65416548
const turnNewModelMessages: ModelMessage[] = [];
@@ -7738,9 +7745,10 @@ function chatAgent<
77387745
}
77397746

77407747
// If messages arrived during streaming (without pendingMessages config),
7741-
// use the first one immediately as the next turn.
7742-
if (pendingMessages.length > 0) {
7743-
currentWirePayload = pendingMessages[0]!;
7748+
// dispatch the oldest as the next turn. The rest stay queued
7749+
// and drain one per turn.
7750+
if (pendingWireMessages.length > 0) {
7751+
currentWirePayload = pendingWireMessages.shift()!;
77447752
return "continue";
77457753
}
77467754

@@ -7847,6 +7855,9 @@ function chatAgent<
78477855
// Turn error handler: write an error chunk + turn-complete to the stream
78487856
// so the client sees the error, then wait for the next message instead
78497857
// of killing the entire run. This keeps the conversation alive.
7858+
// Detach the turn's message handler first — left attached it would
7859+
// eat the very message the wait below is waiting for.
7860+
turnMsgSub?.off();
78507861
if (
78517862
turnError instanceof Error &&
78527863
turnError.name === "AbortError" &&
@@ -7982,6 +7993,12 @@ function chatAgent<
79827993
continue;
79837994
}
79847995

7996+
// Same for messages buffered during the errored turn — already consumed, idling strands them.
7997+
if (pendingWireMessages.length > 0) {
7998+
currentWirePayload = pendingWireMessages.shift()!;
7999+
continue;
8000+
}
8001+
79858002
// Wait for the next message — same as after a successful turn
79868003
const effectiveIdleTimeout =
79878004
(metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ??
@@ -8004,6 +8021,8 @@ function chatAgent<
80048021
inferSchemaIn<TClientDataSchema>
80058022
>;
80068023
// Continue to next iteration of the for loop
8024+
} finally {
8025+
turnMsgSub?.off();
80078026
}
80088027
}
80098028
} finally {
@@ -9309,9 +9328,18 @@ function createChatSession(
93099328
const accumulator = new ChatMessageAccumulator();
93109329
let previousTurnUsage: LanguageModelUsage | undefined;
93119330
let cumulativeUsage: LanguageModelUsage = emptyUsage();
9331+
// Messages consumed mid-turn, dispatched one per next(). Iterator-level
9332+
// for the same reason as the agent loop's `pendingWireMessages`:
9333+
// consumed records never replay, so a turn-local buffer loses them.
9334+
const sessionPendingWire: ChatTaskWirePayload[] = [];
9335+
// The current turn's message subscription — detached defensively at the
9336+
// top of next() in case user code threw without complete()/done().
9337+
let activeMsgSub: { off: () => void } | undefined;
93129338

93139339
return {
93149340
async next(): Promise<IteratorResult<ChatTurn>> {
9341+
activeMsgSub?.off();
9342+
activeMsgSub = undefined;
93159343
if (!booted) {
93169344
booted = true;
93179345
await seedSessionInResumeCursorForCustomLoop(currentPayload);
@@ -9380,24 +9408,29 @@ function createChatSession(
93809408
}
93819409
}
93829410

9383-
// Subsequent turns: wait for the next message
9411+
// Subsequent turns: drain buffered mid-turn messages first (they
9412+
// were consumed and won't be re-delivered), then wait.
93849413
if (turn > 0) {
9385-
// chat.requestUpgrade() / chat.endRun() — exit before waiting
9386-
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
9387-
stop.cleanup();
9388-
return { done: true, value: undefined };
9389-
}
9414+
if (sessionPendingWire.length > 0) {
9415+
currentPayload = sessionPendingWire.shift()!;
9416+
} else {
9417+
// chat.requestUpgrade() / chat.endRun() — exit before waiting
9418+
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
9419+
stop.cleanup();
9420+
return { done: true, value: undefined };
9421+
}
93909422

9391-
const next = await messagesInput.waitWithIdleTimeout({
9392-
idleTimeoutInSeconds,
9393-
timeout,
9394-
spanName: "waiting for next message",
9395-
});
9396-
if (!next.ok || runSignal.aborted) {
9397-
stop.cleanup();
9398-
return { done: true, value: undefined };
9423+
const next = await messagesInput.waitWithIdleTimeout({
9424+
idleTimeoutInSeconds,
9425+
timeout,
9426+
spanName: "waiting for next message",
9427+
});
9428+
if (!next.ok || runSignal.aborted) {
9429+
stop.cleanup();
9430+
return { done: true, value: undefined };
9431+
}
9432+
currentPayload = next.output;
93999433
}
9400-
currentPayload = next.output;
94019434
}
94029435

94039436
// Check limits
@@ -9426,11 +9459,10 @@ function createChatSession(
94269459
});
94279460

94289461
// Listen for messages during streaming (steering + next-turn buffer)
9429-
const sessionPendingWire: ChatTaskWirePayload[] = [];
94309462
const sessionMsgSub = messagesInput.on(async (msg) => {
9431-
sessionPendingWire.push(msg);
9432-
94339463
if (sessionPendingMessages) {
9464+
// Steering route — the frontend re-sends non-injected
9465+
// messages on turn complete, so don't also buffer the wire.
94349466
// Slim wire: at most one delta message per record. Read
94359467
// `msg.message` directly — no array slicing needed.
94369468
const lastUIMessage = msg.message;
@@ -9453,8 +9485,12 @@ function createChatSession(
94539485
/* non-fatal */
94549486
}
94559487
}
9488+
return;
94569489
}
9490+
9491+
sessionPendingWire.push(msg);
94579492
});
9493+
activeMsgSub = sessionMsgSub;
94589494

94599495
// Accumulate messages. Slim wire: pass the single delta message as
94609496
// a 0-or-1-length array. The accumulator's behavior is unchanged —
@@ -9555,6 +9591,10 @@ function createChatSession(
95559591
} else {
95569592
throw error;
95579593
}
9594+
} finally {
9595+
// Detach at stream end (like the agent loop): the steering queue
9596+
// can't inject anymore, so later arrivals must buffer for the next turn.
9597+
sessionMsgSub.off();
95589598
}
95599599

95609600
if (response) {
@@ -9726,6 +9766,8 @@ function createChatSession(
97269766
},
97279767

97289768
async return() {
9769+
activeMsgSub?.off();
9770+
activeMsgSub = undefined;
97299771
// `stop` only exists once next() has booted the iterator.
97309772
stop?.cleanup();
97319773
return { done: true, value: undefined };

packages/trigger-sdk/src/v3/sessions.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,11 +753,14 @@ export class SessionInputChannel {
753753
: undefined;
754754

755755
if (waitResult.ok) {
756-
// Advance the seq counter so the SSE tail doesn't replay the
757-
// record that was consumed via the waitpoint.
756+
// Advance both cursors past the record consumed via the
757+
// waitpoint: the seq counter so the SSE tail doesn't replay
758+
// it, and the consume cursor so turn-completes don't stamp a
759+
// stale `session-in-event-id`.
758760
const prevSeq = sessionStreams.lastSeqNum(this.sessionId, "in");
759761
const nextSeq = (prevSeq ?? -1) + 1;
760762
sessionStreams.setLastSeqNum(this.sessionId, "in", nextSeq);
763+
sessionStreams.setLastDispatchedSeqNum(this.sessionId, "in", nextSeq);
761764

762765
return { ok: true as const, output: data as T };
763766
} else {

0 commit comments

Comments
 (0)