Skip to content

Commit 75ec579

Browse files
committed
fix(sdk): simplify chat transcript recovery
1 parent ad4e2ef commit 75ec579

9 files changed

Lines changed: 537 additions & 131 deletions

File tree

.changeset/chat-stop-successor-boundary.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
Keep new chat responses intact after Stop, including slow Stop acknowledgments and page reloads.
66
Sequence-free replies after Stop require a transcript reload before further messages.
77
Loading a fresh transcript through `useLoadTranscript` restores blocked sessions only after its saved input cursor covers the stopped turn.
8+
Transcript recovery reports missing cursor evidence and empty output polls. An empty recovery poll keeps the accepted message available for reconnect.

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
},
8989
"packages/trigger-sdk": {
9090
"ignoreFiles": ["src/**/*-cjs.cts", "src/v3/index-browser.mts"],
91-
"ignoreDependencies": ["ai-v7", "react"]
91+
"ignoreDependencies": ["ai-v7"]
9292
},
9393
"docs": {
9494
"ignoreFiles": ["style.css"]

packages/trigger-sdk/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@
8787
"@ai-sdk/provider": "3.0.8",
8888
"@arethetypeswrong/cli": "^0.18.5",
8989
"@types/react": "^19.2.14",
90+
"@types/react-dom": "19.2.3",
9091
"ai": "^6.0.116",
9192
"ai-v7": "npm:ai@7.0.0-canary.159",
93+
"jsdom": "30.0.1",
94+
"react": "18.3.1",
95+
"react-dom": "18.3.1",
9296
"rimraf": "^6.0.1",
9397
"tshy": "^4.1.3",
9498
"tsx": "4.17.0",

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type InferChatUIMessage,
3333
} from "./ai-shared.js";
3434
import type { UIMessage, ChatRequestOptions } from "ai";
35+
import type { TranscriptCursors } from "./transcriptStorage.js";
3536

3637
/**
3738
* Options for `useTriggerChatTransport`, with a type-safe `task` field.
@@ -55,7 +56,7 @@ export type { ChatTransportEvent, ChatTransportSendSource } from "./chat.js";
5556
/** What a `chat.createLoadTranscriptAction` action returns, as `useLoadTranscript` reads it. */
5657
export type LoadTranscriptResult<TUIMessage extends UIMessage = UIMessage> = {
5758
messages: TUIMessage[];
58-
cursors?: { lastOutEventId?: string; lastInEventId?: string };
59+
cursors?: TranscriptCursors;
5960
nextCursor?: string;
6061
};
6162

@@ -79,17 +80,15 @@ export type UseLoadTranscriptOptions = {
7980
* history. Applied to the session now if it exists, otherwise held by the
8081
* transport until the session is created, so a load that resolves before the
8182
* session exists still moves the cursor. A no-op when the transcript carries
82-
* no cursor. A captured recovery callback replaces ordinary cursor seeding.
83+
* no cursor.
8384
* Returns whether the cursor was accepted.
8485
*/
8586
export function seedTranscriptCursor(
8687
transport: Pick<TriggerChatTransport, "seedResumeCursor">,
8788
chatId: string,
88-
cursors: { lastOutEventId?: string; lastInEventId?: string } | undefined,
89-
completeRecovery?: (lastEventId: string | undefined, lastInEventId?: string) => boolean
89+
cursors: TranscriptCursors | undefined
9090
): boolean {
9191
const lastEventId = cursors?.lastOutEventId;
92-
if (completeRecovery) return completeRecovery(lastEventId, cursors?.lastInEventId);
9392
if (!lastEventId) return false;
9493
transport.seedResumeCursor(chatId, lastEventId);
9594
return true;
@@ -150,11 +149,12 @@ export function useLoadTranscript<TUIMessage extends UIMessage = UIMessage>(
150149
.current({ chatId, ...(limit !== undefined ? { limit } : {}) })
151150
.then((result) => {
152151
if (cancelled) return;
153-
if (transport) {
154-
const seeded = seedTranscriptCursor(transport, chatId, result.cursors, completeRecovery);
155-
if (completeRecovery && !seeded) {
152+
if (completeRecovery) {
153+
if (!completeRecovery(result.cursors)) {
156154
throw new Error("The loaded transcript is not current. Reload the chat again.");
157155
}
156+
} else if (transport) {
157+
seedTranscriptCursor(transport, chatId, result.cursors);
158158
}
159159
setState({
160160
chatId,

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

Lines changed: 145 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import {
55
TriggerChatTransport,
66
type ChatSessionPersistedState,
7+
type ChatTransportEvent,
78
type TriggerChatTransportOptions,
89
} from "./chat.js";
910

@@ -505,7 +506,7 @@ describe("Stop with a successor response", () => {
505506
await hydrateBlockedSession("constructor");
506507
const recover = transport.prepareTranscriptRecovery("chat");
507508
if (!recover) throw new Error("Expected transcript recovery");
508-
expect(recover("5", "9")).toBe(false);
509+
expect(recover({ lastOutEventId: "5", lastInEventId: "9" })).toBe(false);
509510
await expect(send()).rejects.toThrow("Stopped chat response cannot be matched");
510511
expect(inputSeq).toBe(13);
511512
});
@@ -542,11 +543,11 @@ describe("Stop with a successor response", () => {
542543
if (hydrate === "setSession") transport.setSession("chat", session);
543544
const stale = transport.prepareTranscriptRecovery("chat");
544545
if (!stale) throw new Error("Expected transcript recovery");
545-
expect(stale("5", "9")).toBe(false);
546+
expect(stale({ lastOutEventId: "5", lastInEventId: "9" })).toBe(false);
546547
await expect(send()).rejects.toThrow("Stopped chat response cannot be matched");
547548
const fresh = transport.prepareTranscriptRecovery("chat");
548549
if (!fresh) throw new Error("Expected transcript recovery");
549-
expect(fresh("11", "10")).toBe(true);
550+
expect(fresh({ lastOutEventId: "11", lastInEventId: "10" })).toBe(true);
550551
const next = await transport.reconnectToStream({ chatId: "chat" });
551552
if (!next) throw new Error("Expected a resumed stream");
552553
await vi.waitFor(() => expect(outputs).toHaveLength(2));
@@ -575,7 +576,7 @@ describe("Stop with a successor response", () => {
575576
}
576577
const recover = transport.prepareTranscriptRecovery("chat");
577578
if (!recover) throw new Error("Expected transcript recovery");
578-
expect(recover("17", "11")).toBe(true);
579+
expect(recover({ lastOutEventId: "17", lastInEventId: "11" })).toBe(true);
579580
const next = await send();
580581
emit([...reply(18), complete(23, 13)]);
581582
await expect(readText(next)).resolves.toBe("New response");
@@ -597,8 +598,15 @@ describe("Stop with a successor response", () => {
597598
expect(await stopped).toBe(true);
598599
const recover = transport.prepareTranscriptRecovery("chat");
599600
if (!recover) throw new Error("Expected transcript recovery");
600-
expect(recover("17", "11")).toBe(false);
601+
expect(() => recover({ lastOutEventId: "17", lastInEventId: "11" })).toThrow(
602+
"Transcript recovery requires a stopped input sequence"
603+
);
601604
await expect(send()).rejects.toThrow("Stopped chat response cannot be matched");
605+
holdStop = false;
606+
expect(await transport.stopGeneration("chat")).toBe(true);
607+
expect(
608+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "17", lastInEventId: "11" })
609+
).toBe(true);
602610
});
603611

604612
it("accepts a sequence-free response without a stopped boundary", async () => {
@@ -632,7 +640,7 @@ describe("Stop with a successor response", () => {
632640
await hydrateBlockedSession(hydrate);
633641
const recover = transport.prepareTranscriptRecovery("chat");
634642
if (!recover) throw new Error("Expected transcript recovery");
635-
expect(recover("11", "11")).toBe(true);
643+
expect(recover({ lastOutEventId: "11", lastInEventId: "11" })).toBe(true);
636644
transport.seedResumeCursor("chat", "11");
637645
expect(saved).toMatchObject({
638646
lastEventId: "11",
@@ -658,7 +666,13 @@ describe("Stop with a successor response", () => {
658666
await hydrateBlockedSession("constructor");
659667
const recover = transport.prepareTranscriptRecovery("chat");
660668
if (!recover) throw new Error("Expected transcript recovery");
661-
expect(recover(cursor, "11")).toBe(false);
669+
if (cursor === undefined) {
670+
expect(() => recover({ lastOutEventId: cursor, lastInEventId: "11" })).toThrow(
671+
"Transcript recovery requires numeric input and output cursors"
672+
);
673+
} else {
674+
expect(recover({ lastOutEventId: cursor, lastInEventId: "11" })).toBe(false);
675+
}
662676
await expect(send()).rejects.toThrow("Stopped chat response cannot be matched");
663677
await expect(transport.sendAction("chat", { type: "undo" })).rejects.toThrow(
664678
"Stopped chat response cannot be matched"
@@ -685,7 +699,7 @@ describe("Stop with a successor response", () => {
685699
await hydrateBlockedSession("constructor");
686700
const recover = transport.prepareTranscriptRecovery("chat");
687701
if (!recover) throw new Error("Expected transcript recovery");
688-
expect(recover("11", "11")).toBe(true);
702+
expect(recover({ lastOutEventId: "11", lastInEventId: "11" })).toBe(true);
689703
if (hydrate !== "none") {
690704
const session = transport.getSession("chat");
691705
if (!session) throw new Error("Expected persisted state");
@@ -707,16 +721,21 @@ describe("Stop with a successor response", () => {
707721
}
708722
);
709723

710-
it("retains unknown recovery state after a bounded empty response", async () => {
724+
it("reports an empty recovery poll and reconnects without another append", async () => {
711725
await hydrateBlockedSession("constructor");
712726
const recover = transport.prepareTranscriptRecovery("chat");
713727
if (!recover) throw new Error("Expected transcript recovery");
714-
expect(recover("11", "11")).toBe(true);
728+
expect(recover({ lastOutEventId: "11", lastInEventId: "11" })).toBe(true);
729+
const events: ChatTransportEvent[] = [];
730+
transport.setOnEvent((event) => events.push(event));
715731
resumeAfterStoppedCheckpoint = true;
716732
emptyRecoveredOutput = true;
717733
const resumed = await transport.reconnectToStream({ chatId: "chat" });
718734
if (!resumed) throw new Error("Expected a resumed stream");
719-
await expect(readText(resumed)).resolves.toBe("");
735+
await expect(readText(resumed)).rejects.toThrow(
736+
"Chat recovery received no output before the poll ended. Reconnect to resume the accepted message."
737+
);
738+
expect(events.filter((event) => event.type === "stream-error")).toHaveLength(1);
720739
const request = outputHeaders.at(-1);
721740
if (!request) throw new Error("Expected a stream request");
722741
expect(request.peek).toBe(false);
@@ -742,7 +761,7 @@ describe("Stop with a successor response", () => {
742761
holdStop = true;
743762
const stopped = transport.stopGeneration("chat");
744763
await vi.waitFor(() => expect(pendingStop).toBeDefined());
745-
expect(recover("11", "11")).toBe(false);
764+
expect(recover({ lastOutEventId: "11", lastInEventId: "11" })).toBe(false);
746765
await expect(send()).rejects.toThrow("Stopped chat response cannot be matched");
747766
const pending = pendingStop!;
748767
appendResponse(pending.response, pending.seq);
@@ -751,6 +770,120 @@ describe("Stop with a successor response", () => {
751770
expect(transport.getSession("chat")).toMatchObject({ requiresTranscriptReload: true });
752771
});
753772

773+
it.each(["abort", "stop"] as const)("closes recovery quietly after %s", async (operation) => {
774+
await hydrateBlockedSession("constructor");
775+
expect(
776+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "11", lastInEventId: "11" })
777+
).toBe(true);
778+
const events: ChatTransportEvent[] = [];
779+
transport.setOnEvent((event) => events.push(event));
780+
const abort = new AbortController();
781+
const resumed = await transport.reconnectToStream({
782+
chatId: "chat",
783+
abortSignal: abort.signal,
784+
});
785+
if (!resumed) throw new Error("Expected a resumed stream");
786+
const result = readText(resumed);
787+
await vi.waitFor(() => expect(outputs).toHaveLength(2));
788+
if (operation === "abort") abort.abort();
789+
else await transport.stopGeneration("chat");
790+
await expect(result).resolves.toBe("");
791+
expect(events.filter((event) => event.type === "stream-error")).toEqual([]);
792+
if (operation === "abort") {
793+
expect(transport.getSession("chat")).toMatchObject({
794+
isStreaming: undefined,
795+
skipSettledPeek: true,
796+
});
797+
expect(inputSeq).toBe(13);
798+
} else {
799+
expect(transport.getSession("chat")).toMatchObject({
800+
isStreaming: false,
801+
skipToTurnComplete: true,
802+
});
803+
expect(inputSeq).toBe(14);
804+
}
805+
});
806+
807+
it("closes an empty settled recovery without an error", async () => {
808+
await hydrateBlockedSession("constructor");
809+
expect(
810+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "11", lastInEventId: "11" })
811+
).toBe(true);
812+
const events: ChatTransportEvent[] = [];
813+
transport.setOnEvent((event) => events.push(event));
814+
settled = true;
815+
emptyRecoveredOutput = true;
816+
const resumed = await transport.reconnectToStream({ chatId: "chat" });
817+
if (!resumed) throw new Error("Expected a resumed stream");
818+
await expect(readText(resumed)).resolves.toBe("");
819+
expect(events.filter((event) => event.type === "stream-error")).toEqual([]);
820+
expect(transport.getSession("chat")?.isStreaming).toBe(false);
821+
expect(inputSeq).toBe(13);
822+
});
823+
824+
it("reconnects a watch after an empty recovery response", async () => {
825+
await hydrateBlockedSession("constructor");
826+
expect(
827+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "11", lastInEventId: "11" })
828+
).toBe(true);
829+
const session = transport.getSession("chat")!;
830+
transport.dispose();
831+
const events: ChatTransportEvent[] = [];
832+
transport = createTransport(session, { watch: true, onEvent: (event) => events.push(event) });
833+
emptyRecoveredOutput = true;
834+
const resumed = await transport.reconnectToStream({ chatId: "chat" });
835+
if (!resumed) throw new Error("Expected a resumed stream");
836+
const result = readWatchedTurn(resumed);
837+
await vi.waitFor(() => expect(outputs.length).toBeGreaterThanOrEqual(2));
838+
emptyRecoveredOutput = false;
839+
resumeAfterStoppedCheckpoint = true;
840+
await expect(result).resolves.toBe("New response");
841+
expect(events.filter((event) => event.type === "stream-error")).toEqual([]);
842+
expect(inputSeq).toBe(13);
843+
});
844+
845+
it("uses the active-turn retry policy after recovery receives data", async () => {
846+
await hydrateBlockedSession("constructor");
847+
expect(
848+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "11", lastInEventId: "11" })
849+
).toBe(true);
850+
const events: ChatTransportEvent[] = [];
851+
transport.setOnEvent((event) => events.push(event));
852+
const resumed = await transport.reconnectToStream({ chatId: "chat" });
853+
if (!resumed) throw new Error("Expected a resumed stream");
854+
const reader = resumed.getReader();
855+
await vi.waitFor(() => expect(outputs).toHaveLength(2));
856+
emit([chunk(12, { type: "start", messageId: "new" })]);
857+
await expect(reader.read()).resolves.toMatchObject({ value: { type: "start" } });
858+
outputs.at(-1)!.end();
859+
await vi.waitFor(() => expect(outputs).toHaveLength(3));
860+
emit([...reply(12).slice(1), complete(17, 12)]);
861+
while (!(await reader.read()).done) {}
862+
expect(events.filter((event) => event.type === "stream-error")).toEqual([]);
863+
expect(transport.getSession("chat")?.isStreaming).toBe(false);
864+
expect(inputSeq).toBe(13);
865+
});
866+
867+
it("does not let a replaced recovery stream settle the new response", async () => {
868+
await hydrateBlockedSession("constructor");
869+
expect(
870+
transport.prepareTranscriptRecovery("chat")?.({ lastOutEventId: "11", lastInEventId: "11" })
871+
).toBe(true);
872+
const events: ChatTransportEvent[] = [];
873+
transport.setOnEvent((event) => events.push(event));
874+
const resumed = await transport.reconnectToStream({ chatId: "chat" });
875+
if (!resumed) throw new Error("Expected a resumed stream");
876+
const oldResult = readText(resumed);
877+
await vi.waitFor(() => expect(outputs).toHaveLength(2));
878+
const replacement = await send();
879+
await expect(oldResult).resolves.toBe("");
880+
expect(transport.getSession("chat")?.isStreaming).toBe(true);
881+
emit([...reply(12), complete(17, 13)]);
882+
await expect(readText(replacement)).resolves.toBe("New response");
883+
expect(events.filter((event) => event.type === "stream-error")).toEqual([]);
884+
expect(inputSeq).toBe(14);
885+
});
886+
754887
it.each(["constructor", "setSession"] as const)(
755888
"retains the abandoned-turn marker through %s hydration in watch mode",
756889
async (hydrate) => {

0 commit comments

Comments
 (0)