Skip to content

Commit 1065251

Browse files
fix(chat): ignore stale turn completions after reconnect (#4643)
## Summary Reloading a browser chat mid-turn can replay a completion event for an older input and close the active turn too early. This persists the last browser-owned input sequence and reuses it on reconnect, so older completion events are ignored. The sequence is cleared after the matching boundary, and reconnect avoids the settled-peek shortcut while that sequence is active. The persisted field is optional, so sessions without it keep their existing behavior. ## Testing - `pnpm --dir packages/trigger-sdk run test ./src/v3/chat.test.ts ./test/chat-turn-correlation.test.ts --run` — 67 passed - `pnpm --dir packages/trigger-sdk run test --run` — 32 files, 379 tests passed - `pnpm run build --filter @trigger.dev/sdk` - `pnpm run format` - `pnpm run lint` ## Changelog Browser chats now keep the active turn open across page reloads when older completion records are replayed. ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works 💯 Co-authored-by: Matt Aitken <matt@mattaitken.com>
1 parent c115f44 commit 1065251

4 files changed

Lines changed: 196 additions & 15 deletions

File tree

.changeset/calm-chat-reconnects.md

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+
Browser chats now keep the active turn open across page reloads when older completion records are replayed.

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ describe("TriggerChatTransport", () => {
228228
"chat-1": {
229229
publicAccessToken: "hydrated-pat",
230230
lastEventId: "42",
231+
activeInputSeq: 41,
231232
isStreaming: false,
232233
},
233234
},
@@ -237,6 +238,7 @@ describe("TriggerChatTransport", () => {
237238
expect(session).toEqual({
238239
publicAccessToken: "hydrated-pat",
239240
lastEventId: "42",
241+
activeInputSeq: 41,
240242
isStreaming: false,
241243
});
242244
});
@@ -262,15 +264,21 @@ describe("TriggerChatTransport", () => {
262264
transport.setSession("chat-x", {
263265
publicAccessToken: "tok",
264266
lastEventId: "10",
267+
activeInputSeq: 9,
265268
});
266269

267270
expect(transport.getSession("chat-x")).toMatchObject({
268271
publicAccessToken: "tok",
269272
lastEventId: "10",
273+
activeInputSeq: 9,
270274
});
271275
expect(onSessionChange).toHaveBeenCalledWith(
272276
"chat-x",
273-
expect.objectContaining({ publicAccessToken: "tok", lastEventId: "10" })
277+
expect.objectContaining({
278+
publicAccessToken: "tok",
279+
lastEventId: "10",
280+
activeInputSeq: 9,
281+
})
274282
);
275283
});
276284

@@ -977,7 +985,9 @@ describe("TriggerChatTransport", () => {
977985
it("marks the session streaming and notifies before subscribing", async () => {
978986
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
979987
const urlStr = typeof url === "string" ? url : url.toString();
980-
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
988+
if (isSessionStreamAppendUrl(urlStr)) {
989+
return new Response(JSON.stringify({ ok: true, seq: 7 }), { status: 200 });
990+
}
981991
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse();
982992
throw new Error(`Unexpected URL: ${urlStr}`);
983993
});
@@ -994,7 +1004,9 @@ describe("TriggerChatTransport", () => {
9941004
// isStreaming:true must be observed during the action — otherwise a reload
9951005
// mid-action sees a persisted isStreaming:false and never resumes.
9961006
expect(
997-
onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === true)
1007+
onSessionChange.mock.calls.some(
1008+
([, session]) => session && session.isStreaming === true && session.activeInputSeq === 7
1009+
)
9981010
).toBe(true);
9991011
await drainChunks(stream);
10001012
});

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,13 @@ export type StartSessionResult = {
422422
* Public surface of {@link TriggerChatTransport}'s session state. Everything
423423
* the customer should persist for resumption across page reloads. The
424424
* transport addresses by `chatId` everywhere, so this is light: just a PAT,
425-
* the last SSE event id, and a couple of UX-state flags.
425+
* resume cursors, and a couple of UX-state flags.
426426
*/
427427
export type ChatSessionPersistedState = {
428428
publicAccessToken: string;
429429
lastEventId?: string;
430+
/** The `.in` append sequence of the last send this client owned; reused as `sinceInSeq` on reconnect. */
431+
activeInputSeq?: number;
430432
isStreaming?: boolean;
431433
};
432434

@@ -631,6 +633,8 @@ type ChatSessionState = {
631633
publicAccessToken: string;
632634
/** Last SSE event ID — used to resume the stream without replaying old events. */
633635
lastEventId?: string;
636+
/** `.in` append sequence used to filter stale turn boundaries after reconnecting. */
637+
activeInputSeq?: number;
634638
/** Set when the stream was aborted mid-turn (stop). On reconnect, skip chunks until trigger:turn-complete. */
635639
skipToTurnComplete?: boolean;
636640
/** Whether the agent is currently streaming a response. Set on first chunk, cleared on turn-complete. */
@@ -718,6 +722,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
718722
this.sessions.set(chatId, {
719723
publicAccessToken: session.publicAccessToken,
720724
lastEventId: session.lastEventId,
725+
activeInputSeq: session.activeInputSeq,
721726
isStreaming: session.isStreaming,
722727
});
723728
}
@@ -874,6 +879,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
874879
// turn would be skipped record by record.
875880
state.skipToTurnComplete = false;
876881

882+
state.activeInputSeq = inSeq;
877883
state.isStreaming = true;
878884
this.notifySessionChange(chatId, state);
879885

@@ -1181,13 +1187,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11811187
return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
11821188
resumed: true,
11831189
sendStopOnAbort: options.stopOnAbort ?? false,
1190+
sinceInSeq: state.activeInputSeq,
11841191
// Reconnect-on-reload opts into the server's settled-peek shortcut
1185-
// so the SSE doesn't hang for 60s when no turn is in flight. Active
1186-
// send-a-message paths must keep wait=60 to avoid racing the
1187-
// freshly-triggered turn's first chunk. Watch mode must NOT peek: a
1188-
// settled peek between turns sets sessionSettled and closes the
1192+
// so the SSE doesn't hang for 60s when no turn is in flight. A known
1193+
// active input must not peek because the previous turn's completion
1194+
// can remain at the tail until the current turn writes its first chunk.
1195+
// Watch mode must NOT peek: a settled peek between turns closes the
11891196
// standing subscription, so the viewer never sees the next turn.
1190-
peekSettled: !this.watchMode,
1197+
peekSettled: !this.watchMode && state.activeInputSeq === undefined,
11911198
});
11921199
};
11931200

@@ -1291,6 +1298,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12911298

12921299
// Mark streaming + persist so a reload mid-action resumes (reconnectToStream
12931300
// no-ops when the persisted session says isStreaming: false).
1301+
state.activeInputSeq = inSeq;
12941302
state.isStreaming = true;
12951303
this.notifySessionChange(chatId, state);
12961304

@@ -1315,6 +1323,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13151323
this.sessions.set(chatId, {
13161324
publicAccessToken: session.publicAccessToken,
13171325
lastEventId: session.lastEventId,
1326+
activeInputSeq: session.activeInputSeq,
13181327
isStreaming: session.isStreaming,
13191328
});
13201329
this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)!));
@@ -1449,6 +1458,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14491458
private toPersisted = (state: ChatSessionState): ChatSessionPersistedState => ({
14501459
publicAccessToken: state.publicAccessToken,
14511460
lastEventId: state.lastEventId,
1461+
activeInputSeq: state.activeInputSeq,
14521462
isStreaming: state.isStreaming,
14531463
});
14541464

@@ -1758,6 +1768,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17581768
}) as typeof fetch)
17591769
: undefined;
17601770
let sawFirstChunk = false;
1771+
let sinceInSeq = options?.sinceInSeq;
17611772

17621773
const connectSseOnce = async (token: string) => {
17631774
const subscription = new SSEStreamSubscription(streamUrl, {
@@ -1991,10 +2002,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
19912002
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
19922003
// Skip a turn-complete from an earlier turn (committed `.in` cursor
19932004
// below this send's seq), e.g. an undo action that raced this send.
1994-
if (options?.sinceInSeq !== undefined) {
2005+
if (sinceInSeq !== undefined) {
19952006
const cursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
19962007
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : NaN;
1997-
if (!Number.isNaN(cursor) && cursor < options.sinceInSeq) {
2008+
if (!Number.isNaN(cursor) && cursor < sinceInSeq) {
19982009
continue;
19992010
}
20002011
}
@@ -2012,6 +2023,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
20122023
sessionInEventId: headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER),
20132024
...this.turnAttribution(chatId),
20142025
});
2026+
state.activeInputSeq = undefined;
2027+
sinceInSeq = undefined;
20152028
state.isStreaming = false;
20162029
this.notifySessionChange(chatId, state);
20172030
this.coordinator?.release(chatId);

packages/trigger-sdk/test/chat-turn-correlation.test.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import type { UIMessage } from "ai";
33
import { TriggerChatTransport, type TriggerChatTransportOptions } from "../src/v3/chat.js";
44

@@ -17,13 +17,18 @@ type BatchRecord = {
1717
headers?: Array<[string, string]>;
1818
};
1919

20-
function batchResponse(records: BatchRecord[]): Response {
20+
function batchResponse(records: BatchRecord[], settled = false): Response {
2121
const frames = records
2222
.map((r) => `event: batch\ndata: ${JSON.stringify({ records: [r] })}\n\n`)
2323
.join("");
24+
const headers: Record<string, string> = {
25+
"Content-Type": "text/event-stream",
26+
"X-Stream-Version": "v2",
27+
};
28+
if (settled) headers["X-Session-Settled"] = "true";
2429
return new Response(frames, {
2530
status: 200,
26-
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
31+
headers,
2732
});
2833
}
2934

@@ -42,7 +47,10 @@ function turnComplete(seqNum: number, inCursor: number): BatchRecord {
4247

4348
function textDelta(seqNum: number, text: string): BatchRecord {
4449
return {
45-
body: JSON.stringify({ data: { type: "text-delta", id: "t1", delta: text }, id: "m1" }),
50+
body: JSON.stringify({
51+
data: { type: "text-delta", id: "t1", delta: text },
52+
id: `m${seqNum}`,
53+
}),
4654
seq_num: seqNum,
4755
timestamp: seqNum,
4856
headers: [],
@@ -88,6 +96,36 @@ async function submit(transport: TriggerChatTransport): Promise<string[]> {
8896
}
8997

9098
describe("transport turn correlation", () => {
99+
it("persists the owned send's input sequence before subscribing", async () => {
100+
const onSessionChange = vi.fn();
101+
const transport = new TriggerChatTransport({
102+
task: "test-task",
103+
accessToken: async () => "tok_test",
104+
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
105+
onSessionChange,
106+
fetch: async (_url, _init, ctx) =>
107+
ctx.endpoint === "in" ? inResponse(5) : batchResponse([turnComplete(10, 5)]),
108+
});
109+
110+
const stream = await transport.sendMessages({
111+
trigger: "submit-message",
112+
chatId: "c1",
113+
messageId: undefined,
114+
messages: [user("hi", "u-1")],
115+
abortSignal: undefined,
116+
});
117+
118+
expect(onSessionChange).toHaveBeenCalledWith("c1", {
119+
publicAccessToken: "tok_test",
120+
lastEventId: undefined,
121+
activeInputSeq: 5,
122+
isStreaming: true,
123+
});
124+
expect(transport.getSession("c1")?.activeInputSeq).toBe(5);
125+
await readDeltas(stream);
126+
expect(transport.getSession("c1")?.activeInputSeq).toBeUndefined();
127+
});
128+
91129
it("skips an earlier turn's turn-complete and closes on its own", async () => {
92130
// Append seq 5; the undo turn's complete (cursor 4) must be skipped.
93131
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
@@ -107,4 +145,117 @@ describe("transport turn correlation", () => {
107145
const deltas = await submit(makeTransport(out, undefined));
108146
expect(deltas).toEqual([]);
109147
});
148+
149+
it("reuses a hydrated input sequence to skip stale turn-completes after reconnecting", async () => {
150+
const transport = new TriggerChatTransport({
151+
task: "test-task",
152+
accessToken: async () => "tok_test",
153+
sessions: {
154+
c1: { publicAccessToken: "tok_test", isStreaming: true, activeInputSeq: 5 },
155+
},
156+
fetch: async () =>
157+
batchResponse([turnComplete(10, 4), textDelta(11, "current"), turnComplete(12, 5)]),
158+
});
159+
160+
const stream = await transport.reconnectToStream({ chatId: "c1" });
161+
162+
expect(stream).not.toBeNull();
163+
await expect(readDeltas(stream!)).resolves.toEqual(["current"]);
164+
expect(transport.getSession("c1")?.isStreaming).toBe(false);
165+
expect(transport.getSession("c1")?.activeInputSeq).toBeUndefined();
166+
});
167+
168+
it("does not request a settled peek while reconnecting a known active input", async () => {
169+
vi.useFakeTimers();
170+
try {
171+
const subscribeHeaders: Headers[] = [];
172+
const transport = new TriggerChatTransport({
173+
task: "test-task",
174+
accessToken: async () => "tok_test",
175+
sessions: {
176+
c1: { publicAccessToken: "tok_test", isStreaming: true, activeInputSeq: 5 },
177+
},
178+
fetch: async (_url, init) => {
179+
const headers = new Headers(init?.headers);
180+
subscribeHeaders.push(headers);
181+
182+
if (subscribeHeaders.length === 1) {
183+
// Match the server shortcut: a peek sees the previous turn's
184+
// boundary at the tail and marks this otherwise-normal EOF settled.
185+
return batchResponse([turnComplete(10, 4)], headers.has("X-Peek-Settled"));
186+
}
187+
188+
return batchResponse([textDelta(11, "current"), turnComplete(12, 5)]);
189+
},
190+
});
191+
192+
const stream = await transport.reconnectToStream({ chatId: "c1" });
193+
194+
expect(stream).not.toBeNull();
195+
const deltas = readDeltas(stream!);
196+
await vi.advanceTimersByTimeAsync(1_000);
197+
await expect(deltas).resolves.toEqual(["current"]);
198+
expect(subscribeHeaders).toHaveLength(2);
199+
expect(subscribeHeaders[0]?.get("X-Peek-Settled")).toBeNull();
200+
expect(transport.getSession("c1")?.isStreaming).toBe(false);
201+
expect(transport.getSession("c1")?.activeInputSeq).toBeUndefined();
202+
} finally {
203+
vi.useRealTimers();
204+
}
205+
});
206+
it.each([5, 6])(
207+
"accepts a reconnected turn-complete at or after the active input sequence (%i)",
208+
async (inCursor) => {
209+
const transport = new TriggerChatTransport({
210+
task: "test-task",
211+
accessToken: async () => "tok_test",
212+
sessions: {
213+
c1: { publicAccessToken: "tok_test", isStreaming: true, activeInputSeq: 5 },
214+
},
215+
fetch: async () => batchResponse([turnComplete(10, inCursor), textDelta(11, "late")]),
216+
});
217+
218+
const stream = await transport.reconnectToStream({ chatId: "c1" });
219+
220+
expect(stream).not.toBeNull();
221+
await expect(readDeltas(stream!)).resolves.toEqual([]);
222+
expect(transport.getSession("c1")?.isStreaming).toBe(false);
223+
expect(transport.getSession("c1")?.activeInputSeq).toBeUndefined();
224+
}
225+
);
226+
227+
it("uses the input sequence for one accepted watch turn only", async () => {
228+
let outCalls = 0;
229+
const turnCompleted: number[] = [];
230+
const transport = new TriggerChatTransport({
231+
task: "test-task",
232+
accessToken: async () => "tok_test",
233+
watch: true,
234+
sessions: {
235+
c1: { publicAccessToken: "tok_test", isStreaming: true, activeInputSeq: 5 },
236+
},
237+
onEvent: (event) => {
238+
if (event.type === "turn-completed") turnCompleted.push(Number(event.sessionInEventId));
239+
},
240+
fetch: async () => {
241+
outCalls++;
242+
return outCalls === 1
243+
? batchResponse([
244+
turnComplete(10, 4),
245+
textDelta(11, "first"),
246+
turnComplete(12, 5),
247+
textDelta(13, "second"),
248+
turnComplete(14, 4),
249+
])
250+
: batchResponse([], true);
251+
},
252+
});
253+
254+
const stream = await transport.reconnectToStream({ chatId: "c1" });
255+
256+
expect(stream).not.toBeNull();
257+
await expect(readDeltas(stream!)).resolves.toEqual(["first", "second"]);
258+
expect(turnCompleted).toEqual([5, 4]);
259+
expect(transport.getSession("c1")?.activeInputSeq).toBeUndefined();
260+
});
110261
});

0 commit comments

Comments
 (0)