@tanstack/ai-persistence 0.6.7 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { UIMessage } from '@tanstack/ai';
1
+ import { TerminalRunStatus, UIMessage } from '@tanstack/ai';
2
2
  import { AIPersistence, ChatTranscriptStores } from './types.js';
3
3
  /**
4
4
  * The JSON body `reconstructChat` returns and a server-authoritative client
@@ -31,10 +31,28 @@ export interface ReconstructedChat {
31
31
  truncated: true;
32
32
  cursor: string;
33
33
  };
34
+ /**
35
+ * The thread's finished runs, ascending by `startedAt`. Set only when
36
+ * {@link ReconstructChatOptions.includeRuns} is `true` and the `runs` store
37
+ * implements `listByThread`. Each assistant message of a listed run also
38
+ * gets the timings on `message.metadata.tanstack.run`.
39
+ */
40
+ runs?: Array<{
41
+ runId: string;
42
+ status: TerminalRunStatus;
43
+ startedAt: number;
44
+ finishedAt?: number;
45
+ }>;
34
46
  }
35
47
  export interface ReconstructChatOptions {
36
48
  /** Query parameter carrying the thread id. Defaults to `threadId`. */
37
49
  param?: string;
50
+ /**
51
+ * Add the thread's finished runs, with `startedAt` and `finishedAt`, to the
52
+ * response as `runs`. Needs a `runs` store that implements `listByThread`.
53
+ * Default: `false`.
54
+ */
55
+ includeRuns?: boolean;
38
56
  /**
39
57
  * Authorize access to the requested thread before loading history.
40
58
  *
@@ -1,6 +1,6 @@
1
1
  import { validateReconstructChatStores } from "./types.js";
2
2
  import { storedSubagentInfo } from "./subagent-runs.js";
3
- import { modelMessagesToUIMessages } from "@tanstack/ai";
3
+ import { isTerminalRunStatus, modelMessagesToUIMessages } from "@tanstack/ai";
4
4
  //#region src/reconstruct.ts
5
5
  var MAX_PAGE_SIZE = 500;
6
6
  /**
@@ -76,14 +76,23 @@ async function reconstructChat(persistence, request, options) {
76
76
  pageSize,
77
77
  before
78
78
  }) : windowFromMessagePage(stored, pageSize);
79
+ const messages = await attachSubagentCards(transcript.messages, persistence.stores.runs, messageStore, threadId, pending);
80
+ const runStore = persistence.stores.runs;
81
+ const runs = options?.includeRuns && threadId && runStore?.listByThread ? (await runStore.listByThread(threadId)).flatMap((run) => isTerminalRunStatus(run.status) ? [{
82
+ runId: run.runId,
83
+ status: run.status,
84
+ startedAt: run.startedAt,
85
+ ...run.finishedAt !== void 0 && { finishedAt: run.finishedAt }
86
+ }] : []) : void 0;
79
87
  const body = {
80
- messages: await attachSubagentCards(transcript.messages, persistence.stores.runs, messageStore, threadId, pending),
88
+ messages: runs ? stampRunTimings(messages, runs) : messages,
81
89
  activeRun: active ? { runId: active.runId } : null,
82
90
  interrupts: firstPending ? {
83
91
  runId: firstPending.runId,
84
92
  pending: pending.map((record) => record.payload)
85
93
  } : null,
86
- ..."page" in transcript ? { page: transcript.page } : {}
94
+ ..."page" in transcript ? { page: transcript.page } : {},
95
+ ...runs ? { runs } : {}
87
96
  };
88
97
  return new Response(JSON.stringify(body), { headers: {
89
98
  "content-type": "application/json",
@@ -98,6 +107,33 @@ function messageRunId(message) {
98
107
  const runId = tanstack.runId;
99
108
  return typeof runId === "string" && runId !== "" ? runId : void 0;
100
109
  }
110
+ /**
111
+ * Write each finished run's timings to `metadata.tanstack.run` on the
112
+ * assistant messages of that run, so a client reads them from the message.
113
+ */
114
+ function stampRunTimings(messages, runs) {
115
+ const byId = new Map(runs.map((run) => [run.runId, run]));
116
+ return messages.map((message) => {
117
+ const tanstack = message.metadata?.tanstack;
118
+ const runId = tanstack?.run?.id;
119
+ const run = message.role === "assistant" && typeof runId === "string" ? byId.get(runId) : void 0;
120
+ if (!run) return message;
121
+ return {
122
+ ...message,
123
+ metadata: {
124
+ ...message.metadata,
125
+ tanstack: {
126
+ ...tanstack,
127
+ run: {
128
+ id: run.runId,
129
+ startedAt: run.startedAt,
130
+ ...run.finishedAt !== void 0 && { finishedAt: run.finishedAt }
131
+ }
132
+ }
133
+ }
134
+ };
135
+ });
136
+ }
101
137
  /** Rebuild one child card from its run record and stored transcript. */
102
138
  async function childCard(child, runs, messageStore, pending, depth) {
103
139
  const subagentRunId = child.subagentRunId ?? child.runId;
@@ -1 +1 @@
1
- {"version":3,"file":"reconstruct.js","names":[],"sources":["../../src/reconstruct.ts"],"sourcesContent":["import { modelMessagesToUIMessages } from '@tanstack/ai'\nimport type {\n ModelMessage,\n RunRecord,\n SubagentPart,\n UIMessage,\n} from '@tanstack/ai'\nimport { storedSubagentInfo } from './subagent-runs'\nimport { validateReconstructChatStores } from './types'\nimport type {\n AIPersistence,\n ChatTranscriptStores,\n InterruptRecord,\n MessagePage,\n MessageStore,\n} from './types'\n\nconst MAX_PAGE_SIZE = 500\n\n/**\n * The JSON body `reconstructChat` returns and a server-authoritative client\n * hydrates from on mount.\n *\n * `messages` is the stored transcript as UI messages (ready to paint).\n * `activeRun` is a cursor to a run still generating for the thread, or `null` —\n * resolved from the STABLE thread id via `stores.runs.findActiveRun`, so the\n * client learns \"there is a live run to tail\" without ever handling a run id.\n * `interrupts` is the thread's pending human-in-the-loop interrupts (tool\n * approvals, client-tool/generic waits) and the run they paused, or `null` —\n * so a reload (or another device) re-prompts the approval from the SERVER, not\n * from client storage. Resolved via `stores.interrupts.listPending`.\n * `page` is set only when the GET included a valid `limit`. `truncated` is true\n * when older UI messages exist. `cursor` is the opaque `before` token for the\n * next older window.\n */\nexport interface ReconstructedChat {\n messages: Array<UIMessage>\n activeRun: { runId: string } | null\n interrupts: {\n runId: string\n pending: Array<Record<string, unknown>>\n } | null\n page?: { truncated: false } | { truncated: true; cursor: string }\n}\n\nexport interface ReconstructChatOptions {\n /** Query parameter carrying the thread id. Defaults to `threadId`. */\n param?: string\n /**\n * Authorize access to the requested thread before loading history.\n *\n * ⚠️ Without this, any caller who knows or guesses `?threadId=` receives the\n * full transcript. Multi-user / multi-tenant deployments **must** supply\n * an authorization check (session → owned threads) or resolve a validated\n * thread id in the route and pass it via a custom `param` that only your\n * server sets.\n *\n * Return:\n * - `true` to allow the load\n * - `false` for a default `403` response\n * - a `Response` to return as-is (e.g. `401` with a body)\n */\n authorize?: (\n threadId: string,\n request: Request,\n ) => boolean | Response | Promise<boolean | Response>\n}\n\n/**\n * Build the JSON `Response` a server-authoritative client hydrates from on load\n * (see the client-persistence guide). Reads the thread id from the request query\n * (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }`\n * ({@link ReconstructedChat}):\n *\n * - `messages` — the stored transcript as UI messages.\n * - `activeRun` — `{ runId }` if a run is still generating for the thread (so the\n * client tails it via the durability stream), else `null`. Resolved via the\n * required `stores.runs.findActiveRun`; `null` when the `runs` store is absent.\n * - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop\n * interrupts (a paused approval / wait) and the run they paused, else `null`, so\n * a reload re-prompts the decision from the server. Resolved via the optional\n * `stores.interrupts.listPending`; `null` when that store is absent.\n *\n * Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns\n * the newest window of UI messages plus `page`. `?before=` walks to an older\n * window. Invalid `limit` (`0`, negative, NaN) is ignored and the full\n * transcript is returned. `activeRun` and `interrupts` are never paged.\n *\n * Requires `stores.messages`. Returns an empty transcript with no active run\n * and no interrupts when the thread id is missing or the thread is unknown, so\n * the caller never has to special-case a first load.\n *\n * This helper does **not** enforce tenancy by itself. Pass\n * {@link ReconstructChatOptions.authorize} (or wrap the call in your own\n * session gate) before exposing it on a public route.\n *\n * ```ts\n * export async function GET(request: Request) {\n * return reconstructChat(persistence, request, {\n * authorize: async (threadId, req) => {\n * const userId = await getSessionUserId(req)\n * return userId != null && (await userOwnsThread(userId, threadId))\n * },\n * })\n * }\n * ```\n */\nexport async function reconstructChat(\n persistence: AIPersistence<ChatTranscriptStores>,\n request: Request,\n options?: ReconstructChatOptions,\n): Promise<Response> {\n validateReconstructChatStores(persistence)\n const messageStore = persistence.stores.messages\n if (!messageStore) {\n // validateReconstructChatStores already throws; this narrows for TypeScript.\n throw new Error('reconstructChat requires stores.messages.')\n }\n\n const requestUrl = new URL(request.url)\n const param = options?.param ?? 'threadId'\n const threadId = requestUrl.searchParams.get(param) ?? ''\n const pageSize = parsePageSize(requestUrl.searchParams.get('limit'))\n const before = parseBefore(requestUrl.searchParams.get('before'))\n\n if (threadId && options?.authorize) {\n const decision = await options.authorize(threadId, request)\n if (decision instanceof Response) {\n return decision\n }\n if (!decision) {\n return new Response(JSON.stringify({ error: 'Forbidden' }), {\n status: 403,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n })\n }\n }\n\n // Resolve the active run BEFORE reading the transcript. `withPersistence`\n // persists the final transcript BEFORE marking a run complete, so observing\n // \"no active run\" here guarantees the transcript read below is the FINAL one.\n // Reading them in the other order opens a finish-window race: a fast run that\n // completes between the two reads would return a stale streaming snapshot with\n // `activeRun: null`, leaving the client stuck on the partial (no run to tail).\n const active = threadId\n ? await persistence.stores.runs?.findActiveRun(threadId)\n : null\n const stored =\n threadId === ''\n ? []\n : pageSize === undefined\n ? await messageStore.loadThread(threadId)\n : await messageStore.loadThread(threadId, {\n limit: pageSize + 1,\n ...(before === undefined ? {} : { before }),\n })\n // Pending interrupts for the thread, so a reload re-prompts the approval from\n // the server. Each stored `payload` is the full interrupt descriptor the\n // client hydrates; they share the run they paused.\n const pending = threadId\n ? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])\n : []\n const firstPending = pending[0]\n const isPaging = pageSize !== undefined && threadId !== ''\n const transcript = !isPaging\n ? {\n messages: modelMessagesToUIMessages(threadMessages(stored)),\n }\n : Array.isArray(stored)\n ? await windowFromArray({\n stored,\n messageStore,\n threadId,\n pageSize,\n before,\n })\n : windowFromMessagePage(stored, pageSize)\n const messages = await attachSubagentCards(\n transcript.messages,\n persistence.stores.runs,\n messageStore,\n threadId,\n pending,\n )\n const body: ReconstructedChat = {\n messages,\n activeRun: active ? { runId: active.runId } : null,\n interrupts: firstPending\n ? {\n runId: firstPending.runId,\n pending: pending.map((record) => record.payload),\n }\n : null,\n ...('page' in transcript ? { page: transcript.page } : {}),\n }\n return new Response(JSON.stringify(body), {\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n })\n}\n\nfunction messageRunId(message: UIMessage) {\n const metadata = message.metadata\n if (!metadata || typeof metadata !== 'object') return\n const tanstack = metadata.tanstack\n if (!tanstack || typeof tanstack !== 'object') return\n const runId = (tanstack as { runId?: unknown }).runId\n return typeof runId === 'string' && runId !== '' ? runId : undefined\n}\n\ntype Runs = NonNullable<ChatTranscriptStores['runs']>\n\n/** Rebuild one child card from its run record and stored transcript. */\nasync function childCard(\n child: RunRecord,\n runs: Runs,\n messageStore: MessageStore,\n pending: ReadonlyArray<InterruptRecord>,\n depth: number,\n): Promise<SubagentPart> {\n const subagentRunId = child.subagentRunId ?? child.runId\n const stored = await messageStore.loadThread(child.threadId)\n const info = storedSubagentInfo(stored)\n const messages = modelMessagesToUIMessages(\n stored.filter(\n (message) => storedSubagentInfo([message])?.placeholder !== true,\n ),\n )\n const nested =\n runs.listByParentRun && depth < 8\n ? await runs.listByParentRun(subagentRunId)\n : []\n if (nested.length > 0) {\n const cards = await Promise.all(\n nested.map((run) =>\n childCard(run, runs, messageStore, pending, depth + 1),\n ),\n )\n const last = messages.findLastIndex((m) => m.role === 'assistant')\n if (last === -1) {\n messages.push({\n id: `child-cards:${subagentRunId}`,\n role: 'assistant',\n parts: cards,\n })\n } else {\n const host = messages[last]\n if (host) messages[last] = { ...host, parts: [...host.parts, ...cards] }\n }\n }\n const failed = child.status === 'failed' || child.status === 'aborted'\n const interruptIds = pending\n .filter((record) => record.payload.subagentRunId === subagentRunId)\n .map((record) => record.interruptId)\n return {\n type: 'subagent',\n subagent: {\n id: subagentRunId,\n name: child.name ?? info?.name ?? 'subagent',\n status: failed\n ? 'error'\n : child.status === 'running'\n ? 'running'\n : child.status === 'interrupted'\n ? 'suspended'\n : 'finished',\n ...(child.parentRunId !== undefined && {\n parentRunId: child.parentRunId,\n }),\n ...(info?.parentToolCallId !== undefined && {\n parentToolCallId: info.parentToolCallId,\n }),\n ...(interruptIds.length > 0 && { interruptIds }),\n ...(info?.metadata !== undefined && { metadata: info.metadata }),\n messages,\n ...(failed && child.error ? { error: child.error } : {}),\n },\n }\n}\n\n/**\n * Put stored subagent cards back on the transcript. A routed child sits on\n * the parent assistant message of its run. A child that a tool call started\n * sits on the message that holds that tool call.\n */\nasync function attachSubagentCards(\n messages: Array<UIMessage>,\n runs: ChatTranscriptStores['runs'],\n messageStore: MessageStore,\n threadId: string,\n pending: ReadonlyArray<InterruptRecord>,\n) {\n if (!runs?.listByParentRun) return messages\n const parentRunIds = new Set<string>()\n for (const message of messages) {\n const runId = messageRunId(message)\n if (runId) parentRunIds.add(runId)\n }\n const hasToolCalls = messages.some((message) =>\n message.parts.some((part) => part.type === 'tool-call'),\n )\n if (hasToolCalls && runs.listByThread && threadId !== '') {\n for (const run of await runs.listByThread(threadId)) {\n parentRunIds.add(run.runId)\n }\n }\n\n const cardsByRun = new Map<string, Array<SubagentPart>>()\n const cardsByToolCall = new Map<string, Array<SubagentPart>>()\n for (const runId of parentRunIds) {\n for (const child of await runs.listByParentRun(runId)) {\n const card = await childCard(child, runs, messageStore, pending, 0)\n const toolCallId = card.subagent.parentToolCallId\n const target = toolCallId === undefined ? cardsByRun : cardsByToolCall\n const key = toolCallId ?? runId\n target.set(key, [...(target.get(key) ?? []), card])\n }\n }\n if (cardsByRun.size === 0 && cardsByToolCall.size === 0) return messages\n\n return messages.map((message) => {\n if (message.role !== 'assistant') return message\n const runId = messageRunId(message)\n const routed = runId !== undefined ? cardsByRun.get(runId) : undefined\n const started = message.parts.flatMap((part) =>\n part.type === 'tool-call' ? (cardsByToolCall.get(part.id) ?? []) : [],\n )\n if (!routed && started.length === 0) return message\n // A routed parent message holds only the children's text. The cards\n // replace it.\n const parts = routed\n ? message.parts.filter((part) => part.type !== 'text')\n : message.parts\n return {\n ...message,\n parts: [...(routed ?? []), ...parts, ...started],\n }\n })\n}\n\nfunction parsePageSize(raw: string | null) {\n if (raw == null) return\n const pageSize = Number(raw)\n const isValidPageSize = Number.isInteger(pageSize) && pageSize > 0\n if (!isValidPageSize) return\n return Math.min(pageSize, MAX_PAGE_SIZE)\n}\n\nfunction parseBefore(raw: string | null) {\n if (raw == null || raw === '') return\n return raw\n}\n\nfunction threadMessages(\n loaded: Array<ModelMessage> | MessagePage,\n): Array<ModelMessage> {\n return Array.isArray(loaded) ? loaded : loaded.messages\n}\n\nfunction completePage() {\n return { truncated: false as const }\n}\n\nfunction truncatedPage(cursor: string) {\n return { truncated: true as const, cursor }\n}\n\nfunction pageFromCursor(cursor: string | undefined) {\n if (cursor === undefined || cursor === '') {\n return completePage()\n }\n return truncatedPage(cursor)\n}\n\nfunction newestUiWindow(messages: Array<UIMessage>, pageSize: number) {\n const truncated = messages.length > pageSize\n if (!truncated) {\n return { messages, page: completePage() }\n }\n const uiWindow = messages.slice(messages.length - pageSize)\n return {\n messages: uiWindow,\n page: pageFromCursor(uiWindow[0]?.id),\n }\n}\n\nfunction uiBeforeCursor(messages: Array<UIMessage>, cursor: string) {\n const cut = messages.findIndex((message) => message.id === cursor)\n if (cut === -1) return\n return messages.slice(0, cut)\n}\n\nfunction windowFromMessagePage(page: MessagePage, pageSize: number) {\n const ui = modelMessagesToUIMessages(page.messages)\n if (ui.length > pageSize) {\n // Extra slice uses a library-minted cursor. Keeping the adapter cursor\n // after dropping the oldest row would skip that row on the next GET.\n return newestUiWindow(ui, pageSize)\n }\n if (page.truncated) {\n return {\n messages: ui,\n page: pageFromCursor(page.cursor),\n }\n }\n return { messages: ui, page: completePage() }\n}\n\nasync function windowFromArray(input: {\n stored: Array<ModelMessage>\n messageStore: MessageStore\n threadId: string\n pageSize: number\n before: string | undefined\n}) {\n const { stored, messageStore, threadId, pageSize, before } = input\n if (before === undefined) {\n return newestUiWindow(modelMessagesToUIMessages(stored), pageSize)\n }\n // Array adapters own no cursor. Apply `before` to the full transcript so an\n // adapter that ignored the hint cannot return the same newest page forever.\n const full = threadMessages(await messageStore.loadThread(threadId))\n const older = uiBeforeCursor(modelMessagesToUIMessages(full), before)\n if (older === undefined) {\n return { messages: [], page: truncatedPage(before) }\n }\n return newestUiWindow(older, pageSize)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"reconstruct.js","names":[],"sources":["../../src/reconstruct.ts"],"sourcesContent":["import { isTerminalRunStatus, modelMessagesToUIMessages } from '@tanstack/ai'\nimport type {\n ModelMessage,\n RunRecord,\n SubagentPart,\n TerminalRunStatus,\n UIMessage,\n} from '@tanstack/ai'\nimport { storedSubagentInfo } from './subagent-runs'\nimport { validateReconstructChatStores } from './types'\nimport type {\n AIPersistence,\n ChatTranscriptStores,\n InterruptRecord,\n MessagePage,\n MessageStore,\n} from './types'\n\nconst MAX_PAGE_SIZE = 500\n\n/**\n * The JSON body `reconstructChat` returns and a server-authoritative client\n * hydrates from on mount.\n *\n * `messages` is the stored transcript as UI messages (ready to paint).\n * `activeRun` is a cursor to a run still generating for the thread, or `null` —\n * resolved from the STABLE thread id via `stores.runs.findActiveRun`, so the\n * client learns \"there is a live run to tail\" without ever handling a run id.\n * `interrupts` is the thread's pending human-in-the-loop interrupts (tool\n * approvals, client-tool/generic waits) and the run they paused, or `null` —\n * so a reload (or another device) re-prompts the approval from the SERVER, not\n * from client storage. Resolved via `stores.interrupts.listPending`.\n * `page` is set only when the GET included a valid `limit`. `truncated` is true\n * when older UI messages exist. `cursor` is the opaque `before` token for the\n * next older window.\n */\nexport interface ReconstructedChat {\n messages: Array<UIMessage>\n activeRun: { runId: string } | null\n interrupts: {\n runId: string\n pending: Array<Record<string, unknown>>\n } | null\n page?: { truncated: false } | { truncated: true; cursor: string }\n /**\n * The thread's finished runs, ascending by `startedAt`. Set only when\n * {@link ReconstructChatOptions.includeRuns} is `true` and the `runs` store\n * implements `listByThread`. Each assistant message of a listed run also\n * gets the timings on `message.metadata.tanstack.run`.\n */\n runs?: Array<{\n runId: string\n status: TerminalRunStatus\n startedAt: number\n finishedAt?: number\n }>\n}\n\nexport interface ReconstructChatOptions {\n /** Query parameter carrying the thread id. Defaults to `threadId`. */\n param?: string\n /**\n * Add the thread's finished runs, with `startedAt` and `finishedAt`, to the\n * response as `runs`. Needs a `runs` store that implements `listByThread`.\n * Default: `false`.\n */\n includeRuns?: boolean\n /**\n * Authorize access to the requested thread before loading history.\n *\n * ⚠️ Without this, any caller who knows or guesses `?threadId=` receives the\n * full transcript. Multi-user / multi-tenant deployments **must** supply\n * an authorization check (session → owned threads) or resolve a validated\n * thread id in the route and pass it via a custom `param` that only your\n * server sets.\n *\n * Return:\n * - `true` to allow the load\n * - `false` for a default `403` response\n * - a `Response` to return as-is (e.g. `401` with a body)\n */\n authorize?: (\n threadId: string,\n request: Request,\n ) => boolean | Response | Promise<boolean | Response>\n}\n\n/**\n * Build the JSON `Response` a server-authoritative client hydrates from on load\n * (see the client-persistence guide). Reads the thread id from the request query\n * (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }`\n * ({@link ReconstructedChat}):\n *\n * - `messages` — the stored transcript as UI messages.\n * - `activeRun` — `{ runId }` if a run is still generating for the thread (so the\n * client tails it via the durability stream), else `null`. Resolved via the\n * required `stores.runs.findActiveRun`; `null` when the `runs` store is absent.\n * - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop\n * interrupts (a paused approval / wait) and the run they paused, else `null`, so\n * a reload re-prompts the decision from the server. Resolved via the optional\n * `stores.interrupts.listPending`; `null` when that store is absent.\n *\n * Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns\n * the newest window of UI messages plus `page`. `?before=` walks to an older\n * window. Invalid `limit` (`0`, negative, NaN) is ignored and the full\n * transcript is returned. `activeRun` and `interrupts` are never paged.\n *\n * Requires `stores.messages`. Returns an empty transcript with no active run\n * and no interrupts when the thread id is missing or the thread is unknown, so\n * the caller never has to special-case a first load.\n *\n * This helper does **not** enforce tenancy by itself. Pass\n * {@link ReconstructChatOptions.authorize} (or wrap the call in your own\n * session gate) before exposing it on a public route.\n *\n * ```ts\n * export async function GET(request: Request) {\n * return reconstructChat(persistence, request, {\n * authorize: async (threadId, req) => {\n * const userId = await getSessionUserId(req)\n * return userId != null && (await userOwnsThread(userId, threadId))\n * },\n * })\n * }\n * ```\n */\nexport async function reconstructChat(\n persistence: AIPersistence<ChatTranscriptStores>,\n request: Request,\n options?: ReconstructChatOptions,\n): Promise<Response> {\n validateReconstructChatStores(persistence)\n const messageStore = persistence.stores.messages\n if (!messageStore) {\n // validateReconstructChatStores already throws; this narrows for TypeScript.\n throw new Error('reconstructChat requires stores.messages.')\n }\n\n const requestUrl = new URL(request.url)\n const param = options?.param ?? 'threadId'\n const threadId = requestUrl.searchParams.get(param) ?? ''\n const pageSize = parsePageSize(requestUrl.searchParams.get('limit'))\n const before = parseBefore(requestUrl.searchParams.get('before'))\n\n if (threadId && options?.authorize) {\n const decision = await options.authorize(threadId, request)\n if (decision instanceof Response) {\n return decision\n }\n if (!decision) {\n return new Response(JSON.stringify({ error: 'Forbidden' }), {\n status: 403,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n })\n }\n }\n\n // Resolve the active run BEFORE reading the transcript. `withPersistence`\n // persists the final transcript BEFORE marking a run complete, so observing\n // \"no active run\" here guarantees the transcript read below is the FINAL one.\n // Reading them in the other order opens a finish-window race: a fast run that\n // completes between the two reads would return a stale streaming snapshot with\n // `activeRun: null`, leaving the client stuck on the partial (no run to tail).\n const active = threadId\n ? await persistence.stores.runs?.findActiveRun(threadId)\n : null\n const stored =\n threadId === ''\n ? []\n : pageSize === undefined\n ? await messageStore.loadThread(threadId)\n : await messageStore.loadThread(threadId, {\n limit: pageSize + 1,\n ...(before === undefined ? {} : { before }),\n })\n // Pending interrupts for the thread, so a reload re-prompts the approval from\n // the server. Each stored `payload` is the full interrupt descriptor the\n // client hydrates; they share the run they paused.\n const pending = threadId\n ? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])\n : []\n const firstPending = pending[0]\n const isPaging = pageSize !== undefined && threadId !== ''\n const transcript = !isPaging\n ? {\n messages: modelMessagesToUIMessages(threadMessages(stored)),\n }\n : Array.isArray(stored)\n ? await windowFromArray({\n stored,\n messageStore,\n threadId,\n pageSize,\n before,\n })\n : windowFromMessagePage(stored, pageSize)\n const messages = await attachSubagentCards(\n transcript.messages,\n persistence.stores.runs,\n messageStore,\n threadId,\n pending,\n )\n const runStore = persistence.stores.runs\n const runs =\n options?.includeRuns && threadId && runStore?.listByThread\n ? (await runStore.listByThread(threadId)).flatMap((run) =>\n isTerminalRunStatus(run.status)\n ? [\n {\n runId: run.runId,\n status: run.status,\n startedAt: run.startedAt,\n ...(run.finishedAt !== undefined && {\n finishedAt: run.finishedAt,\n }),\n },\n ]\n : [],\n )\n : undefined\n const body: ReconstructedChat = {\n messages: runs ? stampRunTimings(messages, runs) : messages,\n activeRun: active ? { runId: active.runId } : null,\n interrupts: firstPending\n ? {\n runId: firstPending.runId,\n pending: pending.map((record) => record.payload),\n }\n : null,\n ...('page' in transcript ? { page: transcript.page } : {}),\n ...(runs ? { runs } : {}),\n }\n return new Response(JSON.stringify(body), {\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n })\n}\n\nfunction messageRunId(message: UIMessage) {\n const metadata = message.metadata\n if (!metadata || typeof metadata !== 'object') return\n const tanstack = metadata.tanstack\n if (!tanstack || typeof tanstack !== 'object') return\n const runId = (tanstack as { runId?: unknown }).runId\n return typeof runId === 'string' && runId !== '' ? runId : undefined\n}\n\n/**\n * Write each finished run's timings to `metadata.tanstack.run` on the\n * assistant messages of that run, so a client reads them from the message.\n */\nfunction stampRunTimings(\n messages: Array<UIMessage>,\n runs: NonNullable<ReconstructedChat['runs']>,\n): Array<UIMessage> {\n const byId = new Map(runs.map((run) => [run.runId, run]))\n return messages.map((message) => {\n const tanstack = message.metadata?.tanstack\n const runId: unknown = tanstack?.run?.id\n const run =\n message.role === 'assistant' && typeof runId === 'string'\n ? byId.get(runId)\n : undefined\n if (!run) return message\n return {\n ...message,\n metadata: {\n ...message.metadata,\n tanstack: {\n ...tanstack,\n run: {\n id: run.runId,\n startedAt: run.startedAt,\n ...(run.finishedAt !== undefined && { finishedAt: run.finishedAt }),\n },\n },\n },\n }\n })\n}\n\ntype Runs = NonNullable<ChatTranscriptStores['runs']>\n\n/** Rebuild one child card from its run record and stored transcript. */\nasync function childCard(\n child: RunRecord,\n runs: Runs,\n messageStore: MessageStore,\n pending: ReadonlyArray<InterruptRecord>,\n depth: number,\n): Promise<SubagentPart> {\n const subagentRunId = child.subagentRunId ?? child.runId\n const stored = await messageStore.loadThread(child.threadId)\n const info = storedSubagentInfo(stored)\n const messages = modelMessagesToUIMessages(\n stored.filter(\n (message) => storedSubagentInfo([message])?.placeholder !== true,\n ),\n )\n const nested =\n runs.listByParentRun && depth < 8\n ? await runs.listByParentRun(subagentRunId)\n : []\n if (nested.length > 0) {\n const cards = await Promise.all(\n nested.map((run) =>\n childCard(run, runs, messageStore, pending, depth + 1),\n ),\n )\n const last = messages.findLastIndex((m) => m.role === 'assistant')\n if (last === -1) {\n messages.push({\n id: `child-cards:${subagentRunId}`,\n role: 'assistant',\n parts: cards,\n })\n } else {\n const host = messages[last]\n if (host) messages[last] = { ...host, parts: [...host.parts, ...cards] }\n }\n }\n const failed = child.status === 'failed' || child.status === 'aborted'\n const interruptIds = pending\n .filter((record) => record.payload.subagentRunId === subagentRunId)\n .map((record) => record.interruptId)\n return {\n type: 'subagent',\n subagent: {\n id: subagentRunId,\n name: child.name ?? info?.name ?? 'subagent',\n status: failed\n ? 'error'\n : child.status === 'running'\n ? 'running'\n : child.status === 'interrupted'\n ? 'suspended'\n : 'finished',\n ...(child.parentRunId !== undefined && {\n parentRunId: child.parentRunId,\n }),\n ...(info?.parentToolCallId !== undefined && {\n parentToolCallId: info.parentToolCallId,\n }),\n ...(interruptIds.length > 0 && { interruptIds }),\n ...(info?.metadata !== undefined && { metadata: info.metadata }),\n messages,\n ...(failed && child.error ? { error: child.error } : {}),\n },\n }\n}\n\n/**\n * Put stored subagent cards back on the transcript. A routed child sits on\n * the parent assistant message of its run. A child that a tool call started\n * sits on the message that holds that tool call.\n */\nasync function attachSubagentCards(\n messages: Array<UIMessage>,\n runs: ChatTranscriptStores['runs'],\n messageStore: MessageStore,\n threadId: string,\n pending: ReadonlyArray<InterruptRecord>,\n) {\n if (!runs?.listByParentRun) return messages\n const parentRunIds = new Set<string>()\n for (const message of messages) {\n const runId = messageRunId(message)\n if (runId) parentRunIds.add(runId)\n }\n const hasToolCalls = messages.some((message) =>\n message.parts.some((part) => part.type === 'tool-call'),\n )\n if (hasToolCalls && runs.listByThread && threadId !== '') {\n for (const run of await runs.listByThread(threadId)) {\n parentRunIds.add(run.runId)\n }\n }\n\n const cardsByRun = new Map<string, Array<SubagentPart>>()\n const cardsByToolCall = new Map<string, Array<SubagentPart>>()\n for (const runId of parentRunIds) {\n for (const child of await runs.listByParentRun(runId)) {\n const card = await childCard(child, runs, messageStore, pending, 0)\n const toolCallId = card.subagent.parentToolCallId\n const target = toolCallId === undefined ? cardsByRun : cardsByToolCall\n const key = toolCallId ?? runId\n target.set(key, [...(target.get(key) ?? []), card])\n }\n }\n if (cardsByRun.size === 0 && cardsByToolCall.size === 0) return messages\n\n return messages.map((message) => {\n if (message.role !== 'assistant') return message\n const runId = messageRunId(message)\n const routed = runId !== undefined ? cardsByRun.get(runId) : undefined\n const started = message.parts.flatMap((part) =>\n part.type === 'tool-call' ? (cardsByToolCall.get(part.id) ?? []) : [],\n )\n if (!routed && started.length === 0) return message\n // A routed parent message holds only the children's text. The cards\n // replace it.\n const parts = routed\n ? message.parts.filter((part) => part.type !== 'text')\n : message.parts\n return {\n ...message,\n parts: [...(routed ?? []), ...parts, ...started],\n }\n })\n}\n\nfunction parsePageSize(raw: string | null) {\n if (raw == null) return\n const pageSize = Number(raw)\n const isValidPageSize = Number.isInteger(pageSize) && pageSize > 0\n if (!isValidPageSize) return\n return Math.min(pageSize, MAX_PAGE_SIZE)\n}\n\nfunction parseBefore(raw: string | null) {\n if (raw == null || raw === '') return\n return raw\n}\n\nfunction threadMessages(\n loaded: Array<ModelMessage> | MessagePage,\n): Array<ModelMessage> {\n return Array.isArray(loaded) ? loaded : loaded.messages\n}\n\nfunction completePage() {\n return { truncated: false as const }\n}\n\nfunction truncatedPage(cursor: string) {\n return { truncated: true as const, cursor }\n}\n\nfunction pageFromCursor(cursor: string | undefined) {\n if (cursor === undefined || cursor === '') {\n return completePage()\n }\n return truncatedPage(cursor)\n}\n\nfunction newestUiWindow(messages: Array<UIMessage>, pageSize: number) {\n const truncated = messages.length > pageSize\n if (!truncated) {\n return { messages, page: completePage() }\n }\n const uiWindow = messages.slice(messages.length - pageSize)\n return {\n messages: uiWindow,\n page: pageFromCursor(uiWindow[0]?.id),\n }\n}\n\nfunction uiBeforeCursor(messages: Array<UIMessage>, cursor: string) {\n const cut = messages.findIndex((message) => message.id === cursor)\n if (cut === -1) return\n return messages.slice(0, cut)\n}\n\nfunction windowFromMessagePage(page: MessagePage, pageSize: number) {\n const ui = modelMessagesToUIMessages(page.messages)\n if (ui.length > pageSize) {\n // Extra slice uses a library-minted cursor. Keeping the adapter cursor\n // after dropping the oldest row would skip that row on the next GET.\n return newestUiWindow(ui, pageSize)\n }\n if (page.truncated) {\n return {\n messages: ui,\n page: pageFromCursor(page.cursor),\n }\n }\n return { messages: ui, page: completePage() }\n}\n\nasync function windowFromArray(input: {\n stored: Array<ModelMessage>\n messageStore: MessageStore\n threadId: string\n pageSize: number\n before: string | undefined\n}) {\n const { stored, messageStore, threadId, pageSize, before } = input\n if (before === undefined) {\n return newestUiWindow(modelMessagesToUIMessages(stored), pageSize)\n }\n // Array adapters own no cursor. Apply `before` to the full transcript so an\n // adapter that ignored the hint cannot return the same newest page forever.\n const full = threadMessages(await messageStore.loadThread(threadId))\n const older = uiBeforeCursor(modelMessagesToUIMessages(full), before)\n if (older === undefined) {\n return { messages: [], page: truncatedPage(before) }\n }\n return newestUiWindow(older, pageSize)\n}\n"],"mappings":"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"}
@@ -10,6 +10,17 @@ type MakePersistence = () => Promise<AIPersistence> | AIPersistence;
10
10
  type OptionalRunStoreMethod = 'listByThread' | 'listByParentRun' | 'listReclaimable';
11
11
  /** Dotted `store.method` key a backend passes to declare an omitted method. */
12
12
  export type PersistenceConformanceMethodKey = `runs.${OptionalRunStoreMethod}`;
13
+ /**
14
+ * Checks added after the suite shipped. They are off by default, so a backend
15
+ * that passed before still passes. Turn them on with `options.checks`.
16
+ *
17
+ * - `'messages.metadata'`: `saveThread` / `loadThread` keep message `metadata`,
18
+ * including `metadata.tanstack.run.id` (run timings on reload need it).
19
+ * - `'runs.listByThread.state'`: `listByThread` returns each run's current
20
+ * `status` and `finishedAt` after `update` (`reconstructChat`'s
21
+ * `includeRuns` needs it).
22
+ */
23
+ export type PersistenceConformanceCheck = 'messages.metadata' | 'runs.listByThread.state';
13
24
  export interface PersistenceConformanceOptions {
14
25
  /**
15
26
  * Store keys this backend intentionally does not provide. Any store that is
@@ -26,6 +37,12 @@ export interface PersistenceConformanceOptions {
26
37
  * has no effect.
27
38
  */
28
39
  skipMethods?: Array<PersistenceConformanceMethodKey>;
40
+ /**
41
+ * Opt-in checks, off by default so existing backends keep passing. A check
42
+ * that is not listed is reported as a skipped case. See
43
+ * {@link PersistenceConformanceCheck}.
44
+ */
45
+ checks?: Array<PersistenceConformanceCheck>;
29
46
  }
30
47
  /**
31
48
  * Register a Vitest suite that validates `makePersistence()` against the full
@@ -74,6 +74,7 @@ async function drainStream(stream) {
74
74
  function runPersistenceConformance(name, makePersistence, options) {
75
75
  const skip = new Set(options?.skip ?? []);
76
76
  const skipMethods = new Set(options?.skipMethods ?? []);
77
+ const checks = new Set(options?.checks ?? []);
77
78
  describe(`AIPersistence conformance: ${name}`, () => {
78
79
  let persistence;
79
80
  beforeAll(async () => {
@@ -189,6 +190,25 @@ function runPersistenceConformance(name, makePersistence, options) {
189
190
  await store.saveThread("thread-rich", rich);
190
191
  expect(await store.loadThread("thread-rich")).toEqual(rich);
191
192
  });
193
+ it("round-trips message metadata", async (ctx) => {
194
+ if (!checks.has("messages.metadata")) return ctx.skip("opt-in check: pass { checks: ['messages.metadata'] }");
195
+ const store = resolveStore("messages");
196
+ if (!store) return ctx.skip("store not provided");
197
+ const withMetadata = [{
198
+ role: "user",
199
+ content: "hi",
200
+ metadata: { author: { id: "user-42" } }
201
+ }, {
202
+ role: "assistant",
203
+ content: "hello",
204
+ metadata: {
205
+ tanstack: { run: { id: "run-1" } },
206
+ custom: 1
207
+ }
208
+ }];
209
+ await store.saveThread("thread-metadata", withMetadata);
210
+ expect(await store.loadThread("thread-metadata")).toEqual(withMetadata);
211
+ });
192
212
  });
193
213
  describe("runs", () => {
194
214
  it("creates, resumes idempotently, updates, and gets", async (ctx) => {
@@ -394,6 +414,27 @@ function runPersistenceConformance(name, makePersistence, options) {
394
414
  const listed = await runs.listByThread("lt");
395
415
  expect(listed.map((r) => r.runId)).toEqual(["lt-a", "lt-b"]);
396
416
  });
417
+ it("lists runs by thread with their current status and finishedAt", async (ctx) => {
418
+ if (!checks.has("runs.listByThread.state")) return ctx.skip("opt-in check: pass { checks: ['runs.listByThread.state'] }");
419
+ const runs = resolveStore("runs");
420
+ if (!runs) return ctx.skip("store not provided");
421
+ if (!hasRunsMethod(runs, "listByThread")) return ctx.skip("runs.listByThread not implemented");
422
+ await runs.createOrResume({
423
+ runId: "lts-a",
424
+ threadId: "lts",
425
+ startedAt: 1
426
+ });
427
+ await runs.update("lts-a", {
428
+ status: "completed",
429
+ finishedAt: 5
430
+ });
431
+ expect(await runs.listByThread("lts")).toEqual([expect.objectContaining({
432
+ runId: "lts-a",
433
+ status: "completed",
434
+ startedAt: 1,
435
+ finishedAt: 5
436
+ })]);
437
+ });
397
438
  it("lists child runs by parent when supported", async (ctx) => {
398
439
  const runs = resolveStore("runs");
399
440
  if (!runs) return ctx.skip("store not provided");