Skip to content

Commit a6778a4

Browse files
committed
fix(sdk): detach the turn message handler when a turn throws
The per-turn handler leaked past turns that threw outside the streaming section (e.g. an onTurnStart hook). With the buffer now loop-level, a leaked handler pushed alongside the next turn's handler, duplicating every mid-stream message; pre-existing behavior lost them instead. The subscription handle is hoisted so the turn's catch/finally always detaches it, and createSession defensively detaches its prior turn's handler when user code exits a turn without complete()/done().
1 parent 29a6924 commit a6778a4

2 files changed

Lines changed: 98 additions & 36 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6386,6 +6386,9 @@ function chatAgent<
63866386
}
63876387

63886388
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;
63896392
try {
63906393
// Extract turn-level context before entering the span. Slim
63916394
// wire: at most one delta message per record. `headStartMessages`
@@ -6539,6 +6542,7 @@ function chatAgent<
65396542
msg as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>
65406543
);
65416544
});
6545+
turnMsgSub = msgSub;
65426546

65436547
// Track new messages for this turn (user input + assistant response).
65446548
const turnNewModelMessages: ModelMessage[] = [];
@@ -7851,6 +7855,9 @@ function chatAgent<
78517855
// Turn error handler: write an error chunk + turn-complete to the stream
78527856
// so the client sees the error, then wait for the next message instead
78537857
// 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();
78547861
if (
78557862
turnError instanceof Error &&
78567863
turnError.name === "AbortError" &&
@@ -8014,6 +8021,8 @@ function chatAgent<
80148021
inferSchemaIn<TClientDataSchema>
80158022
>;
80168023
// Continue to next iteration of the for loop
8024+
} finally {
8025+
turnMsgSub?.off();
80178026
}
80188027
}
80198028
} finally {
@@ -9323,9 +9332,14 @@ function createChatSession(
93239332
// for the same reason as the agent loop's `pendingWireMessages`:
93249333
// consumed records never replay, so a turn-local buffer loses them.
93259334
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;
93269338

93279339
return {
93289340
async next(): Promise<IteratorResult<ChatTurn>> {
9341+
activeMsgSub?.off();
9342+
activeMsgSub = undefined;
93299343
if (!booted) {
93309344
booted = true;
93319345
await seedSessionInResumeCursorForCustomLoop(currentPayload);
@@ -9476,6 +9490,7 @@ function createChatSession(
94769490

94779491
sessionPendingWire.push(msg);
94789492
});
9493+
activeMsgSub = sessionMsgSub;
94799494

94809495
// Accumulate messages. Slim wire: pass the single delta message as
94819496
// a 0-or-1-length array. The accumulator's behavior is unchanged —

packages/trigger-sdk/test/pending-message-drain.test.ts

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,49 @@ describe("chat.agent pending wire buffer", () => {
118118
});
119119
});
120120

121+
describe("chat.agent errored turn", () => {
122+
it(
123+
"does not duplicate messages buffered after a turn that threw",
124+
{ timeout: 20000 },
125+
async () => {
126+
// Throw from a pre-stream hook: throws inside the streaming section are
127+
// already covered by its finally, but a hook throw used to leak the
128+
// turn's message handler into the loop-level buffer.
129+
let turnStarts = 0;
130+
const agent = chat.agent({
131+
id: "pending-drain.errored-turn",
132+
onTurnStart: async () => {
133+
turnStarts++;
134+
if (turnStarts === 1) {
135+
throw new Error("synthetic turn failure");
136+
}
137+
},
138+
run: async ({ messages, signal }) => {
139+
return streamText({ model: echoModel(), messages, abortSignal: signal });
140+
},
141+
});
142+
143+
const harness = mockChatAgent(agent, { chatId: "pending-drain-4" });
144+
try {
145+
// Turn 1 throws — pre-fix its message handler leaked past the turn.
146+
await harness.sendMessage(userMessage("boom", "u-1"));
147+
const second = harness.sendMessage(userMessage("m2", "u-2"));
148+
// m3 lands mid-turn; a leaked handler would push it twice.
149+
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
150+
void harness.sendMessage(userMessage("m3", "u-3"));
151+
await second;
152+
153+
await waitFor(() => streamedText(harness).includes("ANSWER(m3)"));
154+
await new Promise((r) => setTimeout(r, 500));
155+
const text = streamedText(harness);
156+
expect(text.match(/ANSWER\(m3\)/g)).toHaveLength(1);
157+
} finally {
158+
await harness.close();
159+
}
160+
}
161+
);
162+
});
163+
121164
describe("chat.createSession pending wire buffer", () => {
122165
it("dispatches messages buffered during a turn as subsequent turns", async () => {
123166
const agent = chat.customAgent({
@@ -158,46 +201,50 @@ describe("chat.createSession pending wire buffer", () => {
158201
});
159202

160203
describe("chat.createSession stop + immediate send", () => {
161-
it("dispatches a message that arrives right after a stopped turn", { timeout: 20000 }, async () => {
162-
const agent = chat.customAgent({
163-
id: "pending-drain.session-stop",
164-
run: async (payload) => {
165-
const session = chat.createSession(payload, {
166-
signal: new AbortController().signal,
167-
idleTimeoutInSeconds: 2,
168-
// Steering config active — the failure mode routed post-stream
169-
// arrivals into the dead steering queue instead of the next turn.
170-
pendingMessages: {},
171-
});
172-
for await (const turn of session) {
173-
const result = streamText({
174-
model: echoModel(),
175-
messages: turn.messages,
176-
abortSignal: turn.signal,
204+
it(
205+
"dispatches a message that arrives right after a stopped turn",
206+
{ timeout: 20000 },
207+
async () => {
208+
const agent = chat.customAgent({
209+
id: "pending-drain.session-stop",
210+
run: async (payload) => {
211+
const session = chat.createSession(payload, {
212+
signal: new AbortController().signal,
213+
idleTimeoutInSeconds: 2,
214+
// Steering config active — the failure mode routed post-stream
215+
// arrivals into the dead steering queue instead of the next turn.
216+
pendingMessages: {},
177217
});
178-
await turn.complete(result);
179-
}
180-
},
181-
});
218+
for await (const turn of session) {
219+
const result = streamText({
220+
model: echoModel(),
221+
messages: turn.messages,
222+
abortSignal: turn.signal,
223+
});
224+
await turn.complete(result);
225+
}
226+
},
227+
});
182228

183-
const harness = mockChatAgent(agent, { chatId: "pending-drain-3" });
184-
try {
185-
const first = harness.sendMessage(userMessage("write a long essay", "u-1"));
186-
await waitFor(() => streamedText(harness).length > 0);
187-
await harness.sendStop();
188-
// Land the next message inside the stopped turn's post-stream window
189-
// (the ~2s totalUsage race), after the abort has settled — previously
190-
// the still-attached handler steering-routed it into the dead queue.
191-
await new Promise((r) => setTimeout(r, 150));
192-
void harness.sendMessage(userMessage("m2", "u-2"));
193-
await first;
229+
const harness = mockChatAgent(agent, { chatId: "pending-drain-3" });
230+
try {
231+
const first = harness.sendMessage(userMessage("write a long essay", "u-1"));
232+
await waitFor(() => streamedText(harness).length > 0);
233+
await harness.sendStop();
234+
// Land the next message inside the stopped turn's post-stream window
235+
// (the ~2s totalUsage race), after the abort has settled — previously
236+
// the still-attached handler steering-routed it into the dead queue.
237+
await new Promise((r) => setTimeout(r, 150));
238+
void harness.sendMessage(userMessage("m2", "u-2"));
239+
await first;
194240

195-
await waitFor(() => turnCompleteCount(harness) >= 2);
196-
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
197-
} finally {
198-
await harness.close();
241+
await waitFor(() => turnCompleteCount(harness) >= 2);
242+
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
243+
} finally {
244+
await harness.close();
245+
}
199246
}
200-
});
247+
);
201248
});
202249

203250
describe("session.in.wait() consume cursor", () => {

0 commit comments

Comments
 (0)