Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/chat-stop-successor-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@trigger.dev/sdk": patch
---

Keep new chat responses intact after Stop, including slow Stop acknowledgments and page reloads.
Sequence-free replies after Stop require a transcript reload before further messages.
Loading a fresh transcript through `useLoadTranscript` restores blocked sessions only after its saved input cursor covers the stopped turn.
Transcript recovery reports missing cursor evidence and empty output polls. An empty recovery poll keeps the accepted message available for reconnect.
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
},
"packages/trigger-sdk": {
"ignoreFiles": ["src/**/*-cjs.cts", "src/v3/index-browser.mts"],
"ignoreDependencies": ["ai-v7", "react"]
"ignoreDependencies": ["ai-v7"]
},
"docs": {
"ignoreFiles": ["style.css"]
Expand Down
4 changes: 4 additions & 0 deletions packages/trigger-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@
"@ai-sdk/provider": "3.0.8",
"@arethetypeswrong/cli": "^0.18.5",
"@types/react": "^19.2.14",
"@types/react-dom": "19.2.3",
"ai": "^6.0.116",
"ai-v7": "npm:ai@7.0.0-canary.159",
"jsdom": "30.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"rimraf": "^6.0.1",
"tshy": "^4.1.3",
"tsx": "4.17.0",
Expand Down
24 changes: 16 additions & 8 deletions packages/trigger-sdk/src/v3/chat-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type InferChatUIMessage,
} from "./ai-shared.js";
import type { UIMessage, ChatRequestOptions } from "ai";
import type { TranscriptCursors } from "./transcriptStorage.js";

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

Expand All @@ -65,6 +66,8 @@ export type UseLoadTranscriptOptions = {
* the loaded transcript, so the live subscription opens just past the
* persisted history instead of replaying it. Only applies once the
* transport knows the session (from `sessions` or after `start`).
* A blocked session requires a newer output cursor and an input cursor that covers the stopped input.
* A stale result leaves sends blocked.
*/
transport?: TriggerChatTransport;
/** Page size passed to the action. */
Expand All @@ -77,12 +80,13 @@ export type UseLoadTranscriptOptions = {
* history. Applied to the session now if it exists, otherwise held by the
* transport until the session is created, so a load that resolves before the
* session exists still moves the cursor. A no-op when the transcript carries
* no cursor. Returns whether a cursor was provided.
* no cursor.
* Returns whether the cursor was accepted.
*/
export function seedTranscriptCursor(
transport: Pick<TriggerChatTransport, "seedResumeCursor">,
chatId: string,
cursors: { lastOutEventId?: string } | undefined
cursors: TranscriptCursors | undefined
): boolean {
const lastEventId = cursors?.lastOutEventId;
if (!lastEventId) return false;
Expand Down Expand Up @@ -130,8 +134,7 @@ export function useLoadTranscript<TUIMessage extends UIMessage = UIMessage>(

const loadRef = useRef(load);
loadRef.current = load;
const transportRef = useRef(options?.transport);
transportRef.current = options?.transport;
const transport = options?.transport;
const limit = options?.limit;

useEffect(() => {
Expand All @@ -140,13 +143,18 @@ export function useLoadTranscript<TUIMessage extends UIMessage = UIMessage>(
return;
}
let cancelled = false;
const completeRecovery = transport?.prepareTranscriptRecovery(chatId);
setState({ chatId, messages: [], isLoading: true, error: undefined, nextCursor: undefined });
loadRef
.current({ chatId, ...(limit !== undefined ? { limit } : {}) })
.then((result) => {
if (cancelled) return;
if (transportRef.current) {
seedTranscriptCursor(transportRef.current, chatId, result.cursors);
if (completeRecovery) {
if (!completeRecovery(result.cursors)) {
throw new Error("The loaded transcript is not current. Reload the chat again.");
}
} else if (transport) {
seedTranscriptCursor(transport, chatId, result.cursors);
}
setState({
chatId,
Expand All @@ -164,7 +172,7 @@ export function useLoadTranscript<TUIMessage extends UIMessage = UIMessage>(
return () => {
cancelled = true;
};
}, [chatId, limit]);
}, [chatId, limit, transport]);

return {
messages: state.chatId === chatId ? state.messages : [],
Expand Down
Loading
Loading