Skip to content

Commit 177cd53

Browse files
committed
fix(chat): persist active input sequence across reloads
1 parent 444c221 commit 177cd53

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
}
@@ -870,6 +875,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
870875
this.activeStreams.delete(chatId);
871876
}
872877

878+
state.activeInputSeq = inSeq;
873879
state.isStreaming = true;
874880
this.notifySessionChange(chatId, state);
875881

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

@@ -1283,6 +1290,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12831290

12841291
// Mark streaming + persist so a reload mid-action resumes (reconnectToStream
12851292
// no-ops when the persisted session says isStreaming: false).
1293+
state.activeInputSeq = inSeq;
12861294
state.isStreaming = true;
12871295
this.notifySessionChange(chatId, state);
12881296

@@ -1307,6 +1315,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
13071315
this.sessions.set(chatId, {
13081316
publicAccessToken: session.publicAccessToken,
13091317
lastEventId: session.lastEventId,
1318+
activeInputSeq: session.activeInputSeq,
13101319
isStreaming: session.isStreaming,
13111320
});
13121321
this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)!));
@@ -1441,6 +1450,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14411450
private toPersisted = (state: ChatSessionState): ChatSessionPersistedState => ({
14421451
publicAccessToken: state.publicAccessToken,
14431452
lastEventId: state.lastEventId,
1453+
activeInputSeq: state.activeInputSeq,
14441454
isStreaming: state.isStreaming,
14451455
});
14461456

@@ -1750,6 +1760,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17501760
}) as typeof fetch)
17511761
: undefined;
17521762
let sawFirstChunk = false;
1763+
let sinceInSeq = options?.sinceInSeq;
17531764

17541765
const connectSseOnce = async (token: string) => {
17551766
const subscription = new SSEStreamSubscription(streamUrl, {
@@ -1983,10 +1994,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
19831994
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
19841995
// Skip a turn-complete from an earlier turn (committed `.in` cursor
19851996
// below this send's seq), e.g. an undo action that raced this send.
1986-
if (options?.sinceInSeq !== undefined) {
1997+
if (sinceInSeq !== undefined) {
19871998
const cursorRaw = headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER);
19881999
const cursor = cursorRaw !== undefined ? Number.parseInt(cursorRaw, 10) : NaN;
1989-
if (!Number.isNaN(cursor) && cursor < options.sinceInSeq) {
2000+
if (!Number.isNaN(cursor) && cursor < sinceInSeq) {
19902001
continue;
19912002
}
19922003
}
@@ -2004,6 +2015,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
20042015
sessionInEventId: headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER),
20052016
...this.turnAttribution(chatId),
20062017
});
2018+
state.activeInputSeq = undefined;
2019+
sinceInSeq = undefined;
20072020
state.isStreaming = false;
20082021
this.notifySessionChange(chatId, state);
20092022
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)