Skip to content

Commit 29a6924

Browse files
committed
fix(sdk): detach the createSession message listener at stream end
A message arriving between a stopped stream and the turn-complete write (the post-stream usage race) was consumed into the turn's already-dead steering queue and lost. Detach at stream settle, matching the agent loop, so those arrivals buffer for the next turn.
1 parent c00ed5d commit 29a6924

2 files changed

Lines changed: 47 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9576,6 +9576,10 @@ function createChatSession(
95769576
} else {
95779577
throw error;
95789578
}
9579+
} finally {
9580+
// Detach at stream end (like the agent loop): the steering queue
9581+
// can't inject anymore, so later arrivals must buffer for the next turn.
9582+
sessionMsgSub.off();
95799583
}
95809584

95819585
if (response) {

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,49 @@ describe("chat.createSession pending wire buffer", () => {
157157
});
158158
});
159159

160+
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,
177+
});
178+
await turn.complete(result);
179+
}
180+
},
181+
});
182+
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;
194+
195+
await waitFor(() => turnCompleteCount(harness) >= 2);
196+
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
197+
} finally {
198+
await harness.close();
199+
}
200+
});
201+
});
202+
160203
describe("session.in.wait() consume cursor", () => {
161204
it("advances lastDispatchedSeqNum alongside lastSeqNum on waitpoint delivery", async () => {
162205
__setSessionOpenImplForTests(undefined);

0 commit comments

Comments
 (0)