Skip to content

Commit 0a10002

Browse files
committed
fix(sdk,core): harden the transcript read path
Treat a non-positive page limit as no limit instead of an empty page a caller cannot tell apart from the end of the transcript. Hold a seeded resume cursor until the session exists, so a transcript load that resolves before the session is created still opens the live stream past the persisted history instead of replaying it.
1 parent b6eba82 commit 0a10002

5 files changed

Lines changed: 89 additions & 53 deletions

File tree

packages/core/src/v3/sessionStreams/chatSnapshot.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
150150

151151
/**
152152
* Select one page of transcript entries, newest last. `before` keeps only the
153-
* entries ordered before that id; `limit` keeps the last that many. The
153+
* entries ordered before that id; `limit` keeps the last that many. A
154+
* non-positive `limit` is treated as no limit (every entry, no cursor), so a
155+
* caller cannot mistake an empty page for the end of the transcript. The
154156
* returned `nextCursor` is the id to pass as `before` for the previous page,
155157
* absent when there is no earlier page.
156158
*/
@@ -164,7 +166,7 @@ export function pageTranscriptEntries<TUIMessage extends UIMessage = UIMessage>(
164166
if (idx !== -1) entries = entries.slice(0, idx);
165167
}
166168
let nextCursor: string | undefined;
167-
if (opts?.limit !== undefined && opts.limit >= 0 && entries.length > opts.limit) {
169+
if (opts?.limit !== undefined && opts.limit > 0 && entries.length > opts.limit) {
168170
entries = entries.slice(entries.length - opts.limit);
169171
nextCursor = entries[0]?.id;
170172
}

packages/core/test/chatSnapshot.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ describe("pageTranscriptEntries", () => {
3636
it("ignores an unknown `before` id", () => {
3737
expect(pageTranscriptEntries(entries, { before: "zz" }).entries).toHaveLength(5);
3838
});
39+
40+
it("treats a zero limit as no limit rather than an empty page", () => {
41+
const page = pageTranscriptEntries(entries, { limit: 0 });
42+
expect(page.entries).toEqual(entries);
43+
expect(page.nextCursor).toBeUndefined();
44+
});
3945
});
4046

4147
const user = { id: "u-1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] };

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,19 @@ export type UseLoadTranscriptOptions = {
7474
/**
7575
* Move the transport's resume cursor for `chatId` to the transcript's
7676
* `lastOutEventId`, so the live subscription opens just past the persisted
77-
* history. A no-op when the transcript carries no cursor or the transport
78-
* does not know the session yet (the cursor cannot be stored without the
79-
* session's access token). Returns whether the cursor was seeded.
77+
* history. Applied to the session now if it exists, otherwise held by the
78+
* transport until the session is created, so a load that resolves before the
79+
* session exists still moves the cursor. A no-op when the transcript carries
80+
* no cursor. Returns whether a cursor was provided.
8081
*/
8182
export function seedTranscriptCursor(
82-
transport: Pick<TriggerChatTransport, "getSession" | "setSession">,
83+
transport: Pick<TriggerChatTransport, "seedResumeCursor">,
8384
chatId: string,
8485
cursors: { lastOutEventId?: string } | undefined
8586
): boolean {
8687
const lastEventId = cursors?.lastOutEventId;
8788
if (!lastEventId) return false;
88-
const session = transport.getSession(chatId);
89-
if (!session) return false;
90-
transport.setSession(chatId, { ...session, lastEventId });
89+
transport.seedResumeCursor(chatId, lastEventId);
9190
return true;
9291
}
9392

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
730730
private _onEvent: ((event: ChatTransportEvent) => void) | undefined;
731731

732732
private sessions: Map<string, ChatSessionState> = new Map();
733+
private pendingResumeCursors: Map<string, string> = new Map();
733734
private activeStreams: Map<string, AbortController> = new Map();
734735
private pendingStarts: Map<string, Promise<ChatSessionState>> = new Map();
735736
// Last turn-producing send per chat — attribution source for the
@@ -1404,6 +1405,31 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
14041405
this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)!));
14051406
}
14061407

1408+
/**
1409+
* Seed the `.out` resume cursor from a loaded transcript. Applied to the
1410+
* session now if it exists, otherwise held until the session is created so
1411+
* the first live subscription opens past the persisted history instead of
1412+
* replaying it.
1413+
*/
1414+
seedResumeCursor = (chatId: string, lastEventId: string): void => {
1415+
const existing = this.sessions.get(chatId);
1416+
if (existing?.publicAccessToken) {
1417+
existing.lastEventId = lastEventId;
1418+
this.notifySessionChange(chatId, this.toPersisted(existing));
1419+
return;
1420+
}
1421+
this.pendingResumeCursors.set(chatId, lastEventId);
1422+
};
1423+
1424+
private applyPendingResumeCursor(chatId: string, state: ChatSessionState): ChatSessionState {
1425+
const pending = this.pendingResumeCursors.get(chatId);
1426+
if (pending !== undefined && state.lastEventId === undefined) {
1427+
state.lastEventId = pending;
1428+
}
1429+
this.pendingResumeCursors.delete(chatId);
1430+
return state;
1431+
}
1432+
14071433
setOnSessionChange(
14081434
callback: ((chatId: string, session: ChatSessionPersistedState | null) => void) | undefined
14091435
): void {
@@ -1614,7 +1640,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16141640
// `sessions: { ... }` already, or the very first `accessToken` call
16151641
// returns a PAT for an out-of-band-created session.
16161642
const token = await this.resolveAccessToken({ chatId });
1617-
const state: ChatSessionState = { publicAccessToken: token };
1643+
const state = this.applyPendingResumeCursor(chatId, { publicAccessToken: token });
16181644
this.sessions.set(chatId, state);
16191645
this.notifySessionChange(chatId, state);
16201646
return state;
@@ -1633,10 +1659,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16331659
clientData: (this.defaultMetadata ?? {}) as Record<string, unknown>,
16341660
});
16351661

1636-
const state: ChatSessionState = {
1662+
const state = this.applyPendingResumeCursor(chatId, {
16371663
publicAccessToken,
16381664
isStreaming: false,
1639-
};
1665+
});
16401666
this.sessions.set(chatId, state);
16411667
this.notifySessionChange(chatId, state);
16421668
return state;
Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,59 @@
11
import { describe, expect, it } from "vitest";
2-
import type { ChatSessionPersistedState } from "../src/v3/chat.js";
2+
import { TriggerChatTransport } from "../src/v3/chat.js";
33
import { seedTranscriptCursor } from "../src/v3/chat-react.js";
44

5-
function fakeTransport(initial?: ChatSessionPersistedState) {
6-
const sessions = new Map<string, ChatSessionPersistedState>();
7-
if (initial) sessions.set("chat-1", initial);
8-
const calls: Array<{ chatId: string; session: ChatSessionPersistedState }> = [];
9-
return {
10-
calls,
11-
sessions,
12-
getSession: (chatId: string) => sessions.get(chatId),
13-
setSession: (chatId: string, session: ChatSessionPersistedState) => {
14-
calls.push({ chatId, session });
15-
sessions.set(chatId, session);
16-
},
17-
};
5+
function transportWithStart() {
6+
return new TriggerChatTransport({
7+
task: "my-chat",
8+
accessToken: () => "pat",
9+
startSession: async () => ({ publicAccessToken: "pat-from-start" }),
10+
});
1811
}
1912

2013
describe("seedTranscriptCursor", () => {
21-
it("moves the transport's resume cursor to the transcript's lastOutEventId", () => {
22-
const transport = fakeTransport({
23-
publicAccessToken: "pat",
24-
lastEventId: "3",
25-
activeInputSeq: 2,
26-
isStreaming: false,
27-
});
28-
29-
expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" })).toBe(true);
30-
expect(transport.calls).toEqual([
31-
{
32-
chatId: "chat-1",
33-
session: {
34-
publicAccessToken: "pat",
35-
lastEventId: "42",
36-
activeInputSeq: 2,
37-
isStreaming: false,
38-
},
14+
it("forwards the transcript's lastOutEventId to the transport", () => {
15+
const calls: Array<{ chatId: string; lastEventId: string }> = [];
16+
const transport = {
17+
seedResumeCursor: (chatId: string, lastEventId: string) => {
18+
calls.push({ chatId, lastEventId });
3919
},
40-
]);
41-
});
20+
};
4221

43-
it("does nothing when the transport does not know the session yet", () => {
44-
const transport = fakeTransport();
45-
expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" })).toBe(false);
46-
expect(transport.calls).toEqual([]);
22+
expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" })).toBe(true);
23+
expect(calls).toEqual([{ chatId: "chat-1", lastEventId: "42" }]);
4724
});
4825

4926
it("does nothing when the transcript carries no cursor", () => {
50-
const transport = fakeTransport({ publicAccessToken: "pat", lastEventId: "3" });
27+
const calls: string[] = [];
28+
const transport = {
29+
seedResumeCursor: (chatId: string) => {
30+
calls.push(chatId);
31+
},
32+
};
33+
5134
expect(seedTranscriptCursor(transport, "chat-1", undefined)).toBe(false);
5235
expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "" })).toBe(false);
53-
expect(transport.calls).toEqual([]);
54-
expect(transport.getSession("chat-1")?.lastEventId).toBe("3");
36+
expect(calls).toEqual([]);
37+
});
38+
});
39+
40+
describe("TriggerChatTransport resume cursor", () => {
41+
it("holds a seeded cursor until the session is created, then applies it", async () => {
42+
const transport = transportWithStart();
43+
44+
seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" });
45+
expect(transport.getSession("chat-1")).toBeUndefined();
46+
47+
await transport.start("chat-1");
48+
expect(transport.getSession("chat-1")?.lastEventId).toBe("42");
49+
});
50+
51+
it("applies a seeded cursor immediately when the session already exists", async () => {
52+
const transport = transportWithStart();
53+
54+
await transport.start("chat-1");
55+
seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "99" });
56+
57+
expect(transport.getSession("chat-1")?.lastEventId).toBe("99");
5558
});
5659
});

0 commit comments

Comments
 (0)