Skip to content

Commit 1620c09

Browse files
committed
fix(sdk,core): stop chat sessions dropping messages that arrive during a turn
Messages sent to a chat whose run had ended could vanish: turns delivered via the suspend/waitpoint path never advanced the session.in consume cursor, so continuation boots replayed already-answered messages, and the turn loop dispatched only the first buffered mid-turn message and discarded the rest. The buffered queue now outlives the turn and drains one message per turn (chat.agent and chat.createSession), and waitpoint delivery commits the consume cursor.
1 parent eecb27c commit 1620c09

5 files changed

Lines changed: 256 additions & 36 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Fix `chat.agent` and `chat.createSession` dropping user messages when several arrive during a single turn. The most visible case: sending a message to a chat whose run had ended could make the message vanish. The continuation run replayed already-answered messages, silently discarded the new one, and a refresh lost it entirely. Turns delivered while the run was suspended now advance the session.in resume cursor (so continuation boots no longer replay processed messages), and every message buffered during a turn is dispatched as its own turn instead of only the first.

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: 48 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
@@ -6479,11 +6487,6 @@ function chatAgent<
64796487
const cancelSignal = runSignal;
64806488
const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
64816489

6482-
// Buffer messages that arrive during streaming
6483-
const pendingMessages: ChatTaskWirePayload<
6484-
TUIMessage,
6485-
inferSchemaIn<TClientDataSchema>
6486-
>[] = [];
64876490
const pmConfig = locals.get(chatPendingMessagesKey);
64886491
const msgSub = messagesInput.on(async (msg) => {
64896492
// If pendingMessages is configured, route to the steering queue
@@ -6532,7 +6535,7 @@ function chatAgent<
65326535
}
65336536

65346537
// No pendingMessages config — standard wire buffer for next turn
6535-
pendingMessages.push(
6538+
pendingWireMessages.push(
65366539
msg as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>
65376540
);
65386541
});
@@ -7738,9 +7741,10 @@ function chatAgent<
77387741
}
77397742

77407743
// 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]!;
7744+
// dispatch the oldest as the next turn. The rest stay queued
7745+
// and drain one per turn.
7746+
if (pendingWireMessages.length > 0) {
7747+
currentWirePayload = pendingWireMessages.shift()!;
77447748
return "continue";
77457749
}
77467750

@@ -7982,6 +7986,12 @@ function chatAgent<
79827986
continue;
79837987
}
79847988

7989+
// Same for messages buffered during the errored turn — already consumed, idling strands them.
7990+
if (pendingWireMessages.length > 0) {
7991+
currentWirePayload = pendingWireMessages.shift()!;
7992+
continue;
7993+
}
7994+
79857995
// Wait for the next message — same as after a successful turn
79867996
const effectiveIdleTimeout =
79877997
(metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ??
@@ -9309,6 +9319,10 @@ function createChatSession(
93099319
const accumulator = new ChatMessageAccumulator();
93109320
let previousTurnUsage: LanguageModelUsage | undefined;
93119321
let cumulativeUsage: LanguageModelUsage = emptyUsage();
9322+
// Messages consumed mid-turn, dispatched one per next(). Iterator-level
9323+
// for the same reason as the agent loop's `pendingWireMessages`:
9324+
// consumed records never replay, so a turn-local buffer loses them.
9325+
const sessionPendingWire: ChatTaskWirePayload[] = [];
93129326

93139327
return {
93149328
async next(): Promise<IteratorResult<ChatTurn>> {
@@ -9380,24 +9394,29 @@ function createChatSession(
93809394
}
93819395
}
93829396

9383-
// Subsequent turns: wait for the next message
9397+
// Subsequent turns: drain buffered mid-turn messages first (they
9398+
// were consumed and won't be re-delivered), then wait.
93849399
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-
}
9400+
if (sessionPendingWire.length > 0) {
9401+
currentPayload = sessionPendingWire.shift()!;
9402+
} else {
9403+
// chat.requestUpgrade() / chat.endRun() — exit before waiting
9404+
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
9405+
stop.cleanup();
9406+
return { done: true, value: undefined };
9407+
}
93909408

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 };
9409+
const next = await messagesInput.waitWithIdleTimeout({
9410+
idleTimeoutInSeconds,
9411+
timeout,
9412+
spanName: "waiting for next message",
9413+
});
9414+
if (!next.ok || runSignal.aborted) {
9415+
stop.cleanup();
9416+
return { done: true, value: undefined };
9417+
}
9418+
currentPayload = next.output;
93999419
}
9400-
currentPayload = next.output;
94019420
}
94029421

94039422
// Check limits
@@ -9426,11 +9445,10 @@ function createChatSession(
94269445
});
94279446

94289447
// Listen for messages during streaming (steering + next-turn buffer)
9429-
const sessionPendingWire: ChatTaskWirePayload[] = [];
94309448
const sessionMsgSub = messagesInput.on(async (msg) => {
9431-
sessionPendingWire.push(msg);
9432-
94339449
if (sessionPendingMessages) {
9450+
// Steering route — the frontend re-sends non-injected
9451+
// messages on turn complete, so don't also buffer the wire.
94349452
// Slim wire: at most one delta message per record. Read
94359453
// `msg.message` directly — no array slicing needed.
94369454
const lastUIMessage = msg.message;
@@ -9453,7 +9471,10 @@ function createChatSession(
94539471
/* non-fatal */
94549472
}
94559473
}
9474+
return;
94569475
}
9476+
9477+
sessionPendingWire.push(msg);
94579478
});
94589479

94599480
// Accumulate messages. Slim wire: pass the single delta message as

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 {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Import the test harness FIRST — this installs the resource catalog so
2+
// `chat.agent()` calls below register their task functions correctly.
3+
import { mockChatAgent } from "../src/v3/test/index.js";
4+
5+
import { describe, expect, it, vi } from "vitest";
6+
import { chat } from "../src/v3/ai.js";
7+
import { __setSessionOpenImplForTests, sessions } from "../src/v3/sessions.js";
8+
import { apiClientManager, sessionStreams } from "@trigger.dev/core/v3";
9+
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
10+
import { simulateReadableStream, streamText } from "ai";
11+
import { MockLanguageModelV3 } from "ai/test";
12+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
13+
14+
// ── Helpers ────────────────────────────────────────────────────────────
15+
16+
function userMessage(text: string, id: string) {
17+
return {
18+
id,
19+
role: "user" as const,
20+
parts: [{ type: "text" as const, text }],
21+
};
22+
}
23+
24+
function textStreamChunks(text: string): LanguageModelV3StreamPart[] {
25+
return [
26+
{ type: "text-start", id: "t1" },
27+
{ type: "text-delta", id: "t1", delta: text },
28+
{ type: "text-end", id: "t1" },
29+
{
30+
type: "finish",
31+
finishReason: { unified: "stop", raw: "stop" },
32+
usage: {
33+
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
34+
outputTokens: { total: 10, text: 10, reasoning: undefined },
35+
},
36+
},
37+
];
38+
}
39+
40+
/** Model that answers `ANSWER(<last user text>)`, slowly enough that
41+
* records sent right after the turn starts arrive mid-stream. */
42+
function echoModel() {
43+
return new MockLanguageModelV3({
44+
doStream: async ({ prompt }) => {
45+
const users = prompt.filter((m) => m.role === "user");
46+
const last = users[users.length - 1];
47+
const text = Array.isArray(last?.content)
48+
? last.content
49+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
50+
.map((p) => p.text)
51+
.join("")
52+
: "";
53+
return {
54+
stream: simulateReadableStream({
55+
chunks: textStreamChunks(`ANSWER(${text})`),
56+
initialDelayInMs: 100,
57+
chunkDelayInMs: 10,
58+
}),
59+
};
60+
},
61+
});
62+
}
63+
64+
async function waitFor(check: () => boolean, timeoutMs = 10_000) {
65+
const start = Date.now();
66+
while (Date.now() - start < timeoutMs) {
67+
if (check()) return;
68+
await new Promise((r) => setTimeout(r, 20));
69+
}
70+
throw new Error("waitFor timed out");
71+
}
72+
73+
function streamedText(harness: { allChunks: unknown[] }): string {
74+
return (harness.allChunks as { type?: string; delta?: string }[])
75+
.filter((c) => c.type === "text-delta")
76+
.map((c) => c.delta ?? "")
77+
.join("");
78+
}
79+
80+
function turnCompleteCount(harness: { allRawChunks: unknown[] }): number {
81+
return (harness.allRawChunks as { type?: string }[]).filter(
82+
(c) => c.type === "trigger:turn-complete"
83+
).length;
84+
}
85+
86+
// ── Tests ──────────────────────────────────────────────────────────────
87+
88+
describe("chat.agent pending wire buffer", () => {
89+
it("dispatches every message buffered during a turn, not just the first", async () => {
90+
const agent = chat.agent({
91+
id: "pending-drain.agent",
92+
run: async ({ messages, signal }) => {
93+
return streamText({ model: echoModel(), messages, abortSignal: signal });
94+
},
95+
});
96+
97+
const harness = mockChatAgent(agent, { chatId: "pending-drain-1" });
98+
try {
99+
const first = harness.sendMessage(userMessage("m1", "u-1"));
100+
// Once m1's turn is streaming, land two more records back-to-back —
101+
// both are consumed into the turn's buffer before the turn ends.
102+
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
103+
void harness.sendMessage(userMessage("m2", "u-2"));
104+
void harness.sendMessage(userMessage("m3", "u-3"));
105+
await first;
106+
107+
await waitFor(() => turnCompleteCount(harness) >= 3);
108+
109+
const text = streamedText(harness);
110+
const m2At = text.indexOf("ANSWER(m2)");
111+
const m3At = text.indexOf("ANSWER(m3)");
112+
expect(m2At).toBeGreaterThan(-1);
113+
expect(m3At).toBeGreaterThan(-1);
114+
expect(m3At).toBeGreaterThan(m2At);
115+
} finally {
116+
await harness.close();
117+
}
118+
});
119+
});
120+
121+
describe("chat.createSession pending wire buffer", () => {
122+
it("dispatches messages buffered during a turn as subsequent turns", async () => {
123+
const agent = chat.customAgent({
124+
id: "pending-drain.session",
125+
run: async (payload) => {
126+
const session = chat.createSession(payload, {
127+
signal: new AbortController().signal,
128+
idleTimeoutInSeconds: 2,
129+
});
130+
for await (const turn of session) {
131+
const result = streamText({
132+
model: echoModel(),
133+
messages: turn.messages,
134+
abortSignal: turn.signal,
135+
});
136+
await turn.complete(result);
137+
}
138+
},
139+
});
140+
141+
const harness = mockChatAgent(agent, { chatId: "pending-drain-2" });
142+
try {
143+
const first = harness.sendMessage(userMessage("m1", "u-1"));
144+
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
145+
void harness.sendMessage(userMessage("m2", "u-2"));
146+
void harness.sendMessage(userMessage("m3", "u-3"));
147+
await first;
148+
149+
await waitFor(() => turnCompleteCount(harness) >= 3);
150+
151+
const text = streamedText(harness);
152+
expect(text).toContain("ANSWER(m2)");
153+
expect(text).toContain("ANSWER(m3)");
154+
} finally {
155+
await harness.close();
156+
}
157+
});
158+
});
159+
160+
describe("session.in.wait() consume cursor", () => {
161+
it("advances lastDispatchedSeqNum alongside lastSeqNum on waitpoint delivery", async () => {
162+
__setSessionOpenImplForTests(undefined);
163+
await runInMockTaskContext(async () => {
164+
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
165+
createSessionStreamWaitpoint: async () => ({ waitpointId: "wp_test_1" }),
166+
waitForWaitpointToken: async () => ({ success: true }),
167+
} as never);
168+
169+
const sessionId = "cursor-sess";
170+
// Simulate records 0..4 already received via SSE before the suspend.
171+
sessionStreams.setLastSeqNum(sessionId, "in", 4);
172+
173+
const result = await sessions.open(sessionId).in.wait();
174+
175+
expect(result.ok).toBe(true);
176+
expect(sessionStreams.lastSeqNum(sessionId, "in")).toBe(5);
177+
// The waitpoint-delivered record was consumed by this caller, so the
178+
// committed-consume cursor (what turn-completes persist as
179+
// `session-in-event-id`) must advance with it.
180+
expect(sessionStreams.lastDispatchedSeqNum(sessionId, "in")).toBe(5);
181+
});
182+
});
183+
});

0 commit comments

Comments
 (0)