From ed95a9e58d6bbd6e35adc2d76717330e457d04bc Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 9 Sep 2026 07:20:25 +0100 Subject: [PATCH] feat(sdk,core,webapp): transcript storage for chat.agent Give chat.agent a pluggable TranscriptStorage seam so a run can own its conversation history across continuations: the version 2 transcript snapshot and dual-version dashboard reader, the storage option with a read API and conformance suite, run-tail recovery, compaction and injected-context persistence, and a dashboard TranscriptStorage over the agent's message rows. Includes the continuation-boot recovery hardening and the compaction/injection persistence fix. Rebased onto main and migrated to zod v4. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VG39FXXkFFU24U5EtJMwPi --- .changeset/transcript-storage.md | 19 + .../components/runs/v3/agent/AgentView.tsx | 30 +- .../v3/agent/transcriptSnapshotSeed.test.ts | 62 + .../runs/v3/agent/transcriptSnapshotSeed.ts | 32 + .../api.v1.sessions.$sessionId.transcript.ts | 97 ++ .../test/chat-snapshot-integration.test.ts | 51 +- apps/webapp/test/replay-after-crash.test.ts | 24 +- docs/ai-chat/actions.mdx | 4 +- docs/ai-chat/background-injection.mdx | 2 + docs/ai-chat/compaction.mdx | 2 + docs/ai-chat/frontend.mdx | 2 + docs/ai-chat/lifecycle-hooks.mdx | 8 +- .../ai-chat/patterns/database-persistence.mdx | 8 +- .../patterns/persistence-and-replay.mdx | 57 +- docs/ai-chat/reference.mdx | 61 +- docs/ai-chat/transcript-storage.mdx | 233 +++ docs/docs.json | 1 + .../drizzle/0006_wooden_shaman.sql | 2 + .../drizzle/meta/0006_snapshot.json | 1369 +++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../dashboard-agent-db/src/schema.ts | 7 + .../dashboard-agent/package.json | 1 + .../src/transcript-storage.test.ts | 34 + .../dashboard-agent/src/transcript-storage.ts | 167 ++ packages/core/src/v3/apiClient/index.ts | 25 + packages/core/src/v3/schemas/api.ts | 15 + .../src/v3/sessionStreams/chatSnapshot.ts | 130 +- .../v3/test/test-session-stream-manager.ts | 6 +- packages/core/test/chatSnapshot.test.ts | 156 ++ .../trigger-authoring-chat-agent/SKILL.md | 5 +- .../trigger-chat-agent-advanced/SKILL.md | 10 +- packages/trigger-sdk/src/v3/ai.ts | 729 +++++---- packages/trigger-sdk/src/v3/chat-react.ts | 122 ++ packages/trigger-sdk/src/v3/chat.ts | 52 +- packages/trigger-sdk/src/v3/chatSnapshotIo.ts | 198 +++ packages/trigger-sdk/src/v3/test/index.ts | 5 + .../src/v3/test/mock-chat-agent.ts | 46 +- .../src/v3/test/transcript-storage-tests.ts | 327 ++++ .../trigger-sdk/src/v3/transcriptStorage.ts | 529 +++++++ .../trigger-sdk/test/action-snapshot.test.ts | 7 +- .../test/action-stream-accumulator.test.ts | 13 +- packages/trigger-sdk/test/action-turn.test.ts | 14 +- .../trigger-sdk/test/chat-snapshot.test.ts | 49 +- .../trigger-sdk/test/mockChatAgent.test.ts | 6 +- .../test/transcript-changesets.test.ts | 599 ++++++++ .../test/transcript-gate-split.test.ts | 240 +++ .../transcript-storage-conformance.test.ts | 34 + .../test/transcript-storage-option.test.ts | 93 ++ .../test/transcript-storage.test.ts | 359 +++++ .../test/use-load-transcript.test.ts | 58 + pnpm-lock.yaml | 5 +- 51 files changed, 5698 insertions(+), 414 deletions(-) create mode 100644 .changeset/transcript-storage.md create mode 100644 apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.test.ts create mode 100644 apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.ts create mode 100644 apps/webapp/app/routes/api.v1.sessions.$sessionId.transcript.ts create mode 100644 docs/ai-chat/transcript-storage.mdx create mode 100644 internal-packages/dashboard-agent-db/drizzle/0006_wooden_shaman.sql create mode 100644 internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json create mode 100644 internal-packages/dashboard-agent/src/transcript-storage.test.ts create mode 100644 internal-packages/dashboard-agent/src/transcript-storage.ts create mode 100644 packages/core/test/chatSnapshot.test.ts create mode 100644 packages/trigger-sdk/src/v3/chatSnapshotIo.ts create mode 100644 packages/trigger-sdk/src/v3/test/transcript-storage-tests.ts create mode 100644 packages/trigger-sdk/src/v3/transcriptStorage.ts create mode 100644 packages/trigger-sdk/test/transcript-changesets.test.ts create mode 100644 packages/trigger-sdk/test/transcript-gate-split.test.ts create mode 100644 packages/trigger-sdk/test/transcript-storage-conformance.test.ts create mode 100644 packages/trigger-sdk/test/transcript-storage-option.test.ts create mode 100644 packages/trigger-sdk/test/transcript-storage.test.ts create mode 100644 packages/trigger-sdk/test/use-load-transcript.test.ts diff --git a/.changeset/transcript-storage.md b/.changeset/transcript-storage.md new file mode 100644 index 00000000000..006db36f72b --- /dev/null +++ b/.changeset/transcript-storage.md @@ -0,0 +1,19 @@ +--- +"@trigger.dev/sdk": minor +"@trigger.dev/core": minor +--- + +`chat.agent` persists a conversation through a `TranscriptStorage`: an adapter with `load` and `save` that the runtime drives after every turn, failed turn and history-changing action. The platform snapshot stays the default; bring your own to write the conversation to your database as it happens. Each save carries both the changes since the last one (so a row store writes only what changed, and an undo is one `truncateAfter`) and the whole transcript as it now stands (so a document store writes it as-is with no state of its own). + +```ts +chat.agent({ + id: "my-chat", + storage: myTranscriptStorage, + run: async ({ messages, signal, streamText }) => + streamText({ model, messages, abortSignal: signal }), +}); +``` + +`chat.createLoadTranscriptAction(storage)` and `useLoadTranscript` read the conversation back the same way for every storage, and `runTranscriptStorageTests` from `@trigger.dev/sdk/ai/test` checks an implementation against the contract. + +Compaction summaries and `chat.inject` context now survive a continuation run, and crash recovery runs for every agent, including one that owns its own context. `hydrateMessages` is deprecated in favour of `loadContext` on a storage. The snapshot format is now version 2, which older SDK versions cannot read. diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx index f2570e3b928..f8c72d4db9b 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -1,9 +1,10 @@ import type { UIMessage } from "@ai-sdk/react"; -import { ChatSnapshotV1Schema, SSEStreamSubscription } from "@trigger.dev/core/v3"; +import { SSEStreamSubscription } from "@trigger.dev/core/v3"; import { useEffect, useMemo, useRef, useState } from "react"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView"; +import { seedFromTranscriptSnapshot } from "~/components/runs/v3/agent/transcriptSnapshotSeed"; import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -392,30 +393,17 @@ function useAgentSessionMessages({ const resp = await fetch(url, { signal: abort.signal }); if (!resp.ok) return undefined; const json = (await resp.json()) as unknown; - const parsed = ChatSnapshotV1Schema.safeParse(json); - if (!parsed.success) return undefined; - const snapshot = parsed.data; - // Preserve the snapshot's array order in the final render by - // giving each message a unique, monotonically increasing - // timestamp from `(savedAt - count + index)`. Real chunk - // timestamps from the SSE path use S2 arrival ms (positive - // numbers in the present), so anything below `savedAt` sorts - // before live chunks while preserving snapshot order among - // themselves. - const count = snapshot.messages.length; - snapshot.messages.forEach((raw, i) => { - const message = raw as UIMessage; - if (!message?.id) return; + const seed = seedFromTranscriptSnapshot(json); + if (!seed) return undefined; + for (const { id, message, timestamp } of seed.messages) { // The snapshot's seed wins over the task-payload seed for any // overlapping ids (the snapshot represents the agent's // canonical accumulator, post-turn). - pendingRef.current.set(message.id, message); - if (!timestampsRef.current.has(message.id)) { - timestampsRef.current.set(message.id, snapshot.savedAt - count + i); - } - }); + pendingRef.current.set(id, message); + timestampsRef.current.set(id, timestamp); + } scheduleFlush.current(); - return snapshot.lastOutEventId; + return seed.lastOutEventId; } catch { // 404 / network / parse / abort — fall back to seq=0 SSE return undefined; diff --git a/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.test.ts b/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.test.ts new file mode 100644 index 00000000000..db0a36524ec --- /dev/null +++ b/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { seedFromTranscriptSnapshot } from "./transcriptSnapshotSeed"; + +const user = { id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] }; +const assistant = { id: "a-1", role: "assistant", parts: [{ type: "text", text: "world" }] }; + +describe("seedFromTranscriptSnapshot", () => { + it("seeds from a version 1 snapshot in array order", () => { + const seed = seedFromTranscriptSnapshot({ + version: 1, + savedAt: 1_000, + messages: [user, assistant], + lastOutEventId: "42", + lastInEventId: "7", + }); + + expect(seed).toBeDefined(); + expect(seed!.lastOutEventId).toBe("42"); + expect(seed!.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + expect(seed!.messages.map((m) => m.timestamp)).toEqual([998, 999]); + expect(seed!.messages[1]!.message).toEqual(assistant); + }); + + it("seeds from a version 2 snapshot, unwrapping the message envelope", () => { + const seed = seedFromTranscriptSnapshot({ + version: 2, + savedAt: 1_000, + messages: [ + { id: "u-1", final: true, message: user }, + { id: "a-1", final: false, message: assistant }, + ], + state: { summary: "irrelevant to rendering" }, + lastOutEventId: "42", + lastInEventId: "7", + }); + + expect(seed).toBeDefined(); + expect(seed!.lastOutEventId).toBe("42"); + expect(seed!.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + expect(seed!.messages.map((m) => m.timestamp)).toEqual([998, 999]); + expect(seed!.messages[1]!.message).toEqual(assistant); + }); + + it("skips version 1 entries without an id but keeps the others' positions", () => { + const seed = seedFromTranscriptSnapshot({ + version: 1, + savedAt: 1_000, + messages: [{ role: "user", parts: [] }, assistant], + }); + + expect(seed!.messages.map((m) => m.id)).toEqual(["a-1"]); + expect(seed!.messages[0]!.timestamp).toBe(999); + expect(seed!.lastOutEventId).toBeUndefined(); + }); + + it("returns undefined for an unknown version or a non-snapshot body", () => { + expect(seedFromTranscriptSnapshot({ version: 3, savedAt: 1, messages: [] })).toBeUndefined(); + expect(seedFromTranscriptSnapshot({ error: "not found" })).toBeUndefined(); + expect(seedFromTranscriptSnapshot(null)).toBeUndefined(); + expect(seedFromTranscriptSnapshot("[]")).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.ts b/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.ts new file mode 100644 index 00000000000..8a7302eebbb --- /dev/null +++ b/apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.ts @@ -0,0 +1,32 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { parseTranscriptSnapshot } from "@trigger.dev/core/v3"; + +export type TranscriptSnapshotSeed = { + messages: Array<{ id: string; message: UIMessage; timestamp: number }>; + lastOutEventId: string | undefined; +}; + +/** + * Turn a fetched chat-snapshot blob into the messages the AgentView seeds + * before it opens the `.out` subscription. + * + * Each message gets a unique, monotonically increasing timestamp from + * `(savedAt - count + index)`. Live chunk timestamps are S2 arrival + * milliseconds in the present, so anything below `savedAt` sorts before + * live chunks while preserving the snapshot's own order. + * + * Reads both snapshot versions through `parseTranscriptSnapshot`. Returns + * `undefined` for anything that is not a snapshot this reader understands; + * the caller then falls back to the seq=0 SSE. + */ +export function seedFromTranscriptSnapshot(json: unknown): TranscriptSnapshotSeed | undefined { + const snapshot = parseTranscriptSnapshot(json); + if (!snapshot) return undefined; + const count = snapshot.messages.length; + const messages = snapshot.messages.map((entry, i) => ({ + id: entry.id, + message: entry.message, + timestamp: snapshot.savedAt - count + i, + })); + return { messages, lastOutEventId: snapshot.lastOutEventId }; +} diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.transcript.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.transcript.ts new file mode 100644 index 00000000000..02fdd0e347d --- /dev/null +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.transcript.ts @@ -0,0 +1,97 @@ +import { json } from "@remix-run/server-runtime"; +import { pageTranscriptEntries, parseTranscriptSnapshot } from "@trigger.dev/core/v3"; +import { z } from "zod/v4"; +import { $replica } from "~/db.server"; +import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server"; +import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { downloadPacketFromObjectStore } from "~/v3/objectStore.server"; +import { logger } from "~/services/logger.server"; + +const ParamsSchema = z.object({ + sessionId: z.string(), +}); + +const SearchParamsSchema = z.object({ + limit: z.coerce.number().int().min(1).max(1000).optional(), + before: z.string().optional(), +}); + +function sessionResource( + paramId: string, + session: { friendlyId: string; externalId: string | null } | null | undefined +) { + const ids = new Set([paramId]); + if (session) { + ids.add(session.friendlyId); + if (session.externalId) ids.add(session.externalId); + } + return anyResource([...ids].map((id) => ({ type: "sessions" as const, id }))); +} + +function isObjectNotFound(error: unknown): boolean { + if (!error) return false; + const name = (error as { name?: unknown }).name; + if (name === "NoSuchKey" || name === "NotFound") return true; + const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + if (status === 404) return true; + const message = error instanceof Error ? error.message : String(error); + return /not found|nosuchkey|404|does not exist/i.test(message); +} + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + searchParams: SearchParamsSchema, + corsStrategy: "none", + findResource: async (params, auth) => + resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId), + authorization: { + action: "read", + resource: (session, params) => sessionResource(params.sessionId, session), + }, + }, + async ({ authentication, resource: session, searchParams }) => { + if (!session) { + return json({ error: "Session not found" }, { status: 404 }); + } + + let body: unknown; + try { + const packet = await downloadPacketFromObjectStore( + { dataType: "application/store", data: chatSnapshotStorageKey(session) }, + authentication.environment + ); + body = typeof packet.data === "string" ? JSON.parse(packet.data) : undefined; + } catch (error) { + // A missing blob is a valid empty transcript (a session that has not + // saved yet). Any other read failure must NOT look like an empty chat: + // return an error so the client falls back to the whole-blob read + // instead of rendering a saved conversation as empty. + if (isObjectNotFound(error)) { + return json({ messages: [], state: null }); + } + logger.error("transcript endpoint: snapshot read failed", { + sessionId: session.friendlyId, + error: error instanceof Error ? error.message : String(error), + }); + return json({ error: "Failed to read transcript" }, { status: 502 }); + } + + const snapshot = parseTranscriptSnapshot(body); + if (!snapshot) { + return json({ messages: [], state: null }); + } + + const page = pageTranscriptEntries(snapshot.messages, searchParams); + return json({ + messages: page.entries.map((entry) => entry.message), + state: snapshot.state, + cursors: { + lastOutEventId: snapshot.lastOutEventId, + lastInEventId: snapshot.lastInEventId, + }, + nextCursor: page.nextCursor, + }); + } +); diff --git a/apps/webapp/test/chat-snapshot-integration.test.ts b/apps/webapp/test/chat-snapshot-integration.test.ts index c730e24b960..66dd414a03b 100644 --- a/apps/webapp/test/chat-snapshot-integration.test.ts +++ b/apps/webapp/test/chat-snapshot-integration.test.ts @@ -1,27 +1,8 @@ -// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob -// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors -// the testcontainer pattern from `objectStore.test.ts`. -// -// What this verifies end-to-end: -// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl` -// to mint a presigned PUT, then PUTs JSON to it. -// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a -// presigned GET, then fetches and parses. -// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts. -// - The blob round-trips with `version: 1` shape preserved. -// - 404 (no snapshot for a fresh session) returns `undefined`, not an -// error. -// -// This is the integration safety net behind the unit tests in -// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock -// `fetch`; this one drives a real S3-compatible backend. - import { postgresAndMinioTest } from "@internal/testcontainers"; -import { apiClientManager } from "@trigger.dev/core/v3"; +import { apiClientManager, type TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; import { __readChatSnapshotProductionPathForTests as readChatSnapshot, __writeChatSnapshotProductionPathForTests as writeChatSnapshot, - type ChatSnapshotV1, } from "@trigger.dev/sdk/ai"; import type { UIMessage } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; @@ -35,22 +16,24 @@ vi.setConfig({ testTimeout: 60_000 }); function makeSnapshot( opts: { messages?: UIMessage[]; lastOutEventId?: string } = {} -): ChatSnapshotV1 { +): TranscriptSnapshotV2 { + const messages = opts.messages ?? [ + { + id: "u-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }, + { + id: "a-1", + role: "assistant", + parts: [{ type: "text", text: "world" }], + }, + ]; return { - version: 1, + version: 2, savedAt: 1_700_000_000_000, - messages: opts.messages ?? [ - { - id: "u-1", - role: "user", - parts: [{ type: "text", text: "hello" }], - }, - { - id: "a-1", - role: "assistant", - parts: [{ type: "text", text: "world" }], - }, - ], + messages: messages.map((message) => ({ id: message.id, final: true, message })), + state: null, lastOutEventId: opts.lastOutEventId ?? "evt-42", }; } diff --git a/apps/webapp/test/replay-after-crash.test.ts b/apps/webapp/test/replay-after-crash.test.ts index 6133bda843f..389ccb2db49 100644 --- a/apps/webapp/test/replay-after-crash.test.ts +++ b/apps/webapp/test/replay-after-crash.test.ts @@ -24,11 +24,10 @@ // through it), even though the replay path itself doesn't read from S3. import { postgresAndMinioTest } from "@internal/testcontainers"; -import { apiClientManager } from "@trigger.dev/core/v3"; +import { apiClientManager, type TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; import { __readChatSnapshotProductionPathForTests as readChatSnapshot, __replaySessionOutTailProductionPathForTests as replaySessionOutTail, - type ChatSnapshotV1, } from "@trigger.dev/sdk/ai"; import type { UIMessageChunk } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; @@ -265,13 +264,26 @@ describe("replay after crash (MinIO + SDK helpers)", () => { // Pre-write a snapshot to MinIO via real apiClient stub. const sessionId = "sess_merge_round_trip"; - const snapshot: ChatSnapshotV1 = { - version: 1, + const snapshot: TranscriptSnapshotV2 = { + version: 2, savedAt: 1_700_000_000_000, messages: [ - { id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] }, - { id: "a-1", role: "assistant", parts: [{ type: "text", text: "stale-assistant" }] }, + { + id: "u-1", + final: true, + message: { id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] }, + }, + { + id: "a-1", + final: true, + message: { + id: "a-1", + role: "assistant", + parts: [{ type: "text", text: "stale-assistant" }], + }, + }, ], + state: null, lastOutEventId: "evt-prev", }; diff --git a/docs/ai-chat/actions.mdx b/docs/ai-chat/actions.mdx index 9e741e901a0..3e52ff8b596 100644 --- a/docs/ai-chat/actions.mdx +++ b/docs/ai-chat/actions.mdx @@ -83,9 +83,9 @@ onAction: async ({ action }) => { An action that returns nothing does not fire `onTurnComplete`, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use. -**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation, the runtime writes the snapshot, so the edit survives the run ending. An action that returns `chat.turn()` is followed by a turn, which persists its answer the way every turn does. +**Transcript storage** (the default, or your own `storage`): nothing to do. After an action that changed the conversation, the runtime hands the storage a changeset with `reason: "action"`. An undo is one `truncateAfter`; a regenerate is a `truncateAfter` followed by the new answer's `put` when the turn completes; an edit is a `put` for the edited id. The changeset carries the same resume cursors as the last turn. See [Transcript storage](/ai-chat/transcript-storage#what-the-runtime-saves). An action that returns `chat.turn()` is followed by a turn, which persists its answer the way every turn does. -**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history edit lives only in the running worker until you persist it, and a continuation rehydrates from your store, not from what the worker had in memory. Mirror each edit in your store, not only additions: a regenerate is a delete *and* an insert. The answer that follows `chat.turn()` reaches your store through `onTurnComplete`, like any turn's answer. +**Your own store through the deprecated `hydrateMessages`**: the runtime deliberately does not write, because your store is the source of truth. A history edit lives only in the running worker until you persist it, and a continuation rehydrates from your store, not from what the worker had in memory. Mirror each edit in your store, not only additions: a regenerate is a delete *and* an insert. The answer that follows `chat.turn()` reaches your store through `onTurnComplete`, like any turn's answer. ```ts onAction: async ({ action, chatId }) => { diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index 6d26e0d9e09..200b29c002c 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -250,6 +250,8 @@ chat.inject(messages: ModelMessage[]): void Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns, and are not reset when a new turn starts. +Lifetime: a conversational message (`role: "user"` or `"assistant"`) becomes part of the model's context from the next turn onward, for the rest of the conversation. It is written to the [transcript storage](/ai-chat/transcript-storage)'s `state`, anchored to the message it followed, so it survives a continuation run and comes back in the same place. It does not appear in the UI transcript. A history edit that rebuilds the context drops it. This holds however the message reached the model: drained before `run()` or at a step boundary inside a multi-step turn. A message that is still queued when the run ends (injected from the last `onTurnComplete` before an exit, for example) is carried in the storage's `state` too and is queued again when the next run boots, so it reaches the next turn. A `role: "system"` message is appended to the instructions for the next turn only and is consumed once. Injecting the same notice every turn adds a copy every turn; dedupe on your side. + **Parameters:** | Parameter | Type | Description | diff --git a/docs/ai-chat/compaction.mdx b/docs/ai-chat/compaction.mdx index 13c7be2b0bb..e7d1ed0074f 100644 --- a/docs/ai-chat/compaction.mdx +++ b/docs/ai-chat/compaction.mdx @@ -61,6 +61,8 @@ After each turn completes: On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context. +The compacted context is durable. The runtime writes it to the [transcript storage](/ai-chat/transcript-storage)'s `state` alongside the messages, so a new run that boots to continue the conversation starts from the summary rather than re-reading the whole transcript and summarising it again. An undo or edit that reaches into the summarised part of the conversation clears the stored summary, and compaction runs again from the edited history when the threshold is next crossed. + This is Trigger.dev's provider-agnostic compaction. To persist a **provider's own** compaction across turns instead (Anthropic context editing or OpenAI stored responses), and to fall back between providers without re-sending history, see [Native compaction & provider fallback](/ai-chat/patterns/native-compaction). diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index 35259b3e3c9..2ab7a4b2995 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -137,6 +137,8 @@ On page load, fetch both the messages and the session state from your database, Because the underlying Session row outlives individual runs, a chat you were in yesterday resumes against the same chat — even if the original run has long since exited. The transport hydrates from the persisted state and uses `lastEventId` to resubscribe; if the client tries to send a new message and no run is alive, the server triggers a fresh continuation run on the same session before the message is appended. +If you do not keep your own copy of the conversation, load it from the agent's [transcript storage](/ai-chat/transcript-storage#reading-the-transcript) instead: `chat.createLoadTranscriptAction(storage)` on the server and `useLoadTranscript(chatId, action, { transport })` in the browser return the messages and seed the transport's resume cursor, for the default storage and your own alike. + ```tsx app/chat/[chatId]/ChatPage.tsx "use client"; diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx index 1bb22ff3de7..3c062bb4629 100644 --- a/docs/ai-chat/lifecycle-hooks.mdx +++ b/docs/ai-chat/lifecycle-hooks.mdx @@ -86,7 +86,7 @@ export const myChat = chat.agent({ Fires once on a continuation boot when the dead predecessor was mid-stream — a partial assistant survives on `session.out`. The runtime reconstructs context automatically via a smart default; this hook is the override path for policies that need something different. -The hook does NOT fire when there's no partial — clean continuations after `chat.endRun()` or `chat.requestUpgrade()`, fresh chats, OOM retries on top of a complete snapshot. Those paths dispatch any in-flight user message as a normal turn on the new run without involving the hook. It also does NOT fire when [`hydrateMessages`](#hydratemessages) is registered (the customer owns persistence). +The hook does NOT fire when there's no partial — clean continuations after `chat.endRun()` or `chat.requestUpgrade()`, fresh chats, OOM retries on top of a complete snapshot. Those paths dispatch any in-flight user message as a normal turn on the new run without involving the hook. It fires regardless of whether [`hydrateMessages`](#hydratemessages) or a [transcript storage](/ai-chat/transcript-storage) is registered: crash recovery is runtime-owned for every agent. ```ts export const myChat = chat.agent({ @@ -260,6 +260,10 @@ export const myChat = chat.agent({ ## hydrateMessages + + `hydrateMessages` is deprecated. Give the agent a [transcript storage](/ai-chat/transcript-storage) instead: `loadContext` on the storage decides the model's context, and `save` persists every change, so crash recovery and durable compaction cover it. Existing agents keep working with a one-time warning. Setting `hydrateMessages` together with `storage` is an error. + + Load the full message history from your backend on every turn, replacing the built-in linear accumulator. When set, the hook's return value becomes the accumulated state; the normal accumulation logic (append for submit, replace for regenerate) is skipped entirely. Use this when the backend should be the source of truth for message history: abuse prevention, branching conversations (DAGs), or rollback/undo support. @@ -315,7 +319,7 @@ After the hook returns, the runtime overlays the wire's tool-state advances (`ou - Registering `hydrateMessages` short-circuits the runtime's [snapshot + replay](/ai-chat/patterns/persistence-and-replay) reconstruction at run boot — your hook is the single source of truth for history, so the runtime skips reading or writing the snapshot entirely. No object storage traffic, no replay cost. The trade-off is that you own persistence end-to-end. + Registering `hydrateMessages` turns off the runtime's transcript reads and writes: your hook is the source of truth for history, and the runtime does not read or write its [snapshot](/ai-chat/patterns/persistence-and-replay). Crash recovery still runs. When a new run boots, the dead run's unfinished answer and unacknowledged messages are replayed from the session streams, `onRecoveryBoot` fires, and the hook receives the recovered tail in `previousMessages`. Persisting that tail is yours to do; a [transcript storage](/ai-chat/transcript-storage) does it for you. diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx index 4e49ae81ebf..22cb8b05369 100644 --- a/docs/ai-chat/patterns/database-persistence.mdx +++ b/docs/ai-chat/patterns/database-persistence.mdx @@ -9,6 +9,10 @@ Durable chat runs can span **hours** and **many turns**. You usually want: 1. **Conversation state** — full **`UIMessage[]`** (or equivalent) keyed by **`chatId`**, so reloads and history views work. 2. **Live session state** — a **scoped access token** for the session and optionally **`lastEventId`** for stream resume. + + You can persist the conversation state through a [transcript storage](/ai-chat/transcript-storage) on the agent rather than the hook mapping below. Give `chat.agent` a `storage` that writes to your database and the runtime hands it every change (a new message, an undo, a regenerate, a compaction) as it happens, with the resume cursors, and reads it back when a run continues. Your own database is then the transcript. The hook mapping below still works, and it is the way to persist the live session state (the access token) and anything the transcript does not cover. + + This page describes a **hook mapping** that works with any database. Adapt table and column names to your stack. ## Conceptual data model @@ -167,9 +171,9 @@ chat.agent({ }); ``` -## Alternative: `hydrateMessages` +## Alternative: `hydrateMessages` (deprecated) -For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — use [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) instead of relying on the frontend's accumulated state. +For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — the recommended path is a [transcript storage](/ai-chat/transcript-storage#owning-the-models-context) with `loadContext`. The deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook does the same job without the runtime writing to your store. With hydration, the hook loads messages from your database on every turn. The frontend's messages are ignored (except for the new user message, which arrives in `incomingMessages`): diff --git a/docs/ai-chat/patterns/persistence-and-replay.mdx b/docs/ai-chat/patterns/persistence-and-replay.mdx index de84088ff83..b082ca30163 100644 --- a/docs/ai-chat/patterns/persistence-and-replay.mdx +++ b/docs/ai-chat/patterns/persistence-and-replay.mdx @@ -1,12 +1,12 @@ --- title: "Persistence and replay" sidebarTitle: "Persistence and replay" -description: "How chat.agent rebuilds conversation history at run boot — durable JSON snapshot in object storage plus session.out replay, with a hydrateMessages short-circuit for backend-owned history." +description: "How chat.agent rebuilds conversation history at run boot — the transcript storage's persisted conversation plus session.out replay, and what changes when your app owns the model's context." --- `chat.agent` runs are processes — they boot, stream a turn, and either suspend (waiting for the next message) or exit. When the next message arrives at a session whose previous run already exited, a **fresh** run boots with no in-memory state. Something has to rebuild the conversation history before that turn can produce a coherent response. -This page walks through the **snapshot + replay** model the runtime uses by default, and the [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) short-circuit that turns the whole thing off when the customer owns history. +This page walks through the **storage + replay** model. The persisted conversation comes from the agent's [transcript storage](/ai-chat/transcript-storage); the default storage is the snapshot in object storage described below, and a storage you bring is read the same way. Replay of the session streams covers what happened after the last save, and it runs for every agent, including one that owns the model's context. ## Why a snapshot at all @@ -32,7 +32,7 @@ sequenceDiagram User->>Run1: u1 Run1->>SessionOut: assistant chunks for a1 Run1->>Run1: onTurnComplete - Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastOutTimestamp } + Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastInEventId } Note over Run1: idle suspend (or exit) User->>Run2: u2 (delta only) @@ -52,15 +52,21 @@ The accumulator starts empty. The wire delivers `u1`. After the model finishes, ```json { - "version": 1, + "version": 2, "savedAt": 1715180400000, - "messages": [u1, a1], + "messages": [ + { "id": "u1", "final": true, "message": u1 }, + { "id": "a1", "final": true, "message": a1 } + ], + "state": null, "lastOutEventId": "42", - "lastOutTimestamp": 1715180399000 + "lastInEventId": "7" } ``` -The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost. +`state` holds what the runtime cannot rebuild from the messages, such as a [compaction](/ai-chat/compaction) summary; `final` is false for a partial answer captured from a failed turn. Snapshots written by older SDK versions have `version: 1` and are read as if every message were final with no state. + +The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. With your own storage, the equivalent is whatever `save` writes: the runtime hands it the two new messages as `put` changes and the same cursors, and a row-per-message store writes two rows instead of the whole conversation. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost. ### Run 2 — boot @@ -110,21 +116,25 @@ Replay carries the conversation across the crash boundary with zero customer cod ## OOM-retry interaction -The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot includes a `lastOutTimestamp` field that is exactly that high-water mark. +The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot's `lastInEventId` field is exactly that committed `.in` cursor. -When a snapshot exists, the OOM-retry path reads `lastOutTimestamp` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win. +When a snapshot exists, the OOM-retry path reads `lastInEventId` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win. If no snapshot exists (first turn, or `hydrateMessages` registered), the path falls back to the scan. -## Action turns — no snapshot write +## Action turns + +[Actions](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. An action that changed the conversation is saved on its own, with `reason: "action"` and the same resume cursors as the last turn, so an undo survives the run ending. See [Actions and persistence](/ai-chat/actions#actions-and-persistence). + +## When your app owns the model's context -[Action turns](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. The snapshot write site is gated on `onTurnComplete`, so action turns don't snapshot. +A storage with [`loadContext`](/ai-chat/transcript-storage#owning-the-models-context), or the deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, decides what the model sees on every turn instead of the runtime's accumulated transcript. That changes the boot sequence in one place: with `hydrateMessages` the storage read is skipped, because the hook is the source of truth. Everything else still runs. The `session.out` and `session.in` tails are replayed, a partial answer and unacknowledged messages are recovered, and `onRecoveryBoot` fires. The hook then receives the recovered tail in `previousMessages`, so it can persist an answer a crashed run had already started. -If `onAction` mutates `chat.history.*` and then the run crashes before the next regular turn, the mutation is lost. The user re-fires the action. This matches `chat.history` semantics in general — mutations are persisted at turn boundaries, not action boundaries. +With `loadContext` on a storage, the storage is still read and written: `load` restores the cursors and the runtime's `state` (a compaction summary survives), `save` still receives every change, and only the model's context comes from `loadContext`. -## The `hydrateMessages` short-circuit +### The `hydrateMessages` hook -When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned. +When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. The snapshot is neither read nor written. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned. ```ts import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai"; @@ -160,24 +170,24 @@ export const myChat = chat.agent({ What you gain: -- **Zero object-store traffic per turn.** No snapshot read, no snapshot write, no replay subscription. `OBJECT_STORE_*` env vars don't have to be set. +- **Zero object-store traffic per turn.** No snapshot read, no snapshot write. `OBJECT_STORE_*` env vars don't have to be set. - **Branching, undo, edit, abuse prevention** — patterns that need a backend-side single source of truth work naturally because the customer mediates every read. What you give up: -- **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model. -- **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it. (Same as the pre-snapshot baseline — not a regression, just a missed optimization.) +- **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model, and a compaction summary has nowhere durable to live. +- **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it. -The runtime's snapshot+replay is the safer default. `hydrateMessages` is the right choice when you already have authoritative storage for messages and want one consistent persistence path. +A [transcript storage](/ai-chat/transcript-storage) with `loadContext` gives you the same ownership of the model's context while the runtime keeps writing every change and its own state to your store. It is the recommended path; `hydrateMessages` is deprecated. -## When neither is configured +## When no storage is configured -If `hydrateMessages` is not registered **and** no object store is configured, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time. +If no object store is configured and the agent has no `storage` of its own, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time. For local development this is sometimes fine — you're not testing continuations. For production it isn't. Configure one of: - **Object store** (`OBJECT_STORE_*` env vars on your webapp) — easiest, default behavior. -- **`hydrateMessages` + your own database** — stronger control, suits multi-tenant apps with audit needs. +- **A transcript storage over your own database** — stronger control, suits multi-tenant apps with audit needs. ## Snapshot key & lifecycle @@ -188,7 +198,7 @@ For local development this is sometimes fine — you're not testing continuation | Key suffix | `sessions/{sessionId}/snapshot.json` | | Final key | `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` | | Size | Tens of KB typical, capped only by object-store limits | -| Cadence | Overwritten after every successful `onTurnComplete` | +| Cadence | Overwritten after every successful `onTurnComplete`, and after a history-changing action | Snapshots accumulate per-session forever unless you set a lifecycle policy on the bucket. A 90-day expiry on `packets/*/sessions/*/snapshot.json` is a reasonable default if your chats don't typically resume after that window. Closed sessions are not auto-cleaned today. @@ -201,7 +211,8 @@ For local development against `pnpm run docker`, the bundled MinIO container is ## See also - [Client Protocol](/ai-chat/client-protocol#how-history-is-rebuilt) — the wire-level view of the same model -- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the short-circuit hook +- [Transcript storage](/ai-chat/transcript-storage) — the adapter the runtime persists through, and how to bring your own +- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the deprecated context hook - [OOM resilience](/ai-chat/patterns/oom-resilience) — how `session.in` cutoffs interact with snapshots - [Database persistence](/ai-chat/patterns/database-persistence) — the canonical persistence pattern using `onTurnComplete` - [v4.5 upgrade guide](/ai-chat/upgrade-guide#v45-wire-format-change) — when this model landed and what changed diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 558370ec873..c3e950838cc 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -44,9 +44,10 @@ Options for `chat.agent()`. | `onPreload` | `(event: PreloadEvent) => Promise \| void` | — | Fires on preloaded runs before the first message | | `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires once per chat, on the very first user message. Does NOT fire on continuation runs or OOM-retries — see [onChatStart](/ai-chat/lifecycle-hooks#onchatstart). | | `onValidateMessages` | `(event: ValidateMessagesEvent) => UIMessage[] \| Promise` | — | Validate/transform UIMessages before model conversion. See [onValidateMessages](/ai-chat/lifecycle-hooks#onvalidatemessages) | -| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise` | — | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) | +| `storage` | `TranscriptStorage` | `defaultStorage` | Where the conversation is persisted and read back. The platform snapshot by default; bring your own to write each change to your database. See [Transcript storage](/ai-chat/transcript-storage) | +| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise` | — | **Deprecated.** Load message history from backend, replacing the linear accumulator. Use `loadContext` on a `storage` instead; cannot be combined with `storage`. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) | | `actionSchema` | `TaskSchema` | — | Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) | -| `onAction` | `(event: ActionEvent) => Promise \| void \| ActionTurn` | — | Handle custom actions. Actions are state edits: only `hydrateMessages` + `onAction` fire. Return `chat.turn()` to run a turn on the edited history, or nothing for an edit only. See [Actions](/ai-chat/actions) | +| `onAction` | `(event: ActionEvent) => Promise \| void \| ActionTurn` | — | Handle custom actions. Actions are state edits: only `hydrateMessages` (or a storage's `loadContext`) + `onAction` fire. Return `chat.turn()` to run a turn on the edited history, or nothing for an edit only. See [Actions](/ai-chat/actions) | | `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | | `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | @@ -218,9 +219,38 @@ Passed to the `tools` function form on `chat.agent`, once per turn, to resolve t | `continuation` | `boolean` | Whether this run is continuing an existing chat | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +## TranscriptStorage + +The persistence adapter set through `chat.agent({ storage })`. See [Transcript storage](/ai-chat/transcript-storage). All types below are exported from `@trigger.dev/sdk/ai`. + +| Member | Signature | Description | +| --- | --- | --- | +| `load` | `(scope: TranscriptScope, opts?: TranscriptLoadOptions) => Promise` | The conversation, in order. Called once at a continuation boot and by `chat.createLoadTranscriptAction` for rendering. | +| `save` | `(ctx: TranscriptStorageContext, changeset: TranscriptChangeset) => Promise` | Apply the changes since the last save. Called after every turn, failed turn and history-changing action. | +| `loadContext?` | `(scope: TranscriptScope, event: LoadContextEvent) => Promise` | Optional. When present, the storage owns the model's context: called on every turn and action in place of the runtime's transcript. Same event shape as `HydrateMessagesEvent`. | + +| Type | Shape | +| --- | --- | +| `TranscriptScope` | `{ chatId: string; clientData: TClientData }` | +| `TranscriptStorageContext` | `TranscriptScope` plus `{ turn: number; trigger: "submit-message" \| "regenerate-message" \| "action"; runId: string; ctx: TaskRunContext }` | +| `TranscriptChange` | `{ op: "put"; message: UIMessage; final?: boolean }` \| `{ op: "remove"; id: string }` \| `{ op: "truncateAfter"; afterId: string }` \| `{ op: "state"; value: unknown \| null }` | +| `TranscriptChangeset` | `{ reason: "turn-complete" \| "turn-error" \| "action" \| "compaction" \| "recovery"; changes: TranscriptChange[]; transcript: TranscriptState; cursors?: TranscriptCursors }` | +| `TranscriptState` | `{ entries: Array<{ id: string; final: boolean; message: UIMessage }>; state: unknown \| null }`: the whole conversation after the changeset's changes, for stores that write one document | +| `TranscriptCursors` | `{ lastOutEventId?: string; lastInEventId?: string }` | +| `TranscriptLoadOptions` | `{ limit?: number; before?: string }` | +| `TranscriptLoadResult` | `{ messages: UIMessage[]; state: unknown \| null; cursors?: TranscriptCursors; nextCursor?: string }` | + +| Export | Description | +| --- | --- | +| `defaultStorage` | The platform snapshot storage the agent uses when `storage` is not set. Equal to `snapshotTranscriptStorage()`. | +| `snapshotTranscriptStorage()` | Factory for the platform snapshot storage. | +| `memoryTranscriptStorage()` | An in-process storage that also records every changeset it receives. The reference implementation. | +| `reduceTranscriptChanges(state, changes)` | Pure reducer that applies changes to `{ entries, state }`. Useful for building a storage over a document store. | +| `runTranscriptStorageTests(makeStorage, options?)` | From `@trigger.dev/sdk/ai/test`. The conformance suite for a storage implementation. Pass `{ api: { describe, it, expect } }` when test globals are off, and `clientData` when your storage scopes by it. | + ## HydrateMessagesEvent -Passed to the `hydrateMessages` callback. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages). +Passed to the `hydrateMessages` callback. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages). `hydrateMessages` is deprecated; `LoadContextEvent`, passed to a storage's `loadContext`, has the same shape. | Field | Type | Description | | ------------------ | ----------------------------------------------------- | --------------------------------------------------------- | @@ -369,8 +399,8 @@ Passed to `compactUIMessages` and `compactModelMessages` callbacks. | Field | Type | Description | | --------------- | -------------------- | ---------------------------------------------------- | | `summary` | `string` | The generated summary text | -| `uiMessages` | `UIMessage[]` | Current UI messages (full conversation) | -| `modelMessages` | `ModelMessage[]` | Current model messages (full conversation) | +| `uiMessages` | `UIMessage[]` | Current UI messages (the transcript) | +| `modelMessages` | `ModelMessage[]` | Current model messages (the lane the model is sent; after a compaction this is the summary plus what followed it, not the whole transcript) | | `chatId` | `string` | Chat session ID | | `turn` | `number` | Current turn (0-indexed) | | `clientData` | `unknown` | Custom data from the frontend | @@ -514,6 +544,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.messages` | Incoming message mailbox; supports non-consuming `.peek()` / `.hasPending()`, single-record `.next()`, `.on()`, and suspend-aware `.waitWithIdleTimeout()` | | `chat.local({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) | | `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. | +| `chat.createLoadTranscriptAction(storage, options?)` | Returns a server action that reads a conversation from a transcript storage (`{ chatId, clientData?, limit?, before? }` → `TranscriptLoadResult`). Pair with `useLoadTranscript`. Performs no application authorization: authorize `chatId` for the signed-in user in your own code. See [Transcript storage](/ai-chat/transcript-storage#reading-the-transcript) | | `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` | | `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. | | `chat.close({ reason })` | End the conversation permanently: close the session row, write a terminal `session-closed` record, and exit without a continuation. Decide it before the turn ends (`onBeforeTurnComplete`, not `onTurnComplete`) so the client sees the closed state on that turn. | @@ -876,6 +907,26 @@ Second argument to `chat.createStartSessionAction(taskId, options?)`. Controls h | `baseURL` | `string \| (ctx: { endpoint: "sessions" \| "auth"; chatId: string }) => string` | `apiClientManager.baseURL` | API base URL. `endpoint` is `"sessions"` for `POST /api/v1/sessions` or `"auth"` for `POST /api/v1/auth/jwt/claims` (only fires when `tokenTTL` is set). | | `fetch` | `(url: string, init: RequestInit, ctx: { endpoint: "sessions" \| "auth"; chatId: string }) => Promise` | — | Per-request fetch override. Use to route session-create through a trusted edge proxy so `basePayload.metadata` is rewritten before reaching `api.trigger.dev`. | +## createLoadTranscriptAction options + +Second argument to `chat.createLoadTranscriptAction(storage, options?)`. + +| Option | Type | Default | Description | +| ----------- | ------------------------ | -------------------------- | --------------------------------------------------------------------------- | +| `limit` | `number` | — | Page size when the caller passes none. Returns the most recent messages and a `nextCursor`. | +| `apiClient` | `ApiClientConfiguration` | `apiClientManager` config | Scope the read to a specific API client (secret key, base URL). The default storage reads through it. | + +## useLoadTranscript + +`useLoadTranscript(chatId, load, options?)` from `@trigger.dev/sdk/chat/react`. Loads a conversation through a `chat.createLoadTranscriptAction` action, for rendering before the chat connects. Re-runs when `chatId` changes. + +| Option | Type | Description | +| ----------- | ---------------------- | -------------------------------------------------------------------------------------------- | +| `transport` | `TriggerChatTransport` | Seed the transport's resume cursor for this chat from the transcript, once it knows the session. | +| `limit` | `number` | Page size passed to the action. | + +Returns `{ messages, isLoading, error, nextCursor }`. `nextCursor` is the id to pass as `before` to the action for the page before this one. + ## useMultiTabChat React hook for multi-tab message coordination. Import from `@trigger.dev/sdk/chat/react`. diff --git a/docs/ai-chat/transcript-storage.mdx b/docs/ai-chat/transcript-storage.mdx new file mode 100644 index 00000000000..30e66e5d5c2 --- /dev/null +++ b/docs/ai-chat/transcript-storage.mdx @@ -0,0 +1,233 @@ +--- +title: "Transcript storage" +sidebarTitle: "Transcript storage" +description: "Where a chat.agent conversation is kept: the UIMessages the runtime saves, the platform default, reading history back, and bringing your own database through the TranscriptStorage adapter." +--- + +## Why a conversation needs a home + +A `chat.agent` conversation outlives a single run. One run answers many turns and survives the idle gaps between them, but a run does end eventually (a version upgrade, its turn limit, a crash), and the next message then boots a fresh run with nothing in memory (see [How it works](/ai-chat/how-it-works)). For that new run to answer in context, the conversation so far has to be read back from somewhere durable. The same store is what a page reload and the dashboard read to show history. + +That somewhere is a **transcript storage**. You get one by default with no setup: the platform keeps the conversation as a snapshot in object storage, the same blob the Sessions view in the dashboard renders. Bring your own when you want the conversation in your own database instead. + +## What gets saved + +The transcript is a list of **`UIMessage`s**, keyed by `chatId`. A `UIMessage` is the rich, renderable message the frontend works with: an `id`, a `role`, and an array of `parts` (text, reasoning, tool calls and their results, and any custom `data-*` parts). It is the same shape your React app holds and the same shape the dashboard renders, so what you store is exactly what a user sees. + + +`UIMessage`s are not what the model reads. Each turn the runtime derives a `ModelMessage[]` from the transcript, the flattened `{ role, content }` form an LLM takes, and hands it to your `run()` as `messages`. The transcript storage never deals in `ModelMessage`s. It holds the UI messages; the model's view is derived from them. + + +Keeping the UI shape is deliberate. It is lossless (a tool call and its result survive as parts), it is what renders, and the model's view can be rebuilt from it. Two things cannot be rebuilt from the messages alone, so the runtime hands them to the storage as well: + +- **`state`**: an opaque record for what the model saw that the transcript does not capture, a [compaction](/ai-chat/compaction) summary and [injected context](/ai-chat/background-injection). Store it as-is and give it back on load. +- **cursors**: the stream positions the next run resumes from. Persist them opaquely; a storage never reads them. + +So a save is: the messages, a `state` blob, and two cursors. Nothing else. + +## The default storage + +Do nothing and you get the platform snapshot: the whole conversation written to object storage after each change, read back when a run continues. It is the blob the dashboard's Sessions view renders, and it needs no configuration. + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { anthropic } from "@ai-sdk/anthropic"; + +export const myChat = chat.agent({ + id: "my-chat", + run: async ({ messages, signal, streamText }) => + streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }), +}); +``` + +The default rewrites the whole conversation on every turn. That is fine for most chats and costs one write. When it stops being fine, or when you want the conversation in a database you already run, you bring your own. + +## Bring your own storage + +Set `storage` on the agent to persist the conversation yourself: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { myTranscriptStorage } from "./transcript-storage"; + +export const myChat = chat.agent({ + id: "my-chat", + storage: myTranscriptStorage, + run: async ({ messages, signal, streamText }) => + streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }), +}); +``` + +Reasons to: + +- **Your database is the source of truth.** History lives next to the rest of your data, queryable, backed up, and deletable on your terms. +- **Cheaper writes on long chats.** A row-per-message store writes only what changed on a turn instead of rewriting the whole conversation. +- **Render history in one query** from your own tables, the same `load` the runtime uses. +- **Own the model's context** for branching, trust boundaries, or rollback (see [Owning the model's context](#owning-the-models-context)). + +The runtime drives the storage. You never decide when to write, what a regenerate means for your rows, or how a crash mid-answer is recovered. Those decisions are the same for every backend, so they live in the runtime; your job is to store what it hands you and give it back. + +## The interface + +```ts +type TranscriptStorage = { + load( + scope: { chatId: string; clientData: TClientData }, + opts?: { limit?: number; before?: string } + ): Promise<{ + messages: UIMessage[]; + state: unknown | null; + cursors?: { lastOutEventId?: string; lastInEventId?: string }; + nextCursor?: string; + }>; + + save( + ctx: { + chatId: string; + clientData: TClientData; + turn: number; + trigger: "submit-message" | "regenerate-message" | "action"; + runId: string; + ctx: TaskRunContext; + }, + changeset: { + reason: "turn-complete" | "turn-error" | "action" | "compaction" | "recovery"; + changes: TranscriptChange[]; + transcript: { entries: Array<{ id: string; final: boolean; message: UIMessage }>; state: unknown | null }; + cursors?: { lastOutEventId?: string; lastInEventId?: string }; + } + ): Promise; + + loadContext?( + scope: { chatId: string; clientData: TClientData }, + event: LoadContextEvent + ): Promise; +}; + +type TranscriptChange = + | { op: "put"; message: UIMessage; final?: boolean } + | { op: "remove"; id: string } + | { op: "truncateAfter"; afterId: string } + | { op: "state"; value: unknown | null }; +``` + +`load` returns the conversation. `save` records a change to it. `loadContext` is optional and covered [below](#owning-the-models-context). All the types are exported from `@trigger.dev/sdk/ai`. + +`scope` is the tenant of a read: the `chatId` and the `clientData` your app passed. `ctx` on a save is the same plus the run it happened in. `clientData` is how the runtime hands you the tenant; use it to scope or authorize where your backend needs to. + +## What the runtime hands `save` + +A changeset carries the same save two ways, and a storage uses whichever suits its shape. + +`changes` is the ordered list of what changed since the last save. A row-per-message store applies them, as one transaction where the backend supports one: + +| Change | Meaning | +| --- | --- | +| `put` | Upsert by `message.id`. An unknown id appends at the end; a known id is replaced in place. `final` is `false` for a partial answer captured from a turn that failed or was stopped, and `true` otherwise. | +| `remove` | Delete by id. A no-op for an unknown id. | +| `truncateAfter` | Drop every message ordered after `afterId`. This is what an undo or a regenerate becomes. A no-op for an unknown id. | +| `state` | Replace the runtime's opaque record; `null` clears it. | + +`transcript` is the whole conversation as it stands after those changes, `entries` plus `state`. A store that keeps the conversation as one document (object storage, a key-value store, a JSON column) writes it as-is and keeps no state of its own between saves. The default storage is exactly that: it serialises `transcript` and rewrites the blob. + +The changes are the intent, spelled out. A normal turn is two `put`s, the user's message and the assistant's answer. A steering message the user sent mid-turn is another `put` in the same changeset. An undo through `chat.history.slice(0, -2)` is one `truncateAfter`. A regenerate is a `truncateAfter` and a `put`. A tool approval that updates the assistant message in place is one `put` for that id. Messages are addressed by id; how you order rows is your concern. + +A few properties worth knowing: + +- Saves happen after the turn's answer has reached the browser, so they never delay the response. The runtime awaits each `save` before the run suspends. +- A `save` that throws is logged and the turn continues. The changes fold into the next changeset, and every change is idempotent, so a retried changeset converges on the same result. +- A `load` that throws boots the run from the durable stream's recent tail rather than failing. + +## Reading the transcript + +`load` is the one read for every backend, the default included. Call it on your server, scoped to the signed-in user through `clientData`, and pass the result to the browser: + +```ts app/actions.ts +"use server"; +import { chat, defaultStorage } from "@trigger.dev/sdk/ai"; + +export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 }); +``` + +```tsx app/chat/[chatId]/ChatPage.tsx +"use client"; +import { useLoadTranscript, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { loadTranscript } from "@/app/actions"; + +export function ChatPage({ chatId }: { chatId: string }) { + const transport = useTriggerChatTransport({ task: "my-chat", accessToken, startSession }); + const { messages, isLoading, nextCursor } = useLoadTranscript(chatId, loadTranscript, { + transport, + }); + if (isLoading) return ; + return ; +} +``` + + +The action receives `chatId` from the browser, so authorize it before returning: check that the signed-in user owns this chat. `defaultStorage` loads purely by `chatId` and does no tenant check of its own, so an exported action with no authorization lets any authenticated user read any chat's transcript. A custom storage can enforce tenancy inside `load` using `clientData`, but the server action is still the place to reject a `chatId` the caller may not read. + + +`limit` returns the most recent messages and a `nextCursor`; pass it as `before` for the page before that one. With the default storage, a paged read is served by the platform, so a long conversation is not downloaded in full to render its last fifty messages. When you pass `transport` and it already knows the session, the hook seeds its resume cursor from the transcript, so the live subscription opens just past the persisted history instead of replaying it. + +Swap `defaultStorage` for your own storage and nothing else about the read changes. + +## Owning the model's context + +By default the model's context each turn is the transcript the runtime accumulated, converted to `ModelMessage`s. A storage that declares `loadContext` takes that over: the runtime calls it on every turn and action, with the messages the frontend sent and the transcript the runtime had, and uses the `UIMessage`s it returns as the conversation (converting them to `ModelMessage`s the same way). Reach for it when your database decides what the model sees, for branching conversations, a trust boundary where the browser's history is not to be believed, or a curated context window. + +```ts +const storage: TranscriptStorage<{ userId: string }> = { + load: (scope, opts) => rows.load(scope, opts), + save: (ctx, changeset) => rows.save(ctx, changeset), + loadContext: async ({ chatId, clientData }, { incomingMessages }) => { + const branch = await rows.activeBranch(chatId, clientData.userId); + return [...branch, ...incomingMessages]; + }, +}; +``` + +`save` keeps receiving every change, and crash recovery keeps running. This is the replacement for the deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook; setting both `hydrateMessages` and `storage` on an agent is a startup error. + +## Writing your own storage + +The contract is small and the conformance suite checks it. Point the suite at a factory for your storage and run it under vitest or jest: + +```ts transcript-storage.test.ts +import { runTranscriptStorageTests } from "@trigger.dev/sdk/ai/test"; +import { postgresTranscriptStorage } from "./transcript-storage"; + +runTranscriptStorageTests(() => postgresTranscriptStorage(process.env.TEST_DATABASE_URL!)); +``` + +The suite covers appends and in-place replacement, idempotent `remove` and `truncateAfter`, `state` round-trips, cursors, replaying the same changeset twice, paging, and chat isolation. `memoryTranscriptStorage()` is the reference implementation, and it is handy in your own tests to see exactly what the runtime hands a storage. + +A few things to get right: + +- Pick one view and stay with it. Apply `changes` if you store rows, write `transcript` if you store a document; don't mix them within one save. +- `put` for a known id replaces the message in place; position and ordering don't change. +- `truncateAfter` and `remove` are idempotent. Applying a changeset twice gives the same result as applying it once. +- `load` with no options returns the whole conversation in order. With `limit`, return the most recent messages and a `nextCursor` (the id of the oldest returned message) when earlier messages exist. +- Scope reads and writes by `clientData` where your backend enforces tenancy. + +## Guarantees and limits + +- Crash recovery of a half-written answer is runtime-owned in every configuration. It comes from the durable session stream, which no application database can reconstruct. A storage holds settled turns; the runtime overlays the recovered tail and hands it to `save` like any other change. +- Bringing your own database does not remove platform custody. Session streams still hold message content for their retention window. +- The default storage rewrites the whole conversation each turn. A row-per-message storage writes only what changed. That is the reason to plug in your own. + +## Migrating from hydrateMessages + +`hydrateMessages` keeps working with a one-time deprecation warning. Crash recovery runs for it, but the runtime does not write to your store on its behalf. To move: + +1. Implement `TranscriptStorage` over your existing tables. Your `hydrateMessages` body becomes `loadContext`; the writes you did in hooks become `save`. +2. Set `storage` on the agent and remove `hydrateMessages`. Setting both is an error. +3. Run `runTranscriptStorageTests` against your implementation. + +## See also + +- [Persistence and replay](/ai-chat/patterns/persistence-and-replay): how the runtime rebuilds a conversation when a new run boots +- [Database persistence](/ai-chat/patterns/database-persistence): the hook-based pattern and how it relates +- [Actions](/ai-chat/actions#actions-and-persistence): what an undo or regenerate becomes in the changeset +- [Compaction](/ai-chat/compaction): the summary the runtime keeps in `state` diff --git a/docs/docs.json b/docs/docs.json index 966a7f5d9a9..493efbcde7b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -115,6 +115,7 @@ "ai-chat/pending-messages", "ai-chat/background-injection", "ai-chat/actions", + "ai-chat/transcript-storage", "ai-chat/error-handling" ] }, diff --git a/internal-packages/dashboard-agent-db/drizzle/0006_wooden_shaman.sql b/internal-packages/dashboard-agent-db/drizzle/0006_wooden_shaman.sql new file mode 100644 index 00000000000..17c8acffac1 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0006_wooden_shaman.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trigger_dashboard_agent"."chats" ADD COLUMN "transcript_state" jsonb;--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."chats" ADD COLUMN "transcript_cursors" jsonb; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000000..2678dfe3b02 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1369 @@ +{ + "id": "045c0551-1507-4ca3-949c-b426e4d88ec9", + "prevId": "9f0a4739-19ca-4a15-82dd-25598116feb9", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.agent_message_usage": { + "name": "agent_message_usage", + "schema": "trigger_dashboard_agent", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_message_usage_organization_id_period_pk": { + "name": "agent_message_usage_organization_id_period_pk", + "columns": ["organization_id", "period"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_messages": { + "name": "chat_messages", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_chat_user_role_idx": { + "name": "chat_messages_chat_user_role_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_messages\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_messages_chat_id_message_id_pk": { + "name": "chat_messages_chat_id_message_id_pk", + "columns": ["chat_id", "message_id"] + } + }, + "uniqueConstraints": { + "chat_messages_chat_position_key": { + "name": "chat_messages_chat_position_key", + "nullsNotDistinct": false, + "columns": ["chat_id", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_created_idx": { + "name": "chat_turn_evals_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_message_position": { + "name": "next_message_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "transcript_state": { + "name": "transcript_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transcript_cursors": { + "name": "transcript_cursors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sweep_attempts": { + "name": "sweep_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sweep_attempt_at": { + "name": "last_sweep_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_open_updated_idx": { + "name": "investigations_open_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"investigations\".\"state\"->>'outcome' = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_batches": { + "name": "watch_batches", + "schema": "trigger_dashboard_agent", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "armed_at": { + "name": "armed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_batches_environment_id_cadence_minutes_pk": { + "name": "watch_batches_environment_id_cadence_minutes_pk", + "columns": ["environment_id", "cadence_minutes"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_submissions": { + "name": "watch_submissions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_hash": { + "name": "draft_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "watch_id": { + "name": "watch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unavailable": { + "name": "unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_notification_status": { + "name": "external_notification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_requested'" + }, + "external_notification_reason": { + "name": "external_notification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_result": { + "name": "immediate_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_code": { + "name": "refusal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_error": { + "name": "refusal_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_existing_id": { + "name": "refusal_existing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_submissions_created_idx": { + "name": "watch_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_submissions_chat_id_client_request_id_pk": { + "name": "watch_submissions_chat_id_client_request_id_pk", + "columns": ["chat_id", "client_request_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "alert_dispatch_key": { + "name": "alert_dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_at": { + "name": "retention_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "greatest(delivered_at, cancelled_at, fired_at, last_checked_at, created_at)", + "type": "stored" + } + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((spec ->> 'checkEveryMinutes')::int)", + "type": "stored" + } + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_wake_idx": { + "name": "watches_org_user_wake_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'delivered' and \"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_active_idx": { + "name": "watches_org_user_active_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_active_env_cadence_idx": { + "name": "watches_active_env_cadence_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"last_attempted_at\", \"last_checked_at\", \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_env_cadence_delivery_idx": { + "name": "watches_env_cadence_delivery_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_retention_idx": { + "name": "watches_retention_idx", + "columns": [ + { + "expression": "retention_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired', 'cancelled') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('not_required', 'delivered')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json index 213320fa640..7a74a3b82fc 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1786376934874, "tag": "0005_ambitious_mordo", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1788600710042, + "tag": "0006_wooden_shaman", + "breakpoints": true } ] } diff --git a/internal-packages/dashboard-agent-db/src/schema.ts b/internal-packages/dashboard-agent-db/src/schema.ts index d080d759915..8d3d98c0a7e 100644 --- a/internal-packages/dashboard-agent-db/src/schema.ts +++ b/internal-packages/dashboard-agent-db/src/schema.ts @@ -47,6 +47,13 @@ export const chats = dashboardAgentSchema.table( // The position allocator for `chat_messages`. Bumped by the same single statement // that reads it, so concurrent writers get disjoint contiguous ranges. nextMessagePosition: integer("next_message_position").notNull().default(1), + /** The chat.agent runtime's opaque transcript state, written through the TranscriptStorage adapter. */ + transcriptState: jsonb("transcript_state").$type(), + /** The stream resume cursors the TranscriptStorage adapter was last handed. */ + transcriptCursors: jsonb("transcript_cursors").$type<{ + lastOutEventId?: string; + lastInEventId?: string; + }>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/internal-packages/dashboard-agent/package.json b/internal-packages/dashboard-agent/package.json index db37fde3b4d..50d2629a2bc 100644 --- a/internal-packages/dashboard-agent/package.json +++ b/internal-packages/dashboard-agent/package.json @@ -20,6 +20,7 @@ "@internal/dashboard-agent-db": "workspace:*", "@trigger.dev/sdk": "workspace:*", "ai": "^6.0.116", + "drizzle-orm": "^0.45.0", "zod": "4.5.4" }, "devDependencies": { diff --git a/internal-packages/dashboard-agent/src/transcript-storage.test.ts b/internal-packages/dashboard-agent/src/transcript-storage.test.ts new file mode 100644 index 00000000000..bde2118d214 --- /dev/null +++ b/internal-packages/dashboard-agent/src/transcript-storage.test.ts @@ -0,0 +1,34 @@ +import { createDashboardAgentDb, type DashboardAgentDbClient } from "@internal/dashboard-agent-db"; +import { applyDashboardAgentMigrations } from "@internal/dashboard-agent-db/testing"; +import { createStandalonePostgresContainer } from "@internal/testcontainers"; +import { runTranscriptStorageTests } from "@trigger.dev/sdk/ai/test"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { dashboardAgentTranscriptStorage } from "./transcript-storage"; + +type StartedContainer = { getConnectionUri(): string; stop(): Promise }; + +let container: StartedContainer | undefined; +let client: DashboardAgentDbClient | undefined; + +beforeAll(async () => { + const started = (await createStandalonePostgresContainer()) as { + url?: string; + container: StartedContainer; + }; + container = started.container; + client = createDashboardAgentDb(started.url ?? started.container.getConnectionUri(), { max: 2 }); + await applyDashboardAgentMigrations((statement) => client!.sql.unsafe(statement)); +}); + +afterAll(async () => { + await client?.close(); + await container?.stop(); +}); + +describe("dashboardAgentTranscriptStorage", () => { + runTranscriptStorageTests(() => dashboardAgentTranscriptStorage(client!.db), { + api: { describe, it, expect }, + chatId: "dashboard-agent-conformance", + clientData: { organizationId: "org_conformance", userId: "user_conformance" }, + }); +}); diff --git a/internal-packages/dashboard-agent/src/transcript-storage.ts b/internal-packages/dashboard-agent/src/transcript-storage.ts new file mode 100644 index 00000000000..fd6f5e4f60c --- /dev/null +++ b/internal-packages/dashboard-agent/src/transcript-storage.ts @@ -0,0 +1,167 @@ +import { chatMessages, chats, type DashboardAgentDb } from "@internal/dashboard-agent-db"; +import type { TranscriptStorage } from "@trigger.dev/sdk/ai"; +import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm"; +import type { UIMessage } from "ai"; + +export type DashboardAgentTranscriptClientData = { + organizationId: string; + userId: string; +}; + +/** + * The dashboard agent's transcript as a `TranscriptStorage`: one row per + * message in `chat_messages`, the runtime's state and cursors on the chat row. + * + * This is the real-schema conformance target for the storage contract. The + * agent itself still persists through its hooks; moving it onto `storage` is + * separate work. + */ +export function dashboardAgentTranscriptStorage( + db: DashboardAgentDb +): TranscriptStorage { + return { + async load(scope, opts) { + // Scope the chat lookup by tenant, so a mismatched clientData cannot read + // another org or user's transcript even with a valid chatId. The message + // reads below run only after this row check passes. + const chat = await db + .select({ state: chats.transcriptState, cursors: chats.transcriptCursors }) + .from(chats) + .where( + and( + eq(chats.id, scope.chatId), + eq(chats.organizationId, scope.clientData.organizationId), + eq(chats.userId, scope.clientData.userId), + isNull(chats.deletedAt) + ) + ) + .limit(1); + const row = chat[0]; + if (!row) return { messages: [], state: null }; + + const conditions = [eq(chatMessages.chatId, scope.chatId)]; + if (opts?.before !== undefined) { + const anchor = await positionOf(db, scope.chatId, opts.before); + if (anchor !== undefined) conditions.push(lt(chatMessages.position, anchor)); + } + + let rows: { messageId: string; message: unknown }[]; + let nextCursor: string | undefined; + if (opts?.limit !== undefined) { + const newestFirst = await db + .select({ messageId: chatMessages.messageId, message: chatMessages.message }) + .from(chatMessages) + .where(and(...conditions)) + .orderBy(desc(chatMessages.position)) + .limit(opts.limit + 1); + const hasMore = newestFirst.length > opts.limit; + rows = newestFirst.slice(0, opts.limit).reverse(); + nextCursor = hasMore ? rows[0]?.messageId : undefined; + } else { + rows = await db + .select({ messageId: chatMessages.messageId, message: chatMessages.message }) + .from(chatMessages) + .where(and(...conditions)) + .orderBy(asc(chatMessages.position)); + } + + return { + messages: rows.map((r) => r.message as UIMessage) as never, + state: row.state ?? null, + cursors: row.cursors ?? undefined, + nextCursor, + }; + }, + + async save(ctx, changeset) { + await db.transaction(async (tx) => { + await tx + .insert(chats) + .values({ + id: ctx.chatId, + organizationId: ctx.clientData.organizationId, + userId: ctx.clientData.userId, + }) + .onConflictDoNothing(); + await tx.select({ id: chats.id }).from(chats).where(eq(chats.id, ctx.chatId)).for("update"); + + for (const change of changeset.changes) { + switch (change.op) { + case "put": { + const message = change.message; + const updated = await tx + .update(chatMessages) + .set({ message, role: message.role }) + .where( + and(eq(chatMessages.chatId, ctx.chatId), eq(chatMessages.messageId, message.id)) + ) + .returning({ messageId: chatMessages.messageId }); + if (updated.length > 0) break; + const reserved = await tx + .update(chats) + .set({ + nextMessagePosition: sql`${chats.nextMessagePosition} + 1`, + lastMessageAt: sql`now()`, + updatedAt: sql`now()`, + }) + .where(eq(chats.id, ctx.chatId)) + .returning({ next: chats.nextMessagePosition }); + const position = reserved[0]!.next - 1; + await tx.insert(chatMessages).values({ + chatId: ctx.chatId, + messageId: message.id, + position, + role: message.role, + message, + }); + break; + } + case "remove": { + await tx + .delete(chatMessages) + .where( + and(eq(chatMessages.chatId, ctx.chatId), eq(chatMessages.messageId, change.id)) + ); + break; + } + case "truncateAfter": { + const anchor = await positionOf(tx, ctx.chatId, change.afterId); + if (anchor === undefined) break; + await tx + .delete(chatMessages) + .where(and(eq(chatMessages.chatId, ctx.chatId), gt(chatMessages.position, anchor))); + break; + } + case "state": { + await tx + .update(chats) + .set({ transcriptState: change.value ?? null, updatedAt: sql`now()` }) + .where(eq(chats.id, ctx.chatId)); + break; + } + } + } + + if (changeset.cursors) { + await tx + .update(chats) + .set({ transcriptCursors: changeset.cursors, updatedAt: sql`now()` }) + .where(eq(chats.id, ctx.chatId)); + } + }); + }, + }; +} + +async function positionOf( + db: Pick, + chatId: string, + messageId: string +): Promise { + const rows = await db + .select({ position: chatMessages.position }) + .from(chatMessages) + .where(and(eq(chatMessages.chatId, chatId), inArray(chatMessages.messageId, [messageId]))) + .limit(1); + return rows[0]?.position; +} diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index cbcd51d6c97..a74feb8ed69 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -51,6 +51,7 @@ import { CreateSessionStreamWaitpointResponseBody, CreateStreamResponseBody, CreateUploadPayloadUrlResponseBody, + SessionTranscriptResponseBody, CreateWaitpointTokenResponseBody, CreatedSessionResponseBody, DeletedScheduleObject, @@ -711,6 +712,30 @@ export class ApiClient { ); } + /** + * One page of a `chat.agent` session's persisted transcript, most recent + * messages first when `limit` is set. Secret key only. + */ + getSessionTranscript( + sessionId: string, + options?: { limit?: number; before?: string }, + requestOptions?: ZodFetchOptions + ) { + const query = new URLSearchParams(); + if (options?.limit !== undefined) query.set("limit", String(options.limit)); + if (options?.before !== undefined) query.set("before", options.before); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return zodfetch( + SessionTranscriptResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/transcript${suffix}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + retrieveRun(runId: string, requestOptions?: ZodFetchOptions) { return zodfetch( RetrieveRunResponse, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 07996ed2035..e574f44bbc8 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1040,6 +1040,21 @@ export const CreateUploadPayloadUrlResponseBody = z.object({ storagePath: z.string().optional(), }); +/** One page of a `chat.agent` session transcript, from `GET /api/v1/sessions/{id}/transcript`. */ +export const SessionTranscriptResponseBody = z.object({ + messages: z.array(z.unknown()), + state: z.unknown().nullable(), + cursors: z + .object({ + lastOutEventId: z.string().optional(), + lastInEventId: z.string().optional(), + }) + .optional(), + nextCursor: z.string().optional(), +}); + +export type SessionTranscriptResponseBody = z.infer; + export const WorkersListResponseBody = z .object({ type: z.string(), diff --git a/packages/core/src/v3/sessionStreams/chatSnapshot.ts b/packages/core/src/v3/sessionStreams/chatSnapshot.ts index 84a3bc5a4da..75611fa2d0b 100644 --- a/packages/core/src/v3/sessionStreams/chatSnapshot.ts +++ b/packages/core/src/v3/sessionStreams/chatSnapshot.ts @@ -19,6 +19,8 @@ import { z } from "zod/v4"; +import type { UIMessage } from "ai"; + export type ChatSnapshotV1 = { version: 1; savedAt: number; @@ -42,12 +44,138 @@ export type ChatSnapshotV1 = { */ export const ChatSnapshotV1Schema = z.object({ version: z.literal(1), - savedAt: z.number(), + savedAt: z.number().optional(), messages: z.array(z.unknown()), lastOutEventId: z.string().optional(), lastInEventId: z.string().optional(), }); +/** + * One transcript entry in a version 2 snapshot. `id` duplicates + * `message.id` so a reader can address entries without inspecting the + * message body; `final` is false for a partial assistant message captured + * by an errored or stopped turn. + */ +export type TranscriptSnapshotEntry = { + id: string; + final: boolean; + message: TUIMessage; +}; + +/** + * Version 2 of the persisted transcript blob. Entries are ordered by array + * position. `state` is an opaque record the runtime uses for compaction and + * other cross-run bookkeeping; `null` when nothing has been recorded. + * + * Readers must accept version 1 as well; writers only emit version 2. Use + * {@link parseTranscriptSnapshot} to read either. + */ +export type TranscriptSnapshotV2 = { + version: 2; + savedAt: number; + messages: TranscriptSnapshotEntry[]; + state: unknown | null; + lastOutEventId?: string; + lastInEventId?: string; +}; + +export const TranscriptSnapshotV2Schema = z.object({ + version: z.literal(2), + savedAt: z.number().optional(), + messages: z.array( + z.object({ + id: z.string(), + final: z.boolean(), + message: z.unknown(), + }) + ), + state: z.unknown().nullable(), + lastOutEventId: z.string().optional(), + lastInEventId: z.string().optional(), +}); + +/** + * Parse a fetched snapshot blob of any known version into the version 2 + * shape. A version 1 blob is upgraded in memory: every message becomes a + * `final: true` entry keyed by its `id` and `state` is `null`. In both + * versions, entries without a non-empty string `id` or a non-null object + * `message` are dropped; a version 2 entry whose `message.id` disagrees with + * the envelope `id` is dropped too, since a reader keys by one and renders by + * the other. A caller never sees an entry it would crash on or mis-order. + * A missing `savedAt` defaults to `0` rather than rejecting the whole blob: + * the field only orders snapshot history before live chunks, and dropping a + * whole conversation over an absent timestamp is the wrong failure mode. + * Returns `undefined` for an unknown version or a body that is not a + * snapshot; callers treat that as "no snapshot". + */ +export function parseTranscriptSnapshot( + input: unknown +): TranscriptSnapshotV2 | undefined { + const v2 = TranscriptSnapshotV2Schema.safeParse(input); + if (v2.success) { + const messages: TranscriptSnapshotEntry[] = []; + for (const entry of v2.data.messages) { + if (entry.id.length === 0) continue; + if (typeof entry.message !== "object" || entry.message === null) continue; + if ((entry.message as { id?: unknown }).id !== entry.id) continue; + messages.push({ id: entry.id, final: entry.final, message: entry.message as TUIMessage }); + } + return { + version: 2, + savedAt: v2.data.savedAt ?? 0, + messages, + state: v2.data.state ?? null, + lastOutEventId: v2.data.lastOutEventId, + lastInEventId: v2.data.lastInEventId, + }; + } + + const v1 = ChatSnapshotV1Schema.safeParse(input); + if (v1.success) { + const messages: TranscriptSnapshotEntry[] = []; + for (const raw of v1.data.messages) { + const id = (raw as { id?: unknown } | null)?.id; + if (typeof id !== "string" || id.length === 0) continue; + messages.push({ id, final: true, message: raw as TUIMessage }); + } + return { + version: 2, + savedAt: v1.data.savedAt ?? 0, + messages, + state: null, + lastOutEventId: v1.data.lastOutEventId, + lastInEventId: v1.data.lastInEventId, + }; + } + + return undefined; +} + +/** + * Select one page of transcript entries, newest last. `before` keeps only the + * entries ordered before that id; `limit` keeps the last that many. A + * non-positive `limit` is treated as no limit (every entry, no cursor), so a + * caller cannot mistake an empty page for the end of the transcript. The + * returned `nextCursor` is the id to pass as `before` for the previous page, + * absent when there is no earlier page. + */ +export function pageTranscriptEntries( + all: TranscriptSnapshotEntry[], + opts: { limit?: number; before?: string } | undefined +): { entries: TranscriptSnapshotEntry[]; nextCursor: string | undefined } { + let entries = all; + if (opts?.before !== undefined) { + const idx = entries.findIndex((e) => e.id === opts.before); + if (idx !== -1) entries = entries.slice(0, idx); + } + let nextCursor: string | undefined; + if (opts?.limit !== undefined && opts.limit > 0 && entries.length > opts.limit) { + entries = entries.slice(entries.length - opts.limit); + nextCursor = entries[0]?.id; + } + return { entries, nextCursor }; +} + /** * S3 key suffix for a session's snapshot blob. The webapp's presigned * URL routes prefix this with `packets/{projectRef}/{envSlug}/`. diff --git a/packages/core/src/v3/test/test-session-stream-manager.ts b/packages/core/src/v3/test/test-session-stream-manager.ts index 5388c9dc40e..f35c652c29f 100644 --- a/packages/core/src/v3/test/test-session-stream-manager.ts +++ b/packages/core/src/v3/test/test-session-stream-manager.ts @@ -220,7 +220,11 @@ export class TestSessionStreamManager implements SessionStreamManager { } setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { - this.seqNums.set(keyFor(sessionId, io), seqNum); + const key = keyFor(sessionId, io); + const current = this.seqNums.get(key); + if (current === undefined || seqNum > current) { + this.seqNums.set(key, seqNum); + } } consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void { diff --git a/packages/core/test/chatSnapshot.test.ts b/packages/core/test/chatSnapshot.test.ts new file mode 100644 index 00000000000..e496fc66d63 --- /dev/null +++ b/packages/core/test/chatSnapshot.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + pageTranscriptEntries, + parseTranscriptSnapshot, + type ChatSnapshotV1, + type TranscriptSnapshotEntry, + type TranscriptSnapshotV2, +} from "../src/v3/sessionStreams/chatSnapshot.js"; + +describe("pageTranscriptEntries", () => { + const entries: TranscriptSnapshotEntry[] = ["a", "b", "c", "d", "e"].map((id) => ({ + id, + final: true, + message: { id, role: "user", parts: [] }, + })); + + it("returns everything with no options and no cursor", () => { + expect(pageTranscriptEntries(entries, undefined)).toEqual({ entries, nextCursor: undefined }); + }); + + it("returns the newest `limit` entries with the cursor for the page before", () => { + const page = pageTranscriptEntries(entries, { limit: 2 }); + expect(page.entries.map((e) => e.id)).toEqual(["d", "e"]); + expect(page.nextCursor).toBe("d"); + }); + + it("pages backwards with `before` until the cursor runs out", () => { + const page = pageTranscriptEntries(entries, { limit: 2, before: "d" }); + expect(page.entries.map((e) => e.id)).toEqual(["b", "c"]); + expect(page.nextCursor).toBe("b"); + const last = pageTranscriptEntries(entries, { limit: 2, before: "b" }); + expect(last.entries.map((e) => e.id)).toEqual(["a"]); + expect(last.nextCursor).toBeUndefined(); + }); + + it("ignores an unknown `before` id", () => { + expect(pageTranscriptEntries(entries, { before: "zz" }).entries).toHaveLength(5); + }); + + it("treats a zero limit as no limit rather than an empty page", () => { + const page = pageTranscriptEntries(entries, { limit: 0 }); + expect(page.entries).toEqual(entries); + expect(page.nextCursor).toBeUndefined(); + }); +}); + +const user = { id: "u-1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] }; +const assistant = { + id: "a-1", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "hello" }], +}; + +describe("parseTranscriptSnapshot", () => { + it("returns a version 2 blob unchanged", () => { + const blob: TranscriptSnapshotV2 = { + version: 2, + savedAt: 10, + messages: [ + { id: "u-1", final: true, message: user }, + { id: "a-1", final: false, message: assistant }, + ], + state: { summary: "s", through: "u-1" }, + lastOutEventId: "9", + lastInEventId: "3", + }; + + expect(parseTranscriptSnapshot(blob)).toEqual(blob); + }); + + it("normalises a version 2 blob with no state to state: null", () => { + const parsed = parseTranscriptSnapshot({ + version: 2, + savedAt: 10, + messages: [], + state: undefined, + }); + + expect(parsed?.state).toBeNull(); + }); + + it("upgrades a version 1 blob: every message final, state null, cursors kept", () => { + const blob: ChatSnapshotV1 = { + version: 1, + savedAt: 10, + messages: [user, assistant], + lastOutEventId: "9", + lastInEventId: "3", + }; + + expect(parseTranscriptSnapshot(blob)).toEqual({ + version: 2, + savedAt: 10, + messages: [ + { id: "u-1", final: true, message: user }, + { id: "a-1", final: true, message: assistant }, + ], + state: null, + lastOutEventId: "9", + lastInEventId: "3", + }); + }); + + it("drops version 1 messages that have no string id", () => { + const parsed = parseTranscriptSnapshot({ + version: 1, + savedAt: 10, + messages: [{ role: "user", parts: [] }, { id: 7, role: "user", parts: [] }, assistant, null], + }); + + expect(parsed?.messages.map((m) => m.id)).toEqual(["a-1"]); + }); + + it("drops version 2 entries whose message is null or not an object", () => { + const parsed = parseTranscriptSnapshot({ + version: 2, + savedAt: 10, + messages: [ + { id: "u-1", final: true, message: user }, + { id: "bad-1", final: true, message: null }, + { id: "bad-2", final: false, message: "oops" }, + { id: "", final: true, message: assistant }, + { id: "a-1", final: true, message: assistant }, + ], + state: null, + }); + + expect(parsed?.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + }); + + it("drops version 2 entries whose message.id disagrees with the envelope id", () => { + const parsed = parseTranscriptSnapshot({ + version: 2, + savedAt: 10, + messages: [ + { id: "u-1", final: true, message: user }, + { id: "mismatch", final: true, message: assistant }, + { id: "a-1", final: true, message: assistant }, + ], + state: null, + }); + + expect(parsed?.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + }); + + it("returns undefined for unknown versions and non-snapshot bodies", () => { + expect(parseTranscriptSnapshot({ version: 3, savedAt: 1, messages: [] })).toBeUndefined(); + expect( + parseTranscriptSnapshot({ version: 2, savedAt: 1, messages: [{ id: "x" }] }) + ).toBeUndefined(); + expect(parseTranscriptSnapshot({ version: 1, savedAt: "1", messages: [] })).toBeUndefined(); + expect(parseTranscriptSnapshot({ message: "Not Found" })).toBeUndefined(); + expect(parseTranscriptSnapshot(undefined)).toBeUndefined(); + expect(parseTranscriptSnapshot([])).toBeUndefined(); + }); +}); diff --git a/packages/trigger-sdk/skills/trigger-authoring-chat-agent/SKILL.md b/packages/trigger-sdk/skills/trigger-authoring-chat-agent/SKILL.md index e008ab291ae..25ffe3c6c30 100644 --- a/packages/trigger-sdk/skills/trigger-authoring-chat-agent/SKILL.md +++ b/packages/trigger-sdk/skills/trigger-authoring-chat-agent/SKILL.md @@ -222,8 +222,9 @@ frontend, narrow `useChat` with `InferChatUIMessage` from `@trigg `chat.agent` accepts hooks that fire in a fixed per-turn order: ```text -onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only) - -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete +onValidateMessages -> storage.loadContext (or the deprecated hydrateMessages) + -> onChatStart (chat's first message only) + -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete -> storage.save ``` `onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where diff --git a/packages/trigger-sdk/skills/trigger-chat-agent-advanced/SKILL.md b/packages/trigger-sdk/skills/trigger-chat-agent-advanced/SKILL.md index 052411df61d..72a7b727877 100644 --- a/packages/trigger-sdk/skills/trigger-chat-agent-advanced/SKILL.md +++ b/packages/trigger-sdk/skills/trigger-chat-agent-advanced/SKILL.md @@ -193,11 +193,11 @@ compaction: { to also emit a model response, built with the `streamText` from `onAction`'s own argument so it carries the agent's prompt and tools like any other turn. -Persistence splits by model. Without `hydrateMessages` the runtime snapshots the conversation after -an action that changed it, so a rollback or a returned response survives the run ending. With -`hydrateMessages` your store is the source of truth and the runtime does not write, so mirror every -mutation yourself: a regenerate is a delete and an insert, and `chat.pipeAndCapture` hands back the -same assistant message the runtime would have captured. +Persistence goes through the agent's transcript storage (`storage` on `chat.agent`; the platform +snapshot by default). After an action that changed the conversation the runtime hands the storage a +changeset: an undo is one `truncateAfter`, a regenerate is a `truncateAfter` plus the new answer's +`put`. With the deprecated `hydrateMessages` your store is the source of truth and the runtime does +not write, so mirror every mutation yourself: a regenerate is a delete and an insert. ```ts export const myChat = chat.agent({ diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index d12c90cecb3..03ca519c8f8 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -76,6 +76,34 @@ import { streamText as aiStreamText, zodSchema, } from "../imports/ai-runtime.js"; +import { + createTranscriptShadow, + defaultStorage, + diffTranscript, + parseTranscriptRuntimeState, + prefixFingerprint, + restoreModelLane, + type TranscriptChange, + type TranscriptChangeReason, + type TranscriptLoadResult, + type TranscriptRuntimeState, + type TranscriptShadow, + type TranscriptStorage, + type TranscriptStorageContext, +} from "./transcriptStorage.js"; + +let transcriptStorageOverride: TranscriptStorage | undefined; + +/** + * Test-only override for the storage `chat.agent` persists through, so a + * test can capture the exact changesets the runtime produces. + * @internal + */ +export function __setTranscriptStorageForTests( + storage: TranscriptStorage | undefined +): void { + transcriptStorageOverride = storage; +} import { type ChatInputChunk, type ChatTaskWirePayload, @@ -240,16 +268,13 @@ async function findLatestSessionInCursor(chatId: string): Promise = /** @internal */ export type { ChatInputChunk, ChatTaskWirePayload }; -/** - * Test-only override hook — `mockChatAgent` installs a fake to return - * synthetic snapshots without hitting S3. Mirrors the `__set*ImplForTests` - * pattern in `sessions.ts`. Not part of the public API. - * @internal - */ -type ReadChatSnapshotImpl = ( - sessionId: string -) => Promise | undefined> | ChatSnapshotV1 | undefined; -let readChatSnapshotImpl: ReadChatSnapshotImpl | undefined; - -export function __setReadChatSnapshotImplForTests(impl: ReadChatSnapshotImpl | undefined): void { - readChatSnapshotImpl = impl; -} - -/** - * Test-only override hook — see `__setReadChatSnapshotImplForTests`. The - * mock harness records writes for assertion via this setter. Not public. - * @internal - */ -type WriteChatSnapshotImpl = ( - sessionId: string, - snapshot: ChatSnapshotV1 -) => Promise | void; -let writeChatSnapshotImpl: WriteChatSnapshotImpl | undefined; - -export function __setWriteChatSnapshotImplForTests(impl: WriteChatSnapshotImpl | undefined): void { - writeChatSnapshotImpl = impl; -} - -/** - * Read the persisted snapshot for a session. Returns `undefined` on: - * - missing object (404 from the presigned GET — fresh session, never - * persisted) - * - presign failure (network/auth issue) - * - malformed JSON - * - version mismatch (forward-compat — older runtimes ignore newer blobs) - * - * Always swallows errors via `logger.warn`. The agent boot loop must stay - * available even if S3 hiccups; the worst case is replaying more of - * `session.out` than strictly necessary. - * @internal - */ -async function readChatSnapshot( - sessionId: string -): Promise | undefined> { - if (readChatSnapshotImpl) { - return (await readChatSnapshotImpl(sessionId)) ?? undefined; - } - const apiClient = apiClientManager.clientOrThrow(); - let presignedUrl: string; - try { - const resp = await apiClient.getChatSnapshotUrl(sessionId); - presignedUrl = resp.presignedUrl; - } catch (error) { - logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", { - error: error instanceof Error ? error.message : String(error), - sessionId, - }); - return undefined; - } - let response: Response; - try { - response = await fetch(presignedUrl, { method: "GET" }); - } catch (error) { - logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", { - error: error instanceof Error ? error.message : String(error), - sessionId, - }); - return undefined; - } - if (response.status === 404) { - // First-ever boot for this session — no snapshot yet. Caller falls - // through to replay-only. - return undefined; - } - if (!response.ok) { - logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", { - status: response.status, - sessionId, - }); - return undefined; - } - let parsed: unknown; - try { - parsed = await response.json(); - } catch (error) { - logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", { - error: error instanceof Error ? error.message : String(error), - sessionId, - }); - return undefined; - } - if (!parsed || typeof parsed !== "object") return undefined; - const candidate = parsed as Partial>; - if (candidate.version !== 1 || !Array.isArray(candidate.messages)) { - logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", { - version: candidate.version, - sessionId, - }); - return undefined; - } - return candidate as ChatSnapshotV1; -} - -/** - * Persist the snapshot for a session. Awaited by callers immediately after - * `onTurnComplete` — the agent may suspend right after this point, and - * fire-and-forget promises don't reliably complete on suspend. - * - * Errors are swallowed via `logger.warn`. A failed write means the next - * boot replays slightly more of `session.out` (back to the previous - * snapshot's cursor) instead of failing — the conversation stays - * coherent, only the boot path does marginally more work. - * @internal - */ -async function writeChatSnapshot( - sessionId: string, - snapshot: ChatSnapshotV1 -): Promise { - if (writeChatSnapshotImpl) { - await writeChatSnapshotImpl(sessionId, snapshot); - return; - } - const apiClient = apiClientManager.clientOrThrow(); - let presignedUrl: string; - try { - const resp = await apiClient.createChatSnapshotUploadUrl(sessionId); - presignedUrl = resp.presignedUrl; - } catch (error) { - logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", { - error: error instanceof Error ? error.message : String(error), - sessionId, - }); - return; - } - let response: Response; - try { - response = await fetch(presignedUrl, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(snapshot), - }); - } catch (error) { - logger.warn("chat.agent: snapshot upload failed; next run will replay further", { - error: error instanceof Error ? error.message : String(error), - sessionId, - }); - return; - } - if (!response.ok) { - logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", { - status: response.status, - sessionId, - }); - } -} - -/** - * Test-only entry point that bypasses `__setReadChatSnapshotImplForTests` - * and reaches the real `apiClient.getPayloadUrl` + `fetch` + JSON-parse path. - * Used by `chat-snapshot.test.ts` to verify 404 / 500 / malformed JSON / - * version-mismatch / network-error behavior end-to-end. Tests mock global - * `fetch` and the api-client config; this wrapper lets them drive the - * production code without the override hook short-circuiting. - * - * Not part of the public API. The `__` prefix and `ForTests` suffix mirror - * the override-hook setters above. - * @internal - */ -export async function __readChatSnapshotProductionPathForTests( - sessionId: string -): Promise | undefined> { - const saved = readChatSnapshotImpl; - readChatSnapshotImpl = undefined; - try { - return await readChatSnapshot(sessionId); - } finally { - readChatSnapshotImpl = saved; - } -} - -/** - * Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests` - * and reaches the real `apiClient.createUploadPayloadUrl` + `fetch` PUT - * path. Pairs with `__readChatSnapshotProductionPathForTests` — see that - * function's note for the rationale. - * - * Not part of the public API. - * @internal - */ -export async function __writeChatSnapshotProductionPathForTests( - sessionId: string, - snapshot: ChatSnapshotV1 -): Promise { - const saved = writeChatSnapshotImpl; - writeChatSnapshotImpl = undefined; - try { - await writeChatSnapshot(sessionId, snapshot); - } finally { - writeChatSnapshotImpl = saved; - } -} +export { + __readChatSnapshotProductionPathForTests, + __setReadChatSnapshotImplForTests, + __setWriteChatSnapshotImplForTests, + __writeChatSnapshotProductionPathForTests, +} from "./chatSnapshotIo.js"; + +export { + defaultStorage, + memoryTranscriptStorage, + reduceTranscriptChanges, + snapshotTranscriptStorage, + type LoadContextEvent, + type MemoryTranscriptStorage, + type TranscriptChange, + type TranscriptChangeReason, + type TranscriptChangeset, + type TranscriptCursors, + type TranscriptLoadOptions, + type TranscriptLoadResult, + type TranscriptScope, + type TranscriptState, + type TranscriptStorage, + type TranscriptStorageContext, +} from "./transcriptStorage.js"; /** * Merge two `UIMessage[]` lists by `id`, with the second list winning on @@ -2734,6 +2581,13 @@ function spliceHandoverPartial( * @internal */ const chatBackgroundQueueKey = locals.create("chat.backgroundQueue"); +/** + * Background injections a step-boundary drain handed to the model this turn, + * with the transcript message they followed. Reconciled into the model lane + * and the persisted injections once the turn's response is in. + */ +const chatPendingBackgroundKey = + locals.create<{ afterId: string; messages: ModelMessage[] }[]>("chat.pendingBackground"); /** * System-role context injected mid-conversation, held for the instructions lane. @@ -5214,6 +5068,13 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { const injected = bgQueue.splice(0); // drain resultMessages = [...(resultMessages ?? messages), ...injected]; + const pendingBackground = locals.get(chatPendingBackgroundKey) ?? []; + pendingBackground.push({ + afterId: + (locals.get(chatCurrentUIMessagesKey) as UIMessage[] | undefined)?.at(-1)?.id ?? "", + messages: injected, + }); + locals.set(chatPendingBackgroundKey, pendingBackground); } return resultMessages ? { messages: resultMessages } : undefined; @@ -5353,6 +5214,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { ); } +const warnedHydrateMessagesDeprecated = new Set(); +function warnHydrateMessagesDeprecatedOnce(agentId: string) { + if (warnedHydrateMessagesDeprecated.has(agentId)) return; + warnedHydrateMessagesDeprecated.add(agentId); + console.warn( + `[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` + + "storage instead: `save` receives every change to the conversation and `loadContext` " + + "lets the application own the model's context, with crash recovery and durable " + + "compaction that `hydrateMessages` never had." + ); +} + let warnedMissingOnAction = false; function warnMissingOnActionOnce() { if (warnedMissingOnAction) return; @@ -5563,8 +5436,9 @@ export type RecoveryPendingToolCall = { * `chat.endRun()` with no buffered user messages, fresh chat, OOM retry * after a successful turn-complete with no in-flight tail). * - * Does NOT fire when `hydrateMessages` is registered (the customer owns - * persistence; recovery decisions live in their own DB query). + * Fires regardless of who owns the model's context. With `hydrateMessages` + * or a storage `loadContext`, the recovered tail reaches that hook in + * `previousMessages` on the next turn. */ export type RecoveryBootEvent = { /** Task run context — same as `task({ run })` second-argument `ctx`. */ @@ -5634,8 +5508,9 @@ export type RecoveryBootResult = { * context, mutate its tool parts to inject synthesized results, * collapse history, etc. * - * Ignored when `hydrateMessages` is registered (the hydrate hook - * runs per-turn and overwrites the chain). + * With `hydrateMessages` or a storage `loadContext`, this chain is what + * the hook receives as `previousMessages` on the next turn; the hook's + * return value is the chain the model sees. */ chain?: TUIM[]; /** @@ -6220,9 +6095,9 @@ export type ChatAgentOptions< * continuation after `chat.endRun()` with no buffered user, a fresh * chat, or an OOM retry on top of a complete snapshot. * - * Does NOT fire when `hydrateMessages` is registered — that hook owns - * the per-turn chain and overlapping recovery decisions belong in the - * customer's DB. + * Fires regardless of who owns the model's context; a `hydrateMessages` + * hook or a storage `loadContext` receives the recovered tail in + * `previousMessages` on the next turn. * * Defaults (returned when the hook is omitted or returns no field): * - With two or more in-flight users, the partial and the user it @@ -6363,6 +6238,33 @@ export type ChatAgentOptions< event: HydrateMessagesEvent, TUIMessage> ) => TUIMessage[] | Promise; + /** + * Where the conversation is persisted. The runtime calls `save` after + * every turn, failed turn and history-changing action with the changes + * since the last save, and `load` once when a new run boots to continue + * the conversation. + * + * Defaults to `defaultStorage`, the platform's snapshot in object storage + * that the Sessions dashboard renders. Bring your own to write each change + * to your database; `memoryTranscriptStorage()` is the reference + * implementation and `runTranscriptStorageTests` from + * `@trigger.dev/sdk/ai/test` checks yours against the contract. + * + * A storage with `loadContext` also owns the model's context on every + * turn, which is what `hydrateMessages` did. The two cannot be combined. + * + * @example + * ```ts + * chat.agent({ + * id: "my-chat", + * storage: myPostgresTranscriptStorage, + * run: async ({ messages, signal, streamText }) => + * streamText({ model, messages, abortSignal: signal }), + * }); + * ``` + */ + storage?: TranscriptStorage>; + /** * Called at the start of every turn, after message accumulation and `onChatStart` (turn 0), * but before the `run` function executes. @@ -6965,6 +6867,7 @@ function chatAgent< onChatStart, onValidateMessages, hydrateMessages, + storage, actionSchema, onAction, onTurnStart, @@ -6994,6 +6897,23 @@ function chatAgent< ...restOptions } = options; + if (hydrateMessages) { + if (storage) { + throw new Error( + `chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` + + "`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " + + "change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`." + ); + } + if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") { + throw new Error( + `chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` + + "`loadContext`. Both would own the model's context; keep one." + ); + } + warnHydrateMessagesDeprecatedOnce(options.id); + } + const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined; const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined; @@ -7104,6 +7024,23 @@ function chatAgent< // durable snapshot + `session.out` replay (or `hydrateMessages` if // registered) — the wire is delta-only now, no longer a seed. let accumulatedMessages: ModelMessage[] = []; + /** + * Give the model accumulator the background injections a step-boundary + * drain handed to the model this turn, and record them for persistence. + * Returns how many model messages were appended. + */ + const reconcilePendingBackground = (): number => { + const pending = locals.get(chatPendingBackgroundKey); + if (!pending || pending.length === 0) return 0; + locals.set(chatPendingBackgroundKey, []); + let appended = 0; + for (const entry of pending) { + accumulatedMessages.push(...entry.messages); + laneInjections.push(entry); + appended += entry.messages.length; + } + return appended; + }; /** * Give the model accumulator the steering messages a drain consumed, * in the form the model actually received. Appended, never reconverted @@ -7143,7 +7080,48 @@ function chatAgent< // collectively cost ~600ms on every first-message TTFC. Both reads // swallow errors internally; the agent stays available either way. const sessionIdForSnapshot = payload.sessionId ?? payload.chatId; - let bootSnapshot: ChatSnapshotV1 | undefined; + const transcriptStorage: TranscriptStorage = + (storage as TranscriptStorage | undefined) ?? + transcriptStorageOverride ?? + defaultStorage; + const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage); + /** + * Who supplies the model's context each turn: the deprecated + * `hydrateMessages` hook, the storage's `loadContext`, or (undefined) + * the runtime's own transcript. + */ + const loadContextHook = hydrateMessages + ? (event: HydrateMessagesEvent, TUIMessage>) => + hydrateMessages(event) + : storageLoadContext + ? (event: HydrateMessagesEvent, TUIMessage>) => + storageLoadContext( + { chatId: event.chatId, clientData: event.clientData }, + event + ) + : undefined; + let transcriptShadow: TranscriptShadow = createTranscriptShadow([]); + let bootTranscriptState: unknown = null; + /** + * True while the model lane holds a compaction summary, so it cannot be + * rebuilt from the transcript and has to be persisted as state. Reset + * wherever the lane is reconverted from the UI lane. + */ + let laneCompacted = false; + /** Conversational `chat.inject` messages in the lane, anchored to the transcript. */ + let laneInjections: NonNullable = []; + let persistedStateSet = false; + let bootSnapshot: + | { messages: TUIMessage[]; lastOutEventId?: string; lastInEventId?: string } + | undefined; + let bootClientData: unknown = payload.metadata; + if (parseClientData) { + try { + bootClientData = await parseClientData(payload.metadata); + } catch { + bootClientData = payload.metadata; + } + } /** * The `lastOutEventId` the most recent snapshot carried. @@ -7155,33 +7133,114 @@ function chatAgent< */ let lastSnapshotOutEventId: string | undefined; + const storageTrigger = (trigger: string): TranscriptStorageContext["trigger"] => + trigger === "regenerate-message" + ? "regenerate-message" + : trigger === "action" || trigger === "action-turn" + ? "action" + : "submit-message"; + + /** + * Hand the runtime's view of the transcript to the storage as a + * changeset: the diff against what was last saved, plus the cursors the + * next boot resumes from. The shadow only advances when the save + * succeeds, so a failed save is folded into the next changeset. + */ + /** The runtime's opaque state as of the last save; carried on every changeset's transcript. */ + let transcriptState: unknown | null = null; + const saveTranscript = async (opts: { + reason: TranscriptChangeReason; + messages: TUIMessage[]; + turn: number; + trigger: TranscriptStorageContext["trigger"]; + clientData: unknown; + lastOutEventId: string | undefined; + nonFinalIds?: ReadonlySet; + }) => { + const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, { + nonFinalIds: opts.nonFinalIds, + }); + const throughId = opts.messages.at(-1)?.id ?? ""; + const queued = locals.get(chatBackgroundQueueKey) ?? []; + const runtimeState: TranscriptRuntimeState | null = + laneCompacted || laneInjections.length > 0 || queued.length > 0 + ? { + v: 1, + ...(laneCompacted + ? { + compaction: { + modelMessages: accumulatedMessages, + throughId, + fingerprint: prefixFingerprint(shadow, throughId), + }, + } + : {}), + ...(laneInjections.length > 0 ? { injections: laneInjections } : {}), + ...(queued.length > 0 ? { queued: [...queued] } : {}), + } + : null; + if (runtimeState !== null || persistedStateSet) { + changes.push({ op: "state", value: runtimeState } satisfies TranscriptChange); + } + transcriptState = runtimeState; + const inCursor = chatInputRouter().resumeFloor(); + await transcriptStorage.save( + { + chatId: payload.chatId, + clientData: opts.clientData, + turn: opts.turn, + trigger: opts.trigger, + runId: ctx.run.id, + ctx, + }, + { + reason: opts.reason, + changes, + transcript: { + entries: opts.messages.map((message) => ({ + id: message.id, + final: !shadow.nonFinal.has(message.id), + message, + })), + state: transcriptState, + }, + cursors: { + lastOutEventId: opts.lastOutEventId, + lastInEventId: inCursor !== undefined ? String(inCursor) : undefined, + }, + } + ); + transcriptShadow = shadow; + persistedStateSet = runtimeState !== null; + }; + /** * Persist the accumulator outside a turn. * * An action is not a turn, so it never reaches the turn-complete path where - * the snapshot is normally written — but it can change the conversation in - * two ways: a `chat.history` mutation, and a response streamed back from - * `onAction`. Both have to survive, and one write at the end of the action - * covers both rather than writing twice for a regenerate that does both. + * the transcript is normally saved, but a `chat.history` mutation changes + * the conversation and has to survive the run ending. * * Cursor-neutral: an action has no turn cursor of its own, and writing * `undefined` would drop the resume point the last turn established and make * the next boot replay from further back. */ - const writeSnapshotOutsideTurn = async (reason: string) => { + const writeSnapshotOutsideTurn = async ( + reason: string, + turnContext: { turn: number; clientData: unknown } + ) => { if (hydrateMessages) return; try { await tracer.startActiveSpan( "snapshot.write", async () => { - const snapshotInCursor = chatInputRouter().resumeFloor(); - await writeChatSnapshot(sessionIdForSnapshot, { - version: 1, - savedAt: Date.now(), + await saveTranscript({ + reason: "action", messages: accumulatedUIMessages, + turn: turnContext.turn, + trigger: "action", + clientData: turnContext.clientData, lastOutEventId: lastSnapshotOutEventId, - lastInEventId: - snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined, }); }, { @@ -7222,7 +7281,7 @@ function chatAgent< let bootInCursor: number | undefined; let bootInCursorResolved = false; - if (!hydrateMessages && couldHavePriorState) { + if (couldHavePriorState) { // Single parent span for the whole boot read phase — snapshot // read, session.out replay, session.in replay. Per-phase timing // + result counts are attributes on the span. @@ -7232,17 +7291,38 @@ function chatAgent< // snapshot read const snapStart = Date.now(); try { - bootSnapshot = await readChatSnapshot(sessionIdForSnapshot); + const loaded = hydrateMessages + ? undefined + : await transcriptStorage.load({ + chatId: payload.chatId, + clientData: bootClientData, + }); + if (loaded) { + transcriptShadow = createTranscriptShadow( + loaded.messages, + new Set(loaded.nonFinalIds ?? []) + ); + bootTranscriptState = loaded.state; + transcriptState = loaded.state ?? null; + persistedStateSet = loaded.state !== null && loaded.state !== undefined; + bootSnapshot = { + messages: loaded.messages, + lastOutEventId: loaded.cursors?.lastOutEventId, + lastInEventId: loaded.cursors?.lastInEventId, + }; + } } catch (error) { - // `readChatSnapshot` already swallows + warns internally; this catch - // is just belt-and-suspenders against tracer/span errors. - logger.warn("chat.agent: snapshot read failed; continuing without snapshot", { + logger.warn("chat.agent: transcript load failed; continuing from the stream tail", { error: error instanceof Error ? error.message : String(error), sessionId: sessionIdForSnapshot, }); } bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart); - bootSpan.setAttribute("chat.boot.snapshot.present", !!bootSnapshot); + bootSpan.setAttribute( + "chat.boot.snapshot.present", + bootSnapshot !== undefined && + (bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined) + ); bootSpan.setAttribute( "chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0 @@ -7374,7 +7454,7 @@ function chatAgent< }); // ── Recovery boot + chain reconstruction ──────────────────────── - if (!hydrateMessages) { + { const settledMessages = mergeByIdReplaceWins( (bootSnapshot?.messages as TUIMessage[]) ?? [], replayedSettled @@ -7532,6 +7612,7 @@ function chatAgent< // and it's safe because the route handler isn't subject to the // `/in/append` 512 KiB cap. if ( + !loadContextHook && accumulatedUIMessages.length === 0 && payload.trigger === "handover-prepare" && Array.isArray(payload.headStartMessages) && @@ -7565,7 +7646,21 @@ function chatAgent< } } try { - accumulatedMessages = await toModelMessages(accumulatedUIMessages); + const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState); + const restored = await restoreModelLane( + accumulatedUIMessages, + bootRuntimeState, + (messages) => toModelMessages(messages) + ); + accumulatedMessages = restored.messages; + laneCompacted = restored.compacted; + laneInjections = restored.injections; + if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) { + locals.set(chatBackgroundQueueKey, [ + ...(locals.get(chatBackgroundQueueKey) ?? []), + ...bootRuntimeState.queued, + ]); + } } catch (error) { logger.warn("chat.agent: toModelMessages failed at boot; starting empty", { error: error instanceof Error ? error.message : String(error), @@ -8014,6 +8109,7 @@ function chatAgent< } for (let turn = 0; turn < maxTurns; turn++) { + let turnClientData: unknown = payload.metadata; // Declared here so the finally can detach it — a handler leaked past // its turn duplicates every mid-stream message into the shared buffer. let turnMsgSub: { off: () => void } | undefined; @@ -8044,6 +8140,7 @@ function chatAgent< const clientData = ( parseClientData ? await parseClientData(wireMetadata) : wireMetadata ) as inferSchemaOut; + turnClientData = clientData; const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages); // Actions are not turns. They use a different span name @@ -8092,6 +8189,7 @@ function chatAgent< locals.set(chatDeferKey, new Set()); locals.set(chatCompactionStateKey, undefined); locals.set(chatSteeringQueueKey, []); + locals.set(chatPendingBackgroundKey, []); locals.set(chatResponsePartsKey, []); // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected // by deferred work from the previous turn's onTurnComplete need to @@ -8212,11 +8310,11 @@ function chatAgent< : currentWirePayload.action; // Hydrate messages from backend if configured - if (hydrateMessages) { + if (loadContextHook) { const hydrated = await tracer.startActiveSpan( "hydrateMessages()", async () => { - return hydrateMessages({ + return loadContextHook({ chatId: currentWirePayload.chatId, turn, trigger: "action", @@ -8238,6 +8336,8 @@ function chatAgent< ); accumulatedUIMessages = [...hydrated] as TUIMessage[]; accumulatedMessages = await toModelMessages(hydrated); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); } @@ -8275,6 +8375,8 @@ function chatAgent< locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...actionOverride] as TUIMessage[]; accumulatedMessages = await toModelMessages(actionOverride); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); actionChangedHistory = true; @@ -8300,7 +8402,7 @@ function chatAgent< // incoming messages instead (gated on the pending handover). if ( turn === 0 && - hydrateMessages && + loadContextHook && cleanedUIMessages.length === 0 && (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 && Array.isArray(payload.headStartMessages) && @@ -8337,7 +8439,7 @@ function chatAgent< )) as TUIMessage[]; } - if (hydrateMessages) { + if (loadContextHook) { // Snapshot the ids the accumulator knew BEFORE this // turn ran — used below to decide whether an // incoming wire message is genuinely new or just a @@ -8360,7 +8462,7 @@ function chatAgent< const hydrated = await tracer.startActiveSpan( "hydrateMessages()", async () => { - return hydrateMessages({ + return loadContextHook({ chatId: currentWirePayload.chatId, turn, trigger: currentWirePayload.trigger as @@ -8407,6 +8509,8 @@ function chatAgent< accumulatedUIMessages = merged; accumulatedMessages = await toModelMessages(merged); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); // Track new messages for onTurnComplete.newUIMessages. @@ -8456,6 +8560,8 @@ function chatAgent< accumulatedUIMessages.pop(); } accumulatedMessages = await toModelMessages(accumulatedUIMessages); + laneCompacted = false; + laneInjections = []; } else if (cleanedUIMessages.length > 0) { // Submit-message (and the special-cased // handover-prepare → submit-message rewrite earlier in @@ -8509,6 +8615,8 @@ function chatAgent< "chat.agent: replaced message not found at the model lane tail; reconverting the lane" ); accumulatedMessages = await toModelMessages(accumulatedUIMessages); + laneCompacted = false; + laneInjections = []; } } else { const incomingModelMessages = await toModelMessages(cleanedUIMessages); @@ -8599,7 +8707,7 @@ function chatAgent< // history rather than from the snapshot the edit replaced. // The turn then does its own hooks, completion and snapshot. if (actionChangedHistory) { - await writeSnapshotOutsideTurn("action"); + await writeSnapshotOutsideTurn("action", { turn, clientData }); } actionTurn = true; } else if (actionResult !== undefined) { @@ -8611,7 +8719,7 @@ function chatAgent< } else { msgSub?.off(); if (actionChangedHistory) { - await writeSnapshotOutsideTurn("action"); + await writeSnapshotOutsideTurn("action", { turn, clientData }); } await writeTurnCompleteChunk(currentWirePayload.chatId); // Don't consume a turn iteration — actions aren't turns. @@ -8718,6 +8826,8 @@ function chatAgent< locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...turnStartOverride] as TUIMessage[]; accumulatedMessages = await toModelMessages(turnStartOverride); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); } }, @@ -8786,7 +8896,12 @@ function chatAgent< const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1]; const bgQueue = locals.get(chatBackgroundQueueKey); if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") { - accumulatedMessages.push(...bgQueue.splice(0)); + const injected = bgQueue.splice(0); + accumulatedMessages.push(...injected); + laneInjections.push({ + afterId: accumulatedUIMessages.at(-1)?.id ?? "", + messages: injected, + }); } if (isHeadStartFinalTurn) { @@ -8979,6 +9094,8 @@ function chatAgent< accumulatedMessages = await toModelMessages( runOverride.filter((m) => !pendingIds.has(m.id)) ); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); } @@ -9004,6 +9121,8 @@ function chatAgent< accumulatedMessages = taskCompactionConfig?.compactModelMessages ? await taskCompactionConfig.compactModelMessages(compactEvent) : modelOnlyOverride; + laneCompacted = true; + laneInjections = []; // Apply UI messages: callback or default (preserve all) if (taskCompactionConfig?.compactUIMessages) { @@ -9022,9 +9141,10 @@ function chatAgent< // before the response is appended so the order stays // steer-then-answer. Outside the `capturedResponseMessage` // branches below, so a turn that captured no response is covered. - const steerTailThisTurn = reconcilePendingSteer({ - turnNew: turnNewModelMessages, - }).reduce((n, e) => n + e.model.length, 0); + const steerTailThisTurn = + reconcilePendingSteer({ + turnNew: turnNewModelMessages, + }).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground(); // Append the assistant's response (partial or complete) to the accumulator. // The onFinish callback fires even on abort/stop, so partial responses @@ -9096,6 +9216,8 @@ function chatAgent< "chat.agent: replaced response not found at the model lane tail; reconverting the lane" ); accumulatedMessages = await toModelMessages(accumulatedUIMessages); + laneCompacted = false; + laneInjections = []; } } else { accumulatedMessages.push(...responseModelMessages); @@ -9215,6 +9337,9 @@ function chatAgent< }, ]; + laneCompacted = true; + laneInjections = []; + // UI messages: callback or default (preserve all) if (outerCompaction.compactUIMessages) { accumulatedUIMessages = (await outerCompaction.compactUIMessages( @@ -9319,6 +9444,8 @@ function chatAgent< locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...override] as TUIMessage[]; accumulatedMessages = await toModelMessages(override); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); // Update event so onTurnComplete sees compacted messages turnCompleteEvent.messages = accumulatedMessages; @@ -9378,6 +9505,8 @@ function chatAgent< locals.set(chatOverrideMessagesKey, undefined); accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[]; accumulatedMessages = await toModelMessages(turnCompleteOverride); + laneCompacted = false; + laneInjections = []; locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); } }, @@ -9436,16 +9565,19 @@ function chatAgent< await tracer.startActiveSpan( "snapshot.write", async () => { - const snapshotInCursor = chatInputRouter().resumeFloor(); lastSnapshotOutEventId = turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId; - await writeChatSnapshot(sessionIdForSnapshot, { - version: 1, - savedAt: Date.now(), + await saveTranscript({ + reason: "turn-complete", messages: accumulatedUIMessages, + turn, + trigger: storageTrigger(currentWirePayload.trigger), + clientData, lastOutEventId: lastSnapshotOutEventId, - lastInEventId: - snapshotInCursor !== undefined ? String(snapshotInCursor) : undefined, + nonFinalIds: + wasStopped && capturedResponseMessage?.id + ? new Set([capturedResponseMessage.id]) + : undefined, }); }, { @@ -9709,6 +9841,7 @@ function chatAgent< let erroredNewModelMessages: ModelMessage[] = []; const reconciledSteer = reconcilePendingSteer(); + const backgroundTailThisTurn = reconcilePendingBackground(); if (!responseCommitted) { try { @@ -9744,13 +9877,16 @@ function chatAgent< accumulatedMessages, erroredUIMessages[partialIdx]!, partialResponse!, - reconciledSteer.reduce((n, e) => n + e.model.length, 0) + reconciledSteer.reduce((n, e) => n + e.model.length, 0) + + backgroundTailThisTurn ); if (!ok) { logger.warn( "chat.agent: replaced partial not found at the model lane tail; reconverting the lane" ); accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial); + laneCompacted = false; + laneInjections = []; } } accumulatedUIMessages = erroredUIMessagesWithPartial; @@ -9818,14 +9954,15 @@ function chatAgent< // neither the snapshot nor the replayable `.in` tail. if (!hydrateMessages) { try { - const errorSnapshotInCursor = chatInputRouter().resumeFloor(); - await writeChatSnapshot(sessionIdForSnapshot, { - version: 1, - savedAt: Date.now(), + await saveTranscript({ + reason: "turn-error", messages: erroredUIMessagesWithPartial, - lastOutEventId: errorTurnCompleteResult?.lastEventId, - lastInEventId: - errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined, + turn, + trigger: storageTrigger(currentWirePayload.trigger), + clientData: turnClientData, + lastOutEventId: lastSnapshotOutEventId, + nonFinalIds: + includePartial && partialResponse ? new Set([partialResponse.id]) : undefined, }); } catch (error) { logger.warn("chat.agent: error-path snapshot write failed", { @@ -12964,6 +13101,68 @@ async function mintPublicTokenWithOverride(args: { }); } +export type CreateChatLoadTranscriptActionOptions = { + /** + * Scope the action to a specific API client configuration (secret key, + * base URL) instead of the process-wide one. The default storage reads + * through this client. + */ + apiClient?: ApiClientConfiguration; + /** Page size when the caller passes none. */ + limit?: number; +}; + +export type ChatLoadTranscriptParams = { + chatId: string; + clientData?: TClientData; + limit?: number; + before?: string; +}; + +/** + * Creates a server-side helper that reads a conversation from a transcript + * storage, for rendering history before the chat connects. Works the same + * for every storage, the platform default included, so the browser never + * reads a store directly and the secret key stays on the server. + * + * Wrap it in a Next.js server action (or any server-side handler), scope it + * to the authenticated user through `clientData`, and pass the result to + * `useLoadTranscript` in the browser. + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { chat, defaultStorage } from "@trigger.dev/sdk/ai"; + * + * export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 }); + * ``` + */ +function createChatLoadTranscriptAction( + storage: TranscriptStorage, + options?: CreateChatLoadTranscriptActionOptions +): (params: ChatLoadTranscriptParams) => Promise { + return async (params) => { + if (!params.chatId) { + throw new Error("chat.createLoadTranscriptAction: params.chatId is required."); + } + if (options?.apiClient) { + const { apiClient, ...rest } = options; + return apiClientManager.runWithConfig(apiClient, () => + createChatLoadTranscriptAction(storage, rest)(params) + ); + } + const limit = params.limit ?? options?.limit; + return storage.load( + { chatId: params.chatId, clientData: params.clientData as TClientData }, + { + ...(limit !== undefined ? { limit } : {}), + ...(params.before !== undefined ? { before: params.before } : {}), + } + ); + }; +} + export const chat = { /** Create a chat agent. See {@link chatAgent}. */ agent: chatAgent, @@ -12975,6 +13174,8 @@ export const chat = { withClientData, /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */ createStartSessionAction: createChatStartSessionAction, + /** Returns a server-side helper that reads a conversation from a transcript storage. */ + createLoadTranscriptAction: createChatLoadTranscriptAction, /** Pipe a stream to the chat transport. See {@link pipeChat}. */ pipe: pipeChat, /** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */ diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts index f238b8e12c1..f9fdaa47a19 100644 --- a/packages/trigger-sdk/src/v3/chat-react.ts +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -52,6 +52,128 @@ export type UseTriggerChatTransportOptions = Om export type { InferChatUIMessage }; export type { ChatTransportEvent, ChatTransportSendSource } from "./chat.js"; +/** What a `chat.createLoadTranscriptAction` action returns, as `useLoadTranscript` reads it. */ +export type LoadTranscriptResult = { + messages: TUIMessage[]; + cursors?: { lastOutEventId?: string; lastInEventId?: string }; + nextCursor?: string; +}; + +export type UseLoadTranscriptOptions = { + /** + * When given, the transport's resume cursor for this chat is seeded from + * 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`). + */ + transport?: TriggerChatTransport; + /** Page size passed to the action. */ + limit?: number; +}; + +/** + * Move the transport's resume cursor for `chatId` to the transcript's + * `lastOutEventId`, so the live subscription opens just past the persisted + * 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. + */ +export function seedTranscriptCursor( + transport: Pick, + chatId: string, + cursors: { lastOutEventId?: string } | undefined +): boolean { + const lastEventId = cursors?.lastOutEventId; + if (!lastEventId) return false; + transport.seedResumeCursor(chatId, lastEventId); + return true; +} + +/** + * Load a conversation's history through a server action created with + * `chat.createLoadTranscriptAction`, for rendering before the chat connects. + * + * Re-runs when `chatId` changes. Pass `undefined` to load nothing. + * + * @example + * ```tsx + * const { messages, isLoading } = useLoadTranscript(chatId, loadTranscript, { transport }); + * if (isLoading) return ; + * return ; + * ``` + */ +export function useLoadTranscript( + chatId: string | undefined, + load: (params: { chatId: string; limit?: number }) => Promise>, + options?: UseLoadTranscriptOptions +): { + messages: TUIMessage[]; + isLoading: boolean; + error: Error | undefined; + /** The id to pass as `before` to the action for the page before this one. */ + nextCursor: string | undefined; +} { + const [state, setState] = useState<{ + chatId: string | undefined; + messages: TUIMessage[]; + isLoading: boolean; + error: Error | undefined; + nextCursor: string | undefined; + }>({ + chatId, + messages: [], + isLoading: chatId !== undefined, + error: undefined, + nextCursor: undefined, + }); + + const loadRef = useRef(load); + loadRef.current = load; + const transportRef = useRef(options?.transport); + transportRef.current = options?.transport; + const limit = options?.limit; + + useEffect(() => { + if (chatId === undefined) { + setState({ chatId, messages: [], isLoading: false, error: undefined, nextCursor: undefined }); + return; + } + let cancelled = false; + 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); + } + setState({ + chatId, + messages: result.messages, + isLoading: false, + error: undefined, + nextCursor: result.nextCursor, + }); + }) + .catch((cause: unknown) => { + if (cancelled) return; + const error = cause instanceof Error ? cause : new Error(String(cause)); + setState({ chatId, messages: [], isLoading: false, error, nextCursor: undefined }); + }); + return () => { + cancelled = true; + }; + }, [chatId, limit]); + + return { + messages: state.chatId === chatId ? state.messages : [], + isLoading: state.chatId === chatId ? state.isLoading : chatId !== undefined, + error: state.chatId === chatId ? state.error : undefined, + nextCursor: state.chatId === chatId ? state.nextCursor : undefined, + }; +} + /** * React hook that creates and memoizes a `TriggerChatTransport` instance. * diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 066537415fb..bc352f9e198 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -762,6 +762,7 @@ export class TriggerChatTransport implements ChatTransport { private _onEvent: ((event: ChatTransportEvent) => void) | undefined; private sessions: Map = new Map(); + private pendingResumeCursors: Map = new Map(); private activeStreams: Map = new Map(); private pendingStarts: Map> = new Map(); // Last turn-producing send per chat — attribution source for the @@ -1453,15 +1454,48 @@ export class TriggerChatTransport implements ChatTransport { }; setSession(chatId: string, session: ChatSessionPersistedState): void { - this.sessions.set(chatId, { - publicAccessToken: session.publicAccessToken, - lastEventId: session.lastEventId, - activeInputSeq: session.activeInputSeq, - isStreaming: session.isStreaming, - }); + this.sessions.set( + chatId, + this.applyPendingResumeCursor(chatId, { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + activeInputSeq: session.activeInputSeq, + isStreaming: session.isStreaming, + }) + ); this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)!)); } + /** + * Seed the `.out` resume cursor from a loaded transcript. Applied to the + * session now if it exists, otherwise held until the session is created so + * the first live subscription opens past the persisted history instead of + * replaying it. Never moves an existing cursor backward: the transcript load + * is async, so a live `.out` record can already have advanced the session + * past the snapshot, and overwriting it would replay those records. + */ + seedResumeCursor = (chatId: string, lastEventId: string): void => { + const existing = this.sessions.get(chatId); + if (existing?.publicAccessToken) { + if (existing.lastEventId === undefined) { + existing.lastEventId = lastEventId; + this.notifySessionChange(chatId, this.toPersisted(existing)); + } + this.pendingResumeCursors.delete(chatId); + return; + } + this.pendingResumeCursors.set(chatId, lastEventId); + }; + + private applyPendingResumeCursor(chatId: string, state: ChatSessionState): ChatSessionState { + const pending = this.pendingResumeCursors.get(chatId); + if (pending !== undefined && state.lastEventId === undefined) { + state.lastEventId = pending; + } + this.pendingResumeCursors.delete(chatId); + return state; + } + setOnSessionChange( callback: ((chatId: string, session: ChatSessionPersistedState | null) => void) | undefined ): void { @@ -1697,7 +1731,7 @@ export class TriggerChatTransport implements ChatTransport { // `sessions: { ... }` already, or the very first `accessToken` call // returns a PAT for an out-of-band-created session. const token = await this.resolveAccessToken({ chatId }); - const state: ChatSessionState = { publicAccessToken: token }; + const state = this.applyPendingResumeCursor(chatId, { publicAccessToken: token }); this.sessions.set(chatId, state); this.notifySessionChange(chatId, state); return state; @@ -1725,10 +1759,10 @@ export class TriggerChatTransport implements ChatTransport { }); } - const state: ChatSessionState = { + const state = this.applyPendingResumeCursor(chatId, { publicAccessToken, isStreaming: false, - }; + }); this.sessions.set(chatId, state); this.notifySessionChange(chatId, state); return state; diff --git a/packages/trigger-sdk/src/v3/chatSnapshotIo.ts b/packages/trigger-sdk/src/v3/chatSnapshotIo.ts new file mode 100644 index 00000000000..21d767498a4 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chatSnapshotIo.ts @@ -0,0 +1,198 @@ +import { + apiClientManager, + logger, + parseTranscriptSnapshot, + type TranscriptSnapshotV2, +} from "@trigger.dev/core/v3"; +import type { UIMessage } from "ai"; + +/** + * Test-only override hook. `mockChatAgent` installs a fake to return + * synthetic snapshots without hitting S3. The fake may return a blob of any + * known version; it is parsed the same way a fetched body is. + * @internal + */ +export type ReadChatSnapshotImpl = (sessionId: string) => Promise | unknown; +let readChatSnapshotImpl: ReadChatSnapshotImpl | undefined; + +export function __setReadChatSnapshotImplForTests(impl: ReadChatSnapshotImpl | undefined): void { + readChatSnapshotImpl = impl; +} + +/** + * Test-only override hook. The mock harness records writes for assertion + * via this setter. + * @internal + */ +export type WriteChatSnapshotImpl = ( + sessionId: string, + snapshot: TranscriptSnapshotV2 +) => Promise | void; +let writeChatSnapshotImpl: WriteChatSnapshotImpl | undefined; + +export function __setWriteChatSnapshotImplForTests(impl: WriteChatSnapshotImpl | undefined): void { + writeChatSnapshotImpl = impl; +} + +/** + * Read the persisted snapshot for a session, in the version 2 shape + * whatever version was stored. Returns `undefined` on: + * - missing object (404 from the presigned GET: fresh session, never persisted) + * - presign failure (network/auth issue) + * - malformed JSON + * - a version this runtime does not know + * + * Always swallows errors via `logger.warn`. The agent boot loop must stay + * available even if S3 hiccups; the worst case is replaying more of + * `session.out` than strictly necessary. + * @internal + */ +export async function readChatSnapshot( + sessionId: string +): Promise | undefined> { + if (readChatSnapshotImpl) { + const seeded = await readChatSnapshotImpl(sessionId); + return seeded === undefined || seeded === null + ? undefined + : parseTranscriptSnapshot(seeded); + } + const apiClient = apiClientManager.clientOrThrow(); + let presignedUrl: string; + try { + const resp = await apiClient.getChatSnapshotUrl(sessionId); + presignedUrl = resp.presignedUrl; + } catch (error) { + logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + let response: Response; + try { + response = await fetch(presignedUrl, { method: "GET" }); + } catch (error) { + logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + if (response.status === 404) { + return undefined; + } + if (!response.ok) { + logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", { + status: response.status, + sessionId, + }); + return undefined; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + const snapshot = parseTranscriptSnapshot(parsed); + if (!snapshot) { + logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", { + version: (parsed as { version?: unknown } | null)?.version, + sessionId, + }); + return undefined; + } + return snapshot; +} + +/** + * Persist the snapshot for a session. Awaited by callers immediately after + * `onTurnComplete`: the agent may suspend right after this point, and + * fire-and-forget promises don't reliably complete on suspend. + * + * Errors are swallowed via `logger.warn`. A failed write means the next + * boot replays slightly more of `session.out` (back to the previous + * snapshot's cursor) instead of failing. + * @internal + */ +export async function writeChatSnapshot( + sessionId: string, + snapshot: TranscriptSnapshotV2 +): Promise { + if (writeChatSnapshotImpl) { + await writeChatSnapshotImpl(sessionId, snapshot); + return; + } + const apiClient = apiClientManager.clientOrThrow(); + let presignedUrl: string; + try { + const resp = await apiClient.createChatSnapshotUploadUrl(sessionId); + presignedUrl = resp.presignedUrl; + } catch (error) { + logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return; + } + let response: Response; + try { + response = await fetch(presignedUrl, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(snapshot), + }); + } catch (error) { + logger.warn("chat.agent: snapshot upload failed; next run will replay further", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return; + } + if (!response.ok) { + logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", { + status: response.status, + sessionId, + }); + } +} + +/** + * Test-only entry point that bypasses `__setReadChatSnapshotImplForTests` + * and reaches the real presign + `fetch` + parse path, so tests can drive + * the production code by mocking global `fetch` and the api client. + * @internal + */ +export async function __readChatSnapshotProductionPathForTests( + sessionId: string +): Promise | undefined> { + const saved = readChatSnapshotImpl; + readChatSnapshotImpl = undefined; + try { + return await readChatSnapshot(sessionId); + } finally { + readChatSnapshotImpl = saved; + } +} + +/** + * Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests` + * and reaches the real presign + `fetch` PUT path. + * @internal + */ +export async function __writeChatSnapshotProductionPathForTests( + sessionId: string, + snapshot: TranscriptSnapshotV2 +): Promise { + const saved = writeChatSnapshotImpl; + writeChatSnapshotImpl = undefined; + try { + await writeChatSnapshot(sessionId, snapshot); + } finally { + writeChatSnapshotImpl = saved; + } +} diff --git a/packages/trigger-sdk/src/v3/test/index.ts b/packages/trigger-sdk/src/v3/test/index.ts index cdeded1a7a8..c76ccebe034 100644 --- a/packages/trigger-sdk/src/v3/test/index.ts +++ b/packages/trigger-sdk/src/v3/test/index.ts @@ -13,6 +13,11 @@ export { type MockChatAgentTurn, } from "./mock-chat-agent.js"; +export { + runTranscriptStorageTests, + type TranscriptStorageTestOptions, +} from "./transcript-storage-tests.js"; + // Re-export the lower-level task context harness so consumers can build // their own test helpers without adding a separate `@trigger.dev/core` // dependency to their reference projects. diff --git a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts index 7e715a7126b..4f483205836 100644 --- a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts +++ b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts @@ -1,6 +1,6 @@ import type { UIMessage, UIMessageChunk } from "ai"; import { resourceCatalog, sessionStreams } from "@trigger.dev/core/v3"; -import type { LocalsKey, SessionChannelIO } from "@trigger.dev/core/v3"; +import type { LocalsKey, SessionChannelIO, TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; import { runInMockTaskContext, type MockTaskContextOptions } from "@trigger.dev/core/v3/test"; import { __setSessionCloseImplForTests, @@ -101,7 +101,7 @@ export type MockChatAgentOptions = { * * See plan section B.3 for the boot orchestration spec. */ - snapshot?: ChatSnapshotV1; + snapshot?: ChatSnapshotV1 | TranscriptSnapshotV2; /** * Set `payload.continuation = true` on the initial wire payload. Used * to simulate a continuation-run boot (a new run picking up after a @@ -237,7 +237,7 @@ export type MockChatAgentHarness = { * Effective on the next run boot only. Calling mid-turn is a no-op * because the snapshot read happens once at run boot. */ - seedSnapshot(snapshot: ChatSnapshotV1 | undefined): void; + seedSnapshot(snapshot: ChatSnapshotV1 | TranscriptSnapshotV2 | undefined): void; /** * Pre-seed `session.out` chunks for the next boot's replay. The runtime's @@ -311,7 +311,7 @@ export type MockChatAgentHarness = { * has been written yet. Updated each time `writeChatSnapshot` is * invoked from the run loop's snapshot-write site (plan section B.6). */ - getSnapshot(): ChatSnapshotV1 | undefined; + getSnapshot(): TranscriptSnapshotV2 | undefined; /** * Close the chat session cleanly. Sends `trigger: "close"` and awaits the @@ -335,6 +335,16 @@ function isControlChunk(chunk: unknown): boolean { return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type); } +/** + * Highest `session.in` seqNum any harness has produced for a session id, + * keyed by `sessionId`. Production `session.in` is a durable S2 stream whose + * seqNums are monotonic across the runs of a chat; a fresh in-memory manager + * per `mockChatAgent` would otherwise restart at 0, so a continuation's + * follow-up message would collide with the resume floor and be dropped. This + * survives the per-run manager reset so continuation runs stay monotonic. + */ +const durableSessionInSeq = new Map(); + /** * Create an offline test harness for a `chat.agent` task. * @@ -440,8 +450,8 @@ export function mockChatAgent( // `lastWrittenSnapshot` for harness consumers to assert via // `getSnapshot()`. Installed below alongside the session overrides; // cleared on close in the same finally block. - let seededSnapshot: ChatSnapshotV1 | undefined = options.snapshot; - let lastWrittenSnapshot: ChatSnapshotV1 | undefined; + let seededSnapshot: ChatSnapshotV1 | TranscriptSnapshotV2 | undefined = options.snapshot; + let lastWrittenSnapshot: TranscriptSnapshotV2 | undefined; let seededReplayChunks: UIMessageChunk[] = []; let seededReplayPartial: UIMessage | undefined; let seededSessionInMessages: UIMessage[] = []; @@ -450,12 +460,10 @@ export function mockChatAgent( __resetChatInputRouterForTests(); - __setReadChatSnapshotImplForTests((_id: string) => { - return seededSnapshot as ChatSnapshotV1 | undefined; - }); + __setReadChatSnapshotImplForTests(() => seededSnapshot); __setWriteChatSnapshotImplForTests( - (_id: string, snapshot: ChatSnapshotV1) => { - lastWrittenSnapshot = snapshot as ChatSnapshotV1; + (_id: string, snapshot: TranscriptSnapshotV2) => { + lastWrittenSnapshot = snapshot as TranscriptSnapshotV2; } ); @@ -576,7 +584,21 @@ export function mockChatAgent( ...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}), }; - sendSessionInput = drivers.sessions.in.send; + const durableSeq = durableSessionInSeq.get(sessionId); + if (durableSeq !== undefined) { + sessionStreams.setLastSeqNum(sessionId, "in", durableSeq); + } + const rawSendSessionInput = drivers.sessions.in.send; + sendSessionInput = async (id, data, io, metadata) => { + await rawSendSessionInput(id, data, io, metadata); + const io2 = io ?? "in"; + if (io2 === "in") { + const latest = sessionStreams.lastSeqNum(id, "in"); + if (latest !== undefined) { + durableSessionInSeq.set(id, Math.max(durableSessionInSeq.get(id) ?? latest, latest)); + } + } + }; closeSessionInput = drivers.sessions.in.close; // Record every chunk written to session.out, detect turn-complete. diff --git a/packages/trigger-sdk/src/v3/test/transcript-storage-tests.ts b/packages/trigger-sdk/src/v3/test/transcript-storage-tests.ts new file mode 100644 index 00000000000..3fe3ad14596 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/transcript-storage-tests.ts @@ -0,0 +1,327 @@ +import type { UIMessage } from "ai"; +import { + emptyTranscriptState, + reduceTranscriptChanges, + type TranscriptChange, + type TranscriptCursors, + type TranscriptState, + type TranscriptStorage, + type TranscriptStorageContext, +} from "../transcriptStorage.js"; + +type TestApi = { + describe: (name: string, fn: () => void) => void; + it: (name: string, fn: () => Promise | void) => void; + expect: (actual: unknown) => { + toEqual: (expected: unknown) => void; + toBeNull: () => void; + toBeUndefined: () => void; + toBe: (expected: unknown) => void; + toHaveLength: (length: number) => void; + }; +}; + +export type TranscriptStorageTestOptions = { + /** + * The test framework's `describe`, `it` and `expect`. Defaults to the + * globals a vitest or jest run with `globals: true` provides. + */ + api?: TestApi; + /** + * A chat id the storage accepts. Each test appends a suffix so tests do + * not see each other's rows. Defaults to `"transcript-conformance"`. + */ + chatId?: string; + /** The `clientData` the storage expects in every scope and context. */ + clientData?: unknown; +}; + +function message(id: string, text: string, role: UIMessage["role"] = "user"): UIMessage { + return { id, role, parts: [{ type: "text", text }] }; +} + +/** + * The contract every `TranscriptStorage` has to meet, as a test suite. Point + * it at a factory for your storage and run it under vitest or jest: + * + * ```ts + * import { runTranscriptStorageTests } from "@trigger.dev/sdk/ai/test"; + * runTranscriptStorageTests(() => myTranscriptStorage(testDatabaseUrl)); + * ``` + * + * The factory runs once per test. Return a fresh, empty storage, or one + * whose chats are isolated by the `chatId` option. + */ +export function runTranscriptStorageTests( + makeStorage: () => TranscriptStorage | Promise>, + options: TranscriptStorageTestOptions = {} +): void { + const globals = globalThis as unknown as Partial; + const api: TestApi = options.api ?? { + describe: globals.describe!, + it: globals.it!, + expect: globals.expect!, + }; + if (!api.describe || !api.it || !api.expect) { + throw new Error( + "runTranscriptStorageTests: no test API found. Enable `globals: true` in your test " + + "config or pass `{ api: { describe, it, expect } }`." + ); + } + const { describe, it, expect } = api; + const baseChatId = options.chatId ?? "transcript-conformance"; + const clientData = options.clientData as TClientData; + + const ctx = (chatId: string, turn = 0): TranscriptStorageContext => ({ + chatId, + clientData, + turn, + trigger: "submit-message", + runId: "run_conformance", + ctx: {} as TranscriptStorageContext["ctx"], + }); + const scope = (chatId: string) => ({ chatId, clientData }); + const ids = (messages: UIMessage[]) => messages.map((m) => m.id); + + const expected = new WeakMap>(); + const save = async ( + storage: TranscriptStorage, + context: TranscriptStorageContext, + changeset: { + reason: "turn-complete" | "turn-error" | "action"; + changes: TranscriptChange[]; + cursors?: TranscriptCursors; + } + ) => { + let perChat = expected.get(storage); + if (!perChat) { + perChat = new Map(); + expected.set(storage, perChat); + } + const transcript = reduceTranscriptChanges( + perChat.get(context.chatId) ?? emptyTranscriptState(), + changeset.changes + ); + perChat.set(context.chatId, transcript); + await storage.save(context, { ...changeset, transcript }); + }; + + describe("TranscriptStorage conformance", () => { + it("loads an unknown chat as an empty transcript", async () => { + const storage = await makeStorage(); + const loaded = await storage.load(scope(`${baseChatId}-empty`)); + expect(loaded.messages).toEqual([]); + expect(loaded.state).toBeNull(); + expect(loaded.nextCursor).toBeUndefined(); + }); + + it("appends puts in changeset order and replaces a known id in place", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-put`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: [ + { op: "put", message: message("u1", "one") }, + { op: "put", message: message("a1", "two", "assistant") }, + { op: "put", message: message("u2", "three") }, + ], + }); + await save(storage, ctx(chatId, 1), { + reason: "turn-complete", + changes: [{ op: "put", message: message("a1", "two, edited", "assistant") }], + }); + const loaded = await storage.load(scope(chatId)); + expect(ids(loaded.messages)).toEqual(["u1", "a1", "u2"]); + expect(loaded.messages[1]).toEqual(message("a1", "two, edited", "assistant")); + }); + + it("removes by id and ignores an unknown id", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-remove`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: [ + { op: "put", message: message("u1", "one") }, + { op: "put", message: message("a1", "two", "assistant") }, + { op: "put", message: message("u2", "three") }, + ], + }); + await save(storage, ctx(chatId, 1), { + reason: "action", + changes: [ + { op: "remove", id: "a1" }, + { op: "remove", id: "never-existed" }, + ], + }); + expect(ids((await storage.load(scope(chatId))).messages)).toEqual(["u1", "u2"]); + }); + + it("truncates after an id and ignores an unknown id", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-truncate`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: ["u1", "a1", "u2", "a2"].map((id) => ({ + op: "put" as const, + message: message(id, id), + })), + }); + await save(storage, ctx(chatId, 1), { + reason: "action", + changes: [{ op: "truncateAfter", afterId: "never-existed" }], + }); + expect((await storage.load(scope(chatId))).messages).toHaveLength(4); + await save(storage, ctx(chatId, 1), { + reason: "action", + changes: [{ op: "truncateAfter", afterId: "a1" }], + }); + expect(ids((await storage.load(scope(chatId))).messages)).toEqual(["u1", "a1"]); + }); + + it("appends after a truncate at the end of the transcript", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-truncate-append`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: ["u1", "a1", "u2", "a2"].map((id) => ({ + op: "put" as const, + message: message(id, id), + })), + }); + await save(storage, ctx(chatId, 1), { + reason: "action", + changes: [ + { op: "truncateAfter", afterId: "u2" }, + { op: "put", message: message("a2b", "regenerated", "assistant") }, + ], + }); + expect(ids((await storage.load(scope(chatId))).messages)).toEqual(["u1", "a1", "u2", "a2b"]); + }); + + it("round-trips state and clears it with null", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-state`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: [ + { op: "put", message: message("u1", "one") }, + { op: "state", value: { v: 1, summary: "so far" } }, + ], + }); + expect((await storage.load(scope(chatId))).state).toEqual({ v: 1, summary: "so far" }); + await save(storage, ctx(chatId, 1), { + reason: "turn-complete", + changes: [{ op: "put", message: message("u2", "two") }], + }); + expect((await storage.load(scope(chatId))).state).toEqual({ v: 1, summary: "so far" }); + await save(storage, ctx(chatId, 2), { + reason: "action", + changes: [{ op: "state", value: null }], + }); + expect((await storage.load(scope(chatId))).state).toBeNull(); + }); + + it("keeps the latest cursors it was given", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-cursors`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: [{ op: "put", message: message("u1", "one") }], + cursors: { lastOutEventId: "10", lastInEventId: "2" }, + }); + await save(storage, ctx(chatId, 1), { + reason: "turn-complete", + changes: [{ op: "put", message: message("u2", "two") }], + cursors: { lastOutEventId: "20", lastInEventId: "4" }, + }); + const loaded = await storage.load(scope(chatId)); + expect(loaded.cursors?.lastOutEventId).toBe("20"); + expect(loaded.cursors?.lastInEventId).toBe("4"); + }); + + it("keeps a partial answer's non-final status when the storage reports it", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-final`; + await save(storage, ctx(chatId), { + reason: "turn-error", + changes: [ + { op: "put", message: message("u1", "one") }, + { op: "put", message: message("a1", "partial", "assistant"), final: false }, + ], + }); + + const partial = await storage.load(scope(chatId)); + if (partial.nonFinalIds !== undefined) { + expect(partial.nonFinalIds).toEqual(["a1"]); + } + + await save(storage, ctx(chatId, 1), { + reason: "turn-complete", + changes: [{ op: "put", message: message("a1", "the finished answer", "assistant") }], + }); + const finalized = await storage.load(scope(chatId)); + expect(ids(finalized.messages)).toEqual(["u1", "a1"]); + if (finalized.nonFinalIds !== undefined) { + expect(finalized.nonFinalIds).toEqual([]); + } + }); + + it("converges when the same changeset is saved twice", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-replay`; + const changeset = { + reason: "turn-complete" as const, + changes: [ + { op: "put" as const, message: message("u1", "one") }, + { op: "put" as const, message: message("a1", "two", "assistant") }, + { op: "state" as const, value: { v: 1 } }, + ], + cursors: { lastOutEventId: "7" }, + }; + await save(storage, ctx(chatId), changeset); + await save(storage, ctx(chatId), changeset); + const loaded = await storage.load(scope(chatId)); + expect(ids(loaded.messages)).toEqual(["u1", "a1"]); + expect(loaded.state).toEqual({ v: 1 }); + }); + + it("pages from the most recent message backwards with limit and before", async () => { + const storage = await makeStorage(); + const chatId = `${baseChatId}-paging`; + await save(storage, ctx(chatId), { + reason: "turn-complete", + changes: ["m1", "m2", "m3", "m4", "m5"].map((id) => ({ + op: "put" as const, + message: message(id, id), + })), + }); + const last = await storage.load(scope(chatId), { limit: 2 }); + expect(ids(last.messages)).toEqual(["m4", "m5"]); + expect(last.nextCursor).toBe("m4"); + const middle = await storage.load(scope(chatId), { limit: 2, before: last.nextCursor }); + expect(ids(middle.messages)).toEqual(["m2", "m3"]); + expect(middle.nextCursor).toBe("m2"); + const first = await storage.load(scope(chatId), { limit: 2, before: middle.nextCursor }); + expect(ids(first.messages)).toEqual(["m1"]); + expect(first.nextCursor).toBeUndefined(); + }); + + it("keeps chats apart", async () => { + const storage = await makeStorage(); + await save(storage, ctx(`${baseChatId}-a`), { + reason: "turn-complete", + changes: [{ op: "put", message: message("u1", "in a") }], + }); + await save(storage, ctx(`${baseChatId}-b`), { + reason: "turn-complete", + changes: [{ op: "put", message: message("u1", "in b") }], + }); + expect((await storage.load(scope(`${baseChatId}-a`))).messages[0]).toEqual( + message("u1", "in a") + ); + expect((await storage.load(scope(`${baseChatId}-b`))).messages[0]).toEqual( + message("u1", "in b") + ); + }); + }); +} diff --git a/packages/trigger-sdk/src/v3/transcriptStorage.ts b/packages/trigger-sdk/src/v3/transcriptStorage.ts new file mode 100644 index 00000000000..be9fccc9846 --- /dev/null +++ b/packages/trigger-sdk/src/v3/transcriptStorage.ts @@ -0,0 +1,529 @@ +import { + apiClientManager, + pageTranscriptEntries, + type TaskRunContext, + type TranscriptSnapshotEntry, +} from "@trigger.dev/core/v3"; +import type { ModelMessage, UIMessage } from "ai"; +import { readChatSnapshot, writeChatSnapshot } from "./chatSnapshotIo.js"; + +/** + * One change to a transcript. Ops address messages by id; position is the + * storage's own concern. + * + * - `put` upserts by `message.id`: an unknown id appends, a known id is + * replaced in place. `final` is false when the runtime captured a partial + * assistant message from an errored or stopped turn; it defaults to true. + * - `remove` deletes by id and is a no-op for an unknown id. + * - `truncateAfter` drops every message ordered after `afterId` (a rollback) + * and is a no-op when `afterId` is unknown. + * - `state` replaces the opaque runtime record; `null` clears it. + */ +export type TranscriptChange = + | { op: "put"; message: UIMessage; final?: boolean } + | { op: "remove"; id: string } + | { op: "truncateAfter"; afterId: string } + | { op: "state"; value: unknown | null }; + +export type TranscriptChangeReason = + | "turn-complete" + | "turn-error" + | "action" + | "compaction" + | "recovery"; + +export type TranscriptCursors = { + lastOutEventId?: string; + lastInEventId?: string; +}; + +/** + * What the runtime hands to `save`: the ordered changes since the last save + * (one transaction where the backend supports it), the whole transcript as + * it stands after those changes, and the stream cursors the next boot should + * resume from. A row-per-message store applies `changes`; a store that keeps + * the conversation as one document writes `transcript` as-is and needs no + * state of its own. Cursors are runtime-computed and persisted opaquely; a + * storage never interprets them. + */ +export type TranscriptChangeset = { + reason: TranscriptChangeReason; + changes: TranscriptChange[]; + transcript: TranscriptState; + cursors?: TranscriptCursors; +}; + +/** The tenant scope of a read. A render read has no run, so this is all `load` gets. */ +export type TranscriptScope = { + chatId: string; + clientData: TClientData; +}; + +/** The run context of a write. Supplied by the runtime; always in-run. */ +export type TranscriptStorageContext = TranscriptScope & { + turn: number; + trigger: "submit-message" | "regenerate-message" | "action"; + runId: string; + ctx: TaskRunContext; +}; + +export type TranscriptLoadOptions = { + /** Return at most this many messages, the most recent ones. */ + limit?: number; + /** Return messages ordered before this message id (a `nextCursor` from a previous page). */ + before?: string; +}; + +export type TranscriptLoadResult = { + messages: TUIMessage[]; + state: unknown | null; + cursors?: TranscriptCursors; + /** The id to pass as `before` for the previous page; absent on the last page. */ + nextCursor?: string; + /** + * Ids among `messages` that were saved with `final: false` (a partial + * answer). A storage that keeps `final` returns them so a partial stays + * partial when a continuation saves it again unchanged; a storage that + * does not keep `final` may leave this out. + */ + nonFinalIds?: string[]; +}; + +/** What `loadContext` receives on every turn and action. */ +export type LoadContextEvent = { + chatId: string; + /** The turn number (0-indexed). */ + turn: number; + trigger: "submit-message" | "regenerate-message" | "action"; + /** The messages the frontend sent for this turn. Empty for actions. */ + incomingMessages: TUIMessage[]; + /** The runtime's transcript before this turn, including any tail it recovered. */ + previousMessages: TUIMessage[]; + clientData?: TClientData; + continuation: boolean; + previousRunId?: string; +}; + +/** + * A persistence adapter for a `chat.agent` transcript. The runtime calls + * `load` once at a continuation boot and `save` after every change to the + * conversation. Both are best-effort from the runtime's point of view: an + * error is logged and the turn continues. + * + * `loadContext` is optional. Its presence declares that the application + * owns the model's context: the runtime calls it on every turn and action + * and uses what it returns as the conversation, instead of the transcript + * it accumulated. Tail recovery still runs and `save` is still called. + */ +export type TranscriptStorage = { + load( + scope: TranscriptScope, + opts?: TranscriptLoadOptions + ): Promise>; + save(ctx: TranscriptStorageContext, changeset: TranscriptChangeset): Promise; + loadContext?( + scope: TranscriptScope, + event: LoadContextEvent + ): Promise | TUIMessage[]; +}; + +/** An in-memory transcript: ordered entries plus the opaque state record. */ +export type TranscriptState = { + entries: TranscriptSnapshotEntry[]; + state: unknown | null; +}; + +export function emptyTranscriptState< + TUIMessage extends UIMessage = UIMessage, +>(): TranscriptState { + return { entries: [], state: null }; +} + +/** + * Apply changes to a transcript, in order. Pure: returns a new state and + * never mutates the input. Replaying the same changes converges, which is + * what lets a storage retry a failed write without checking what landed. + */ +export function reduceTranscriptChanges( + prev: TranscriptState, + changes: readonly TranscriptChange[] +): TranscriptState { + let entries = prev.entries.slice(); + let state = prev.state; + for (const change of changes) { + switch (change.op) { + case "put": { + const message = change.message as TUIMessage; + const entry = { id: message.id, final: change.final ?? true, message }; + const idx = entries.findIndex((e) => e.id === message.id); + if (idx === -1) entries.push(entry); + else entries[idx] = entry; + break; + } + case "remove": { + entries = entries.filter((e) => e.id !== change.id); + break; + } + case "truncateAfter": { + const idx = entries.findIndex((e) => e.id === change.afterId); + if (idx !== -1) entries = entries.slice(0, idx + 1); + break; + } + case "state": { + state = change.value ?? null; + break; + } + } + } + return { entries, state }; +} + +/** + * What the runtime remembers about the transcript it last handed to + * `save`: the ids in order and a fingerprint per message, so the next + * changeset can be derived from the accumulator without asking the storage. + */ +export type TranscriptShadow = { + ids: string[]; + fingerprints: Map; + /** Ids last saved with `final: false`. Everything else was saved final. */ + nonFinal: Set; +}; + +function fingerprintMessage(message: UIMessage): string { + return JSON.stringify(message); +} + +/** + * Build the shadow for `messages`. An id is non-final when this save says so + * (`nonFinalIds`) or when it was non-final before and its content has not + * changed: a partial answer stays partial until the message itself changes. + */ +export function createTranscriptShadow( + messages: readonly UIMessage[], + nonFinalIds?: ReadonlySet, + previous?: TranscriptShadow +): TranscriptShadow { + const ids: string[] = []; + const fingerprints = new Map(); + const nonFinal = new Set(); + for (const message of messages) { + const fingerprint = fingerprintMessage(message); + ids.push(message.id); + fingerprints.set(message.id, fingerprint); + if ( + nonFinalIds?.has(message.id) || + (previous?.nonFinal.has(message.id) && previous.fingerprints.get(message.id) === fingerprint) + ) { + nonFinal.add(message.id); + } + } + return { ids, fingerprints, nonFinal }; +} + +/** + * Derive the changes that take `shadow` to `next`. + * + * The common prefix (by id) is compared message by message and a changed + * message becomes an in-place `put`. Past the prefix, everything the shadow + * still had is dropped with one `truncateAfter` on the last common id (or + * one `remove` per message when there is no common prefix) and everything + * `next` has is appended with `put`, in order. Applying the result with + * {@link reduceTranscriptChanges} reproduces `next` exactly, including + * order, for any two lists. + */ +export function diffTranscript( + shadow: TranscriptShadow, + next: readonly UIMessage[], + options: { nonFinalIds?: ReadonlySet } = {} +): { changes: TranscriptChange[]; shadow: TranscriptShadow } { + const nextShadow = createTranscriptShadow(next, options.nonFinalIds, shadow); + const changes: TranscriptChange[] = []; + const put = (message: UIMessage) => { + const final = nextShadow.nonFinal.has(message.id) ? false : undefined; + changes.push(final === undefined ? { op: "put", message } : { op: "put", message, final }); + }; + + let lcp = 0; + while (lcp < shadow.ids.length && lcp < next.length && shadow.ids[lcp] === next[lcp]!.id) { + lcp++; + } + for (let i = 0; i < lcp; i++) { + const message = next[i]!; + if ( + shadow.fingerprints.get(message.id) !== nextShadow.fingerprints.get(message.id) || + shadow.nonFinal.has(message.id) !== nextShadow.nonFinal.has(message.id) + ) { + put(message); + } + } + if (shadow.ids.length > lcp) { + if (lcp > 0) { + changes.push({ op: "truncateAfter", afterId: shadow.ids[lcp - 1]! }); + } else { + for (const id of shadow.ids) changes.push({ op: "remove", id }); + } + } + for (let i = lcp; i < next.length; i++) { + put(next[i]!); + } + return { changes, shadow: nextShadow }; +} + +/** + * The built-in storage: the whole transcript as one versioned blob in the + * platform's object store, the same blob the Sessions dashboard renders. + * + * Stateless. Every changeset carries the whole transcript as it stands after + * the changes, so `save` serialises that and rewrites the blob: one PUT per + * turn, no read and nothing kept in memory between saves. + */ +export function snapshotTranscriptStorage(): TranscriptStorage { + return { + async load( + scope: TranscriptScope, + opts?: TranscriptLoadOptions + ): Promise> { + if (opts?.limit !== undefined || opts?.before !== undefined) { + const page = await readTranscriptPage(scope.chatId, opts); + if (page) return page; + } + const snapshot = await readChatSnapshot(scope.chatId); + const full: TranscriptState = snapshot + ? { entries: snapshot.messages, state: snapshot.state } + : emptyTranscriptState(); + + return { + ...pageEntries(full.entries, opts), + state: full.state, + cursors: snapshot + ? { lastOutEventId: snapshot.lastOutEventId, lastInEventId: snapshot.lastInEventId } + : undefined, + }; + }, + + async save(ctx, changeset) { + await writeChatSnapshot(ctx.chatId, { + version: 2, + savedAt: Date.now(), + messages: changeset.transcript.entries, + state: changeset.transcript.state, + lastOutEventId: changeset.cursors?.lastOutEventId, + lastInEventId: changeset.cursors?.lastInEventId, + }); + }, + }; +} + +/** + * A page of the platform transcript read server-side, for a render read with + * `limit`/`before` from an app server holding a secret key. `undefined` when + * the call is not possible from here (a run's public token cannot use the + * endpoint), so the caller falls back to reading the whole blob. + */ +async function readTranscriptPage( + chatId: string, + opts: TranscriptLoadOptions +): Promise | undefined> { + try { + const page = await apiClientManager.clientOrThrow().getSessionTranscript(chatId, opts); + return { + messages: page.messages as TUIMessage[], + state: page.state ?? null, + cursors: page.cursors, + nextCursor: page.nextCursor, + }; + } catch { + return undefined; + } +} + +/** The storage `chat.agent` uses when none is configured: {@link snapshotTranscriptStorage}. */ +export const defaultStorage: TranscriptStorage = snapshotTranscriptStorage(); + +function pageEntries( + all: TranscriptSnapshotEntry[], + opts: TranscriptLoadOptions | undefined +): { messages: TUIMessage[]; nextCursor: string | undefined; nonFinalIds: string[] } { + const page = pageTranscriptEntries(all, opts); + return { + messages: page.entries.map((e) => e.message), + nextCursor: page.nextCursor, + nonFinalIds: page.entries.filter((e) => !e.final).map((e) => e.id), + }; +} + +export type MemoryTranscriptStorage = TranscriptStorage & { + /** The stored transcript for a chat, or `undefined` when nothing has been saved. */ + transcript(chatId: string): (TranscriptState & { cursors?: TranscriptCursors }) | undefined; + /** Every changeset `save` received, in order. */ + readonly changesets: Array<{ + ctx: TranscriptStorageContext; + changeset: TranscriptChangeset; + }>; +}; + +/** + * A storage that keeps every transcript in process memory. The reference + * implementation for the conformance tests, and a way to inspect exactly + * what the runtime hands a storage. + */ +export function memoryTranscriptStorage(): MemoryTranscriptStorage { + const transcripts = new Map(); + const changesets: MemoryTranscriptStorage["changesets"] = []; + + return { + changesets, + transcript(chatId) { + return transcripts.get(chatId); + }, + async load( + scope: TranscriptScope, + opts?: TranscriptLoadOptions + ): Promise> { + const stored = transcripts.get(scope.chatId); + if (!stored) return { messages: [], state: null, cursors: undefined, nextCursor: undefined }; + return { + ...pageEntries(stored.entries as TranscriptSnapshotEntry[], opts), + state: stored.state, + cursors: stored.cursors, + }; + }, + async save(ctx, changeset) { + changesets.push({ ctx, changeset }); + const prev = transcripts.get(ctx.chatId); + const next = reduceTranscriptChanges(prev ?? emptyTranscriptState(), changeset.changes); + transcripts.set(ctx.chatId, { ...next, cursors: changeset.cursors ?? prev?.cursors }); + }, + }; +} + +type ModelLaneInjection = { afterId: string; messages: ModelMessage[] }; + +/** + * What the runtime keeps in the storage's `state` slot: the parts of the + * model's context that cannot be rebuilt from the transcript. Opaque to a + * storage; only the runtime reads it. + * + * `compaction` is the whole model lane after a compaction, valid for the + * transcript prefix ending at `throughId` whose fingerprint matches, so a + * rollback or edit of that prefix makes it unusable and the next save + * clears it. `injections` are conversational messages `chat.inject` added, + * anchored after the transcript message they followed. + */ +export type TranscriptRuntimeState = { + v: 1; + compaction?: { modelMessages: ModelMessage[]; throughId: string; fingerprint: string }; + injections?: ModelLaneInjection[]; + /** `chat.inject` messages queued but not yet drained into a turn when the save happened. */ + queued?: ModelMessage[]; +}; + +export function parseTranscriptRuntimeState(value: unknown): TranscriptRuntimeState | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.v !== 1) return undefined; + const out: TranscriptRuntimeState = { v: 1 }; + const compaction = record.compaction as Record | undefined; + if ( + compaction && + typeof compaction === "object" && + Array.isArray(compaction.modelMessages) && + typeof compaction.throughId === "string" && + typeof compaction.fingerprint === "string" + ) { + out.compaction = { + modelMessages: compaction.modelMessages as ModelMessage[], + throughId: compaction.throughId, + fingerprint: compaction.fingerprint, + }; + } + if (Array.isArray(record.injections)) { + out.injections = (record.injections as unknown[]).flatMap((entry) => { + const inj = entry as Record | null; + return inj && typeof inj.afterId === "string" && Array.isArray(inj.messages) + ? [{ afterId: inj.afterId, messages: inj.messages as ModelMessage[] }] + : []; + }); + } + if (Array.isArray(record.queued) && record.queued.length > 0) { + out.queued = record.queued as ModelMessage[]; + } + return out; +} + +/** + * A 32-bit FNV-1a hash over the fingerprints of the messages up to and + * including `throughId`, in order. Cheap enough to compute on every save + * because the per-message fingerprints already exist in the shadow. + */ +export function prefixFingerprint(shadow: TranscriptShadow, throughId: string): string { + let hash = 0x811c9dc5; + if (throughId === "") return hash.toString(16).padStart(8, "0"); + const mix = (s: string) => { + for (let i = 0; i < s.length; i++) { + hash ^= s.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + }; + for (const id of shadow.ids) { + mix(id); + mix(""); + mix(shadow.fingerprints.get(id) ?? ""); + mix(""); + if (id === throughId) return hash.toString(16).padStart(8, "0"); + } + return ""; +} + +/** + * Rebuild the model lane for a transcript at boot. Uses the persisted + * compacted lane when the transcript prefix it covers is unchanged, then + * converts the rest of the transcript, re-inserting persisted injections + * after the messages they followed. + */ +export async function restoreModelLane( + messages: TUIMessage[], + state: TranscriptRuntimeState | undefined, + convert: (messages: TUIMessage[]) => Promise +): Promise<{ messages: ModelMessage[]; compacted: boolean; injections: ModelLaneInjection[] }> { + const lane: ModelMessage[] = []; + let start = 0; + let compacted = false; + + if (state?.compaction) { + const throughId = state.compaction.throughId; + const idx = throughId === "" ? -1 : messages.findIndex((m) => m.id === throughId); + if ( + (idx !== -1 || throughId === "") && + prefixFingerprint(createTranscriptShadow(messages), throughId) === + state.compaction.fingerprint + ) { + lane.push(...state.compaction.modelMessages); + start = idx + 1; + compacted = true; + } + } + + const anchored = (state?.injections ?? []) + .map((inj) => ({ + inj, + idx: inj.afterId === "" ? -1 : messages.findIndex((m) => m.id === inj.afterId), + })) + .filter(({ inj, idx }) => (inj.afterId === "" ? start === 0 : idx >= start)) + .sort((a, b) => a.idx - b.idx); + + let cursor = start; + for (const { inj, idx } of anchored) { + if (idx + 1 > cursor) { + lane.push(...(await convert(messages.slice(cursor, idx + 1)))); + cursor = idx + 1; + } + lane.push(...inj.messages); + } + if (cursor < messages.length) { + lane.push(...(await convert(messages.slice(cursor)))); + } + + return { messages: lane, compacted, injections: anchored.map(({ inj }) => inj) }; +} diff --git a/packages/trigger-sdk/test/action-snapshot.test.ts b/packages/trigger-sdk/test/action-snapshot.test.ts index 39e2ecfe280..89b1601b1d7 100644 --- a/packages/trigger-sdk/test/action-snapshot.test.ts +++ b/packages/trigger-sdk/test/action-snapshot.test.ts @@ -7,7 +7,7 @@ import { chat } from "../src/v3/ai.js"; import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { z } from "zod"; +import { z } from "zod/v4"; function textStream(text: string): ReadableStream { return simulateReadableStream({ @@ -63,7 +63,10 @@ describe("snapshot durability of history mutated by an action", () => { await new Promise((r) => setTimeout(r, 30)); // After a turn the snapshot holds the exchange. - expect(harness.getSnapshot()?.messages.map((m) => m.role)).toEqual(["user", "assistant"]); + expect(harness.getSnapshot()?.messages.map((e) => e.message.role)).toEqual([ + "user", + "assistant", + ]); await harness.sendAction({ type: "undo" }); await new Promise((r) => setTimeout(r, 30)); diff --git a/packages/trigger-sdk/test/action-stream-accumulator.test.ts b/packages/trigger-sdk/test/action-stream-accumulator.test.ts index 0ee0ecfd92e..e36b11b0b97 100644 --- a/packages/trigger-sdk/test/action-stream-accumulator.test.ts +++ b/packages/trigger-sdk/test/action-stream-accumulator.test.ts @@ -4,7 +4,7 @@ import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import { z } from "zod/v4"; import { chat } from "../src/v3/ai.js"; /** @@ -64,7 +64,10 @@ describe("a regenerate action that returns chat.turn()", () => { .map((c) => c.delta ?? "") .join(""); expect(streamed).toBe("regenerated answer"); - expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "regenerated answer"]); + expect(harness.getSnapshot()?.messages.map((e) => textOf(e.message))).toEqual([ + "ask", + "regenerated answer", + ]); } finally { await harness.close(); } @@ -123,7 +126,11 @@ describe("a regenerate action that returns chat.turn()", () => { expect(errors.length).toBeGreaterThan(0); // A turn failure: the hook saw it, and the partial is kept, not discarded. expect(completes.at(-1)?.finishReason).toBe("error"); - expect(harness.getSnapshot()?.messages.map(textOf).at(-1)).toContain("half an answer"); + const lastText = harness + .getSnapshot() + ?.messages.map((e) => textOf(e.message)) + .at(-1); + expect(lastText).toContain("half an answer"); } finally { await harness.close(); } diff --git a/packages/trigger-sdk/test/action-turn.test.ts b/packages/trigger-sdk/test/action-turn.test.ts index 85587781eba..f15679ad426 100644 --- a/packages/trigger-sdk/test/action-turn.test.ts +++ b/packages/trigger-sdk/test/action-turn.test.ts @@ -4,7 +4,7 @@ import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { simulateReadableStream, streamText } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import { z } from "zod/v4"; import { chat } from "../src/v3/ai.js"; /** @@ -80,11 +80,12 @@ function agentWith(onAction: (action: { type: string }) => unknown) { onAction: async ({ action }) => onAction(action) as never, run: async ({ messages, signal, trigger }) => { triggers.push(trigger); - snapshotsAtRun.push((snapshotReader?.()?.messages ?? []).map(textOf)); + snapshotsAtRun.push((snapshotReader?.()?.messages ?? []).map((e) => textOf(e.message))); return streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }); }, }); - let snapshotReader: (() => { messages: { parts?: unknown[] }[] } | undefined) | undefined; + type SnapshotLike = { messages: { message: { parts?: unknown[] } }[] }; + let snapshotReader: (() => SnapshotLike | undefined) | undefined; return { agent, prompts, @@ -92,7 +93,7 @@ function agentWith(onAction: (action: { type: string }) => unknown) { snapshotsAtRun, starts, completes, - attach: (h: { getSnapshot: () => { messages: { parts?: unknown[] }[] } | undefined }) => { + attach: (h: { getSnapshot: () => SnapshotLike | undefined }) => { snapshotReader = () => h.getSnapshot(); }, }; @@ -126,7 +127,10 @@ describe("an action that returns chat.turn()", () => { expect(p).toContain("AGENT-SYSTEM"); expect(p).not.toContain("answer-0"); // Its answer replaced the old one in the conversation. - expect(harness.getSnapshot()?.messages.map(textOf)).toEqual(["ask", "answer-1"]); + expect(harness.getSnapshot()?.messages.map((e) => textOf(e.message))).toEqual([ + "ask", + "answer-1", + ]); // run() saw it as a turn requested by an action, not as the action, so a // handler that returns early on "action" still answers. expect(triggers[1]).toBe("action-turn"); diff --git a/packages/trigger-sdk/test/chat-snapshot.test.ts b/packages/trigger-sdk/test/chat-snapshot.test.ts index 6ae6c3891f3..ff7950434fa 100644 --- a/packages/trigger-sdk/test/chat-snapshot.test.ts +++ b/packages/trigger-sdk/test/chat-snapshot.test.ts @@ -6,6 +6,7 @@ import "../src/v3/test/index.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { apiClientManager } from "@trigger.dev/core/v3"; +import type { TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; import { __readChatSnapshotProductionPathForTests as readChatSnapshot, __writeChatSnapshotProductionPathForTests as writeChatSnapshot, @@ -32,6 +33,20 @@ function buildSnapshot(count = 1): ChatSnapshotV1 { }; } +/** + * The version 2 counterpart, which is what the runtime writes. + */ +function buildSnapshotV2(count = 1): TranscriptSnapshotV2 { + const v1 = buildSnapshot(count); + return { + version: 2, + savedAt: v1.savedAt, + messages: v1.messages.map((message) => ({ id: message.id, final: true, message })), + state: null, + lastOutEventId: v1.lastOutEventId, + }; +} + /** * Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API * client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve @@ -85,7 +100,7 @@ describe("chat snapshot helpers", () => { }); describe("readChatSnapshot", () => { - it("returns the snapshot on a successful GET", async () => { + it("returns a version 1 snapshot upgraded to the version 2 shape on a successful GET", async () => { const { getChatSnapshotUrl } = stubApiClient({}); const snapshot = buildSnapshot(2); stubFetch( @@ -98,11 +113,21 @@ describe("chat snapshot helpers", () => { const result = await readChatSnapshot("session-1"); expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1"); - expect(result).toMatchObject({ - version: 1, - messages: snapshot.messages, - lastOutEventId: "evt-42", - }); + expect(result).toEqual(buildSnapshotV2(2)); + }); + + it("returns a version 2 snapshot as-is on a successful GET", async () => { + stubApiClient({}); + const snapshot = buildSnapshotV2(2); + stubFetch( + async () => + new Response(JSON.stringify(snapshot), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + expect(await readChatSnapshot("session-1")).toEqual(snapshot); }); it("returns undefined on 404 (fresh session, no snapshot yet)", async () => { @@ -206,7 +231,7 @@ describe("chat snapshot helpers", () => { const { createChatSnapshotUploadUrl } = stubApiClient({}); const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); - const snapshot = buildSnapshot(3); + const snapshot = buildSnapshotV2(3); await writeChatSnapshot("session-2", snapshot); expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2"); @@ -227,7 +252,7 @@ describe("chat snapshot helpers", () => { stubFetch(async () => new Response("forbidden", { status: 403 })); await expect( - writeChatSnapshot("forbidden-session", buildSnapshot()) + writeChatSnapshot("forbidden-session", buildSnapshotV2()) ).resolves.toBeUndefined(); }); @@ -237,7 +262,9 @@ describe("chat snapshot helpers", () => { throw new Error("ETIMEDOUT"); }); - await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined(); + await expect( + writeChatSnapshot("timeout-session", buildSnapshotV2()) + ).resolves.toBeUndefined(); }); it("returns without throwing when presign fails (warns)", async () => { @@ -248,7 +275,7 @@ describe("chat snapshot helpers", () => { }); const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); - await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined(); + await expect(writeChatSnapshot("denied-session", buildSnapshotV2())).resolves.toBeUndefined(); // Presign failed → no PUT attempted. expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -270,7 +297,7 @@ describe("chat snapshot helpers", () => { createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }), }); stubFetch(async () => new Response(null, { status: 200 })); - await writeChatSnapshot("round-trip-session", buildSnapshot()); + await writeChatSnapshot("round-trip-session", buildSnapshotV2()); const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!; expect(readArg).toBe(writeArg); diff --git a/packages/trigger-sdk/test/mockChatAgent.test.ts b/packages/trigger-sdk/test/mockChatAgent.test.ts index ace55f1dd29..8e5b2301aee 100644 --- a/packages/trigger-sdk/test/mockChatAgent.test.ts +++ b/packages/trigger-sdk/test/mockChatAgent.test.ts @@ -8,7 +8,7 @@ import { locals } from "@trigger.dev/core/v3"; import { simulateReadableStream, streamText, tool, validateUIMessages } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { z } from "zod"; +import { z } from "zod/v4"; // ── Helpers ──────────────────────────────────────────────────────────── @@ -1875,9 +1875,9 @@ describe("mockChatAgent", () => { await new Promise((r) => setTimeout(r, 50)); const snap = harness.getSnapshot(); expect(snap).toBeDefined(); - expect(snap!.version).toBe(1); + expect(snap!.version).toBe(2); // The snapshot reflects the post-turn accumulator: 1 user + 1 assistant. - const roles = snap!.messages.map((m) => m.role); + const roles = snap!.messages.map((e) => e.message.role); expect(roles).toEqual(["user", "assistant"]); // TestSessionStreamManager assigns the same zero-based sequence // numbers as the durable channel, so the committed input cursor is diff --git a/packages/trigger-sdk/test/transcript-changesets.test.ts b/packages/trigger-sdk/test/transcript-changesets.test.ts new file mode 100644 index 00000000000..c3462c14efd --- /dev/null +++ b/packages/trigger-sdk/test/transcript-changesets.test.ts @@ -0,0 +1,599 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import type { ModelMessage, UIMessage } from "ai"; +import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod/v4"; +import { __setTranscriptStorageForTests, chat } from "../src/v3/ai.js"; +import { + createTranscriptShadow, + memoryTranscriptStorage, + prefixFingerprint, + restoreModelLane, + type MemoryTranscriptStorage, + type TranscriptChange, + type TranscriptRuntimeState, +} from "../src/v3/transcriptStorage.js"; + +const usage = { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, +}; + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function textChunks(text: string): LanguageModelV3StreamPart[] { + return [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, + ]; +} + +function promptText(prompt: unknown): string { + return JSON.stringify(prompt); +} + +function recordingModel(prompts: unknown[], reply = "ack") { + return new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(prompt); + return { stream: simulateReadableStream({ chunks: textChunks(reply) }) }; + }, + }); +} + +async function waitFor(check: () => boolean, label: string, timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +const ops = (changes: TranscriptChange[]) => changes.map((c) => c.op); +const putIds = (changes: TranscriptChange[]) => + changes.flatMap((c) => (c.op === "put" ? [c.message.id] : [])); +const stateOf = (changes: TranscriptChange[]) => + changes.find((c) => c.op === "state")?.value as TranscriptRuntimeState | null | undefined; + +let storage: MemoryTranscriptStorage; + +beforeEach(() => { + storage = memoryTranscriptStorage(); + __setTranscriptStorageForTests(storage); +}); + +afterEach(() => { + __setTranscriptStorageForTests(undefined); +}); + +describe("chat.agent transcript changesets", () => { + it("saves a turn as puts for the new user and assistant messages with cursors", async () => { + const prompts: unknown[] = []; + const agent = chat.agent({ + id: "changeset-turn", + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "changeset-turn" }); + try { + await harness.sendMessage(userMessage("hello", "u1")); + await waitFor(() => storage.changesets.length === 1, "first save"); + + const { ctx, changeset } = storage.changesets[0]!; + expect(ctx.chatId).toBe("changeset-turn"); + expect(ctx.trigger).toBe("submit-message"); + expect(ctx.turn).toBe(0); + expect(changeset.reason).toBe("turn-complete"); + expect(ops(changeset.changes)).toEqual(["put", "put"]); + expect(putIds(changeset.changes)[0]).toBe("u1"); + expect(changeset.cursors?.lastOutEventId).toBeDefined(); + + await harness.sendMessage(userMessage("again", "u2")); + await waitFor(() => storage.changesets.length === 2, "second save"); + expect(ops(storage.changesets[1]!.changeset.changes)).toEqual(["put", "put"]); + expect(putIds(storage.changesets[1]!.changeset.changes)[0]).toBe("u2"); + expect(storage.transcript("changeset-turn")!.entries.map((e) => e.message.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + } finally { + await harness.close(); + } + }); + + it("saves a stopped response with final: false and a completed one as final", async () => { + const chatId = "changeset-stopped"; + const words = ["one", "two", "three", "four", "five", "six"]; + const slow = new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + ...words.map((w) => ({ type: "text-delta" as const, id: "t1", delta: `${w} ` })), + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, + ] satisfies LanguageModelV3StreamPart[], + initialDelayInMs: 0, + chunkDelayInMs: 300, + }), + }), + }); + const agent = chat.agent({ + id: "changeset-stopped", + run: async ({ messages, signal }) => + streamText({ model: slow, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId }); + try { + void harness.sendMessage(userMessage("go", "u1")); + await waitFor( + () => + (harness.allChunks as { type?: string }[]).filter((c) => c.type === "text-delta") + .length >= 1, + "first delta" + ); + await harness.sendStop(); + await waitFor(() => storage.changesets.length === 1, "stopped turn save"); + + const puts = storage.changesets[0]!.changeset.changes.filter((c) => c.op === "put"); + expect(puts).toHaveLength(2); + expect(puts[0]).toMatchObject({ op: "put", message: { id: "u1" } }); + expect(puts[0]).not.toHaveProperty("final"); + expect(puts[1]).toMatchObject({ op: "put", message: { role: "assistant" }, final: false }); + expect(storage.transcript(chatId)!.entries.map((e) => e.final)).toEqual([true, false]); + } finally { + await harness.close(); + } + }); + + it("persists a conversational injection drained at a step boundary and restores it at boot", async () => { + const chatId = "changeset-inject-step"; + let call = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + call += 1; + if (call === 1) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: "tool-input-start", id: "c1", toolName: "lookup" }, + { type: "tool-input-delta", id: "c1", delta: "{}" }, + { type: "tool-input-end", id: "c1" }, + { type: "tool-call", toolCallId: "c1", toolName: "lookup", input: "{}" }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool-calls" }, + usage, + }, + ] satisfies LanguageModelV3StreamPart[], + }), + }; + } + stepPrompts.push(prompt); + return { stream: simulateReadableStream({ chunks: textChunks("done") }) }; + }, + }); + const stepPrompts: unknown[] = []; + const makeAgent = () => + chat.agent({ + id: "changeset-inject-step", + tools: { + lookup: tool({ + description: "look something up", + inputSchema: z.object({}), + execute: async () => { + chat.inject([{ role: "user", content: "[note] drained at the step boundary" }]); + return { ok: true }; + }, + }), + }, + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model, + messages, + abortSignal: signal, + stopWhen: stepCountIs(5), + }), + }); + + const first = mockChatAgent(makeAgent(), { chatId }); + try { + await first.sendMessage(userMessage("look it up", "u1")); + await waitFor(() => storage.changesets.length === 1, "turn save"); + + expect(promptText(stepPrompts[0])).toContain("[note] drained at the step boundary"); + const state = stateOf(storage.changesets[0]!.changeset.changes); + expect(state?.injections).toHaveLength(1); + expect(state!.injections![0]!.afterId).toBe("u1"); + } finally { + await first.close(); + } + + const second = mockChatAgent(makeAgent(), { + chatId, + continuation: true, + previousRunId: "run_1", + }); + try { + call = 1; + await second.sendMessage(userMessage("again", "u2")); + await waitFor(() => stepPrompts.length === 2, "continuation turn"); + expect(promptText(stepPrompts[1])).toContain("[note] drained at the step boundary"); + } finally { + await second.close(); + } + }); + + it("carries an injection that was still queued when the run ended into the continuation", async () => { + const chatId = "changeset-inject-queued"; + let injectedOnce = false; + const makeAgent = (prompts: unknown[]) => + chat.agent({ + id: "changeset-inject-queued", + onTurnComplete: async () => { + if (injectedOnce) return; + injectedOnce = true; + chat.inject([{ role: "user", content: "[note] queued at exit" } as ModelMessage]); + chat.endRun(); + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + + const firstPrompts: unknown[] = []; + const first = mockChatAgent(makeAgent(firstPrompts), { chatId }); + try { + await first.sendMessage(userMessage("one", "u1")); + await waitFor(() => storage.changesets.length === 1, "turn save"); + const state = stateOf(storage.changesets[0]!.changeset.changes); + expect(state?.queued).toHaveLength(1); + expect(state?.injections).toBeUndefined(); + } finally { + await first.close(); + } + + const secondPrompts: unknown[] = []; + const second = mockChatAgent(makeAgent(secondPrompts), { + chatId, + continuation: true, + previousRunId: "run_1", + }); + try { + await second.sendMessage(userMessage("two", "u2")); + await waitFor(() => storage.changesets.length === 2, "continuation save"); + expect(promptText(secondPrompts[0])).toContain("[note] queued at exit"); + const state = stateOf(storage.changesets[1]!.changeset.changes); + expect(state?.queued).toBeUndefined(); + expect(state?.injections).toHaveLength(1); + } finally { + await second.close(); + } + }); + + it("puts a steering message the drain consumed into the turn's changeset", async () => { + const send = { fn: async () => {} }; + let call = 0; + const model = new MockLanguageModelV3({ + doStream: async () => { + call += 1; + if (call === 1) { + await send.fn(); + return { + stream: simulateReadableStream({ + chunks: [ + { type: "tool-input-start", id: "c1", toolName: "lookup" }, + { type: "tool-input-delta", id: "c1", delta: "{}" }, + { type: "tool-input-end", id: "c1" }, + { type: "tool-call", toolCallId: "c1", toolName: "lookup", input: "{}" }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool-calls" }, + usage, + }, + ] satisfies LanguageModelV3StreamPart[], + }), + }; + } + return { stream: simulateReadableStream({ chunks: textChunks("done") }) }; + }, + }); + + const agent = chat.agent({ + id: "changeset-steer", + tools: { + lookup: tool({ + description: "look something up", + inputSchema: z.object({}), + execute: async () => ({ ok: true }), + }), + }, + pendingMessages: { shouldInject: ({ steps }) => steps.length > 0 }, + run: async ({ messages, tools, signal }) => + streamText({ + ...chat.toStreamTextOptions({ tools }), + model, + messages, + abortSignal: signal, + stopWhen: stepCountIs(5), + }), + }); + const harness = mockChatAgent(agent, { chatId: "changeset-steer" }); + send.fn = async () => { + await harness.sendPendingMessage(userMessage("only the platform one", "steer-1")); + }; + try { + await harness.sendMessage(userMessage("summarise every project", "u1")); + await waitFor(() => storage.changesets.length === 1, "save"); + + const ids = putIds(storage.changesets[0]!.changeset.changes); + expect(ids).toContain("steer-1"); + expect(ids.indexOf("steer-1")).toBeGreaterThan(ids.indexOf("u1")); + expect(storage.transcript("changeset-steer")!.entries.map((e) => e.id)).toEqual(ids); + } finally { + await harness.close(); + } + }); + + it("persists a compaction as state and boots a continuation from the summary", async () => { + const chatId = "changeset-compaction"; + let compactions = 0; + const makeAgent = (prompts: unknown[]) => + chat.agent({ + id: "changeset-compaction", + compaction: { + shouldCompact: ({ source }) => source === "outer" && compactions === 0, + summarize: async () => { + compactions += 1; + return "SUMMARY-OF-EVERYTHING"; + }, + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + + const firstPrompts: unknown[] = []; + const first = mockChatAgent(makeAgent(firstPrompts), { chatId }); + try { + await first.sendMessage(userMessage("the early message", "u1")); + await waitFor(() => storage.changesets.length === 1, "turn 1 save"); + expect(compactions).toBe(1); + + const state = stateOf(storage.changesets[0]!.changeset.changes); + expect(state?.compaction).toBeDefined(); + expect(state!.compaction!.throughId).toBe( + putIds(storage.changesets[0]!.changeset.changes).at(-1) + ); + expect(JSON.stringify(state!.compaction!.modelMessages)).toContain("SUMMARY-OF-EVERYTHING"); + expect(JSON.stringify(state!.compaction!.modelMessages)).not.toContain("the early message"); + + await first.sendMessage(userMessage("a follow-up", "u2")); + await waitFor(() => storage.changesets.length === 2, "turn 2 save"); + expect(promptText(firstPrompts[1])).toContain("SUMMARY-OF-EVERYTHING"); + expect(promptText(firstPrompts[1])).not.toContain("the early message"); + expect(stateOf(storage.changesets[1]!.changeset.changes)?.compaction).toBeDefined(); + } finally { + await first.close(); + } + + expect(storage.transcript(chatId)!.entries.map((e) => e.id)).toHaveLength(4); + expect(storage.transcript(chatId)!.state).not.toBeNull(); + + const secondPrompts: unknown[] = []; + const second = mockChatAgent(makeAgent(secondPrompts), { + chatId, + continuation: true, + previousRunId: "run_first", + }); + try { + await second.sendMessage(userMessage("after the continuation", "u3")); + await waitFor(() => secondPrompts.length === 1, "continuation turn"); + + const prompt = promptText(secondPrompts[0]); + expect(prompt).toContain("SUMMARY-OF-EVERYTHING"); + expect(prompt).toContain("a follow-up"); + expect(prompt).toContain("after the continuation"); + expect(prompt).not.toContain("the early message"); + expect(compactions).toBe(1); + } finally { + await second.close(); + } + }); + + it("clears the compaction state in the same changeset as a rollback", async () => { + const chatId = "changeset-rollback"; + let compactions = 0; + const prompts: unknown[] = []; + const agent = chat.agent({ + id: "changeset-rollback", + actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]), + compaction: { + shouldCompact: ({ source }) => source === "outer" && compactions === 0, + summarize: async () => { + compactions += 1; + return "SUMMARY"; + }, + }, + onAction: async ({ action }) => { + if (action.type === "undo") chat.history.slice(0, -2); + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId }); + try { + await harness.sendMessage(userMessage("one", "u1")); + await harness.sendMessage(userMessage("two", "u2")); + await waitFor(() => storage.changesets.length === 2, "two turns"); + expect(stateOf(storage.changesets[1]!.changeset.changes)?.compaction).toBeDefined(); + + await harness.sendAction({ type: "undo" }); + await waitFor(() => storage.changesets.length === 3, "action save"); + + const { ctx, changeset } = storage.changesets[2]!; + expect(ctx.trigger).toBe("action"); + expect(changeset.reason).toBe("action"); + expect(ops(changeset.changes)).toEqual(["truncateAfter", "state"]); + expect(stateOf(changeset.changes)).toBeNull(); + expect(storage.transcript(chatId)!.entries.map((e) => e.id)).toHaveLength(2); + expect(storage.transcript(chatId)!.state).toBeNull(); + } finally { + await harness.close(); + } + }); + + it("persists conversational injections anchored to the transcript and restores them at boot", async () => { + const chatId = "changeset-inject"; + const makeAgent = (prompts: unknown[]) => + chat.agent({ + id: "changeset-inject", + onTurnComplete: async ({ turn }) => { + if (turn === 0) { + chat.inject([{ role: "user", content: "[note] inventory is low" } as ModelMessage]); + } + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + + const firstPrompts: unknown[] = []; + const first = mockChatAgent(makeAgent(firstPrompts), { chatId }); + try { + await first.sendMessage(userMessage("one", "u1")); + await first.sendMessage(userMessage("two", "u2")); + await waitFor(() => storage.changesets.length === 2, "two turns"); + + expect(promptText(firstPrompts[1])).toContain("[note] inventory is low"); + const state = stateOf(storage.changesets[1]!.changeset.changes); + expect(state?.injections).toHaveLength(1); + expect(state!.injections![0]!.afterId).toBe("u2"); + expect(state?.queued).toBeUndefined(); + const queuedAtTurn0 = stateOf(storage.changesets[0]!.changeset.changes); + expect(queuedAtTurn0?.queued).toHaveLength(1); + expect(queuedAtTurn0?.injections).toBeUndefined(); + } finally { + await first.close(); + } + + const secondPrompts: unknown[] = []; + const second = mockChatAgent(makeAgent(secondPrompts), { + chatId, + continuation: true, + previousRunId: "run_first", + }); + try { + await second.sendMessage(userMessage("three", "u3")); + await waitFor(() => secondPrompts.length === 1, "continuation turn"); + const prompt = secondPrompts[0] as { role: string; content: unknown }[]; + const text = promptText(prompt); + expect(text).toContain("[note] inventory is low"); + const noteIdx = prompt.findIndex((m) => promptText(m).includes("[note] inventory is low")); + const u2Idx = prompt.findIndex((m) => promptText(m).includes('"two"')); + const u3Idx = prompt.findIndex((m) => promptText(m).includes('"three"')); + expect(noteIdx).toBeGreaterThan(u2Idx); + expect(noteIdx).toBeLessThan(u3Idx); + } finally { + await second.close(); + } + }); +}); + +function assistantMessage(id: string): UIMessage { + return { id, role: "assistant", parts: [{ type: "text", text: "hello" }] }; +} + +describe("restoreModelLane", () => { + it("restores a compacted lane that covers an emptied transcript", async () => { + const summary = { role: "assistant" as const, content: "[Conversation summary] all of it" }; + const fingerprint = prefixFingerprint(createTranscriptShadow([]), ""); + const restored = await restoreModelLane( + [userMessage("two", "u-2")], + { v: 1, compaction: { modelMessages: [summary], throughId: "", fingerprint } }, + async (messages) => messages.map((m) => ({ role: m.role, content: m.id }) as never) + ); + expect(restored.compacted).toBe(true); + expect(restored.messages).toEqual([summary, { role: "user", content: "u-2" }]); + }); + + it("ignores a compacted lane whose covered prefix changed", async () => { + const shadow = createTranscriptShadow([userMessage("one", "u-1"), assistantMessage("a-1")]); + const state = { + v: 1 as const, + compaction: { + modelMessages: [{ role: "assistant" as const, content: "summary" }], + throughId: "a-1", + fingerprint: prefixFingerprint(shadow, "a-1"), + }, + }; + const edited = { + ...assistantMessage("a-1"), + parts: [{ type: "text" as const, text: "edited" }], + }; + const restored = await restoreModelLane( + [userMessage("one", "u-1"), edited, userMessage("two", "u-2")], + state, + async (messages) => messages.map((m) => ({ role: m.role, content: m.id }) as never) + ); + expect(restored.compacted).toBe(false); + expect(restored.messages.map((m) => m.content)).toEqual(["u-1", "a-1", "u-2"]); + }); + + it("applies persisted injections when the compacted lane is invalidated", async () => { + const state = { + v: 1 as const, + compaction: { + modelMessages: [{ role: "assistant" as const, content: "STALE SUMMARY" }], + throughId: "gone", + fingerprint: "does-not-match", + }, + injections: [ + { + afterId: "", + messages: [{ role: "user" as const, content: "[note] survives compaction loss" }], + }, + ], + }; + const restored = await restoreModelLane([userMessage("kept", "k-1")], state, async (messages) => + messages.map((m) => ({ role: m.role, content: m.id }) as never) + ); + expect(restored.compacted).toBe(false); + const dump = JSON.stringify(restored.messages); + expect(dump).toContain("[note] survives compaction loss"); + expect(dump).not.toContain("STALE SUMMARY"); + }); + + it("does not re-apply an injection a valid compaction already covers", async () => { + const shadow = createTranscriptShadow([userMessage("one", "u-1")]); + const state = { + v: 1 as const, + compaction: { + modelMessages: [{ role: "assistant" as const, content: "SUMMARY WITH THE NOTE BAKED IN" }], + throughId: "u-1", + fingerprint: prefixFingerprint(shadow, "u-1"), + }, + injections: [ + { + afterId: "u-1", + messages: [{ role: "user" as const, content: "[note] already in the summary" }], + }, + ], + }; + const restored = await restoreModelLane([userMessage("one", "u-1")], state, async (messages) => + messages.map((m) => ({ role: m.role, content: m.id }) as never) + ); + expect(restored.compacted).toBe(true); + const dump = JSON.stringify(restored.messages); + expect(dump).toContain("SUMMARY WITH THE NOTE BAKED IN"); + expect(dump).not.toContain("[note] already in the summary"); + }); +}); diff --git a/packages/trigger-sdk/test/transcript-gate-split.test.ts b/packages/trigger-sdk/test/transcript-gate-split.test.ts new file mode 100644 index 00000000000..afc364e93bd --- /dev/null +++ b/packages/trigger-sdk/test/transcript-gate-split.test.ts @@ -0,0 +1,240 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import type { UIMessage } from "ai"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __setTranscriptStorageForTests, chat } from "../src/v3/ai.js"; +import { + memoryTranscriptStorage, + type MemoryTranscriptStorage, + type TranscriptStorage, +} from "../src/v3/transcriptStorage.js"; + +const usage = { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, +}; + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function textChunks(text: string): LanguageModelV3StreamPart[] { + return [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, + ]; +} + +function recordingModel(prompts: unknown[]) { + return new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(prompt); + return { stream: simulateReadableStream({ chunks: textChunks("ack") }) }; + }, + }); +} + +async function waitFor(check: () => boolean, label: string, timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +let storage: MemoryTranscriptStorage; + +beforeEach(() => { + storage = memoryTranscriptStorage(); + __setTranscriptStorageForTests(storage); +}); + +afterEach(() => { + __setTranscriptStorageForTests(undefined); + vi.restoreAllMocks(); +}); + +describe("the persistence gate split", () => { + it("fires onRecoveryBoot for a hydrateMessages agent when a partial assistant is in the tail", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const recoveryEvents: { partialAssistant?: UIMessage }[] = []; + const onRecoveryBoot = async (event: { partialAssistant?: UIMessage }) => { + recoveryEvents.push(event); + return {}; + }; + const hydrated: UIMessage[] = [ + userMessage("from my database", "db-u1"), + { id: "db-a1", role: "assistant", parts: [{ type: "text", text: "stored answer" }] }, + ]; + const hydrateCalls: { previousMessages: UIMessage[] }[] = []; + const prompts: unknown[] = []; + const agent = chat.agent({ + id: "gate-split-hydrate-recovery", + onRecoveryBoot, + hydrateMessages: async ({ previousMessages, incomingMessages }) => { + hydrateCalls.push({ previousMessages }); + return [...hydrated, ...incomingMessages]; + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "gate-split-hydrate-recovery", + continuation: true, + previousRunId: "run_prior", + }); + harness.seedSessionOutPartial({ + id: "a-orphan", + role: "assistant", + parts: [{ type: "text", text: "half an ans" }], + }); + try { + await harness.sendMessage(userMessage("next", "u2")); + await waitFor(() => prompts.length === 1, "turn"); + + expect(recoveryEvents).toHaveLength(1); + expect(recoveryEvents[0]!.partialAssistant?.id).toBe("a-orphan"); + + expect(hydrateCalls).toHaveLength(1); + expect(JSON.stringify(prompts[0])).toContain("from my database"); + expect(storage.changesets).toHaveLength(0); + } finally { + await harness.close(); + } + }); + + it("uses the storage's loadContext for the model's context and still saves the transcript", async () => { + const contextCalls: { trigger: string; previousMessages: UIMessage[] }[] = []; + const loadContext = async ( + _scope: unknown, + event: { trigger: string; previousMessages: UIMessage[]; incomingMessages: UIMessage[] } + ) => { + contextCalls.push({ trigger: event.trigger, previousMessages: event.previousMessages }); + return [userMessage("only what the app chose", "ctx-u1"), ...event.incomingMessages]; + }; + const withContext: TranscriptStorage = { + load: storage.load.bind(storage), + save: storage.save.bind(storage), + loadContext: loadContext as TranscriptStorage["loadContext"], + }; + __setTranscriptStorageForTests(withContext); + + const prompts: unknown[] = []; + const agent = chat.agent({ + id: "gate-split-load-context", + run: async ({ messages, signal }) => + streamText({ model: recordingModel(prompts), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "gate-split-load-context" }); + try { + await harness.sendMessage(userMessage("first", "u1")); + await waitFor(() => storage.changesets.length === 1, "save"); + + expect(contextCalls).toHaveLength(1); + expect(contextCalls[0]!.trigger).toBe("submit-message"); + const prompt = JSON.stringify(prompts[0]); + expect(prompt).toContain("only what the app chose"); + expect(prompt).toContain('"first"'); + + const ids = storage.changesets[0]!.changeset.changes.flatMap((c) => + c.op === "put" ? [c.message.id] : [] + ); + expect(ids).toEqual(["ctx-u1", "u1", expect.any(String)]); + } finally { + await harness.close(); + } + }); + + it("hands a head-start first turn to loadContext as incoming messages, without seeding them twice", async () => { + const calls: { incoming: string[]; previous: string[] }[] = []; + const stored: UIMessage[] = []; + const loadContext = async ( + _scope: unknown, + event: { incomingMessages: UIMessage[]; previousMessages: UIMessage[] } + ) => { + calls.push({ + incoming: event.incomingMessages.map((m) => m.id), + previous: event.previousMessages.map((m) => m.id), + }); + for (const m of event.incomingMessages) { + if (!stored.some((s) => s.id === m.id)) stored.push(m); + } + return [...stored]; + }; + __setTranscriptStorageForTests({ + load: storage.load.bind(storage), + save: storage.save.bind(storage), + loadContext: loadContext as TranscriptStorage["loadContext"], + }); + + let roles: string[] | undefined; + const agent = chat.agent({ + id: "gate-split-head-start-load-context", + onTurnComplete: ({ uiMessages }) => { + roles = uiMessages.map((m) => m.role); + }, + run: async ({ messages, signal }) => + streamText({ model: recordingModel([]), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "gate-split-head-start-load-context", + mode: "handover-prepare", + headStartMessages: [ + { id: "hs-user-1", role: "user", parts: [{ type: "text", text: "say hi" }] }, + ], + }); + try { + await harness.sendHandover({ + partialAssistantMessage: [ + { role: "assistant", content: [{ type: "text", text: "Hi there." }] }, + ], + messageId: "asst-1", + isFinal: true, + }); + await waitFor(() => roles !== undefined, "turn complete"); + + expect(calls).toHaveLength(1); + expect(calls[0]!.incoming).toEqual(["hs-user-1"]); + expect(calls[0]!.previous).toEqual([]); + expect(roles).toEqual(["user", "assistant"]); + } finally { + await harness.close(); + } + }); + + it("refuses an agent that sets both hydrateMessages and a storage with loadContext", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + __setTranscriptStorageForTests({ + load: storage.load.bind(storage), + save: storage.save.bind(storage), + loadContext: async () => [], + }); + expect(() => + chat.agent({ + id: "gate-split-both", + hydrateMessages: async () => [], + run: async ({ messages, signal }) => + streamText({ model: recordingModel([]), messages, abortSignal: signal }), + }) + ).toThrow(/hydrateMessages/); + }); + + it("warns once that hydrateMessages is deprecated", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + chat.agent({ + id: "gate-split-deprecated", + hydrateMessages: async () => [], + run: async ({ messages, signal }) => + streamText({ model: recordingModel([]), messages, abortSignal: signal }), + }); + const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes("hydrateMessages")); + expect(deprecations).toHaveLength(1); + expect(String(deprecations[0]![0])).toMatch(/deprecated/); + }); +}); diff --git a/packages/trigger-sdk/test/transcript-storage-conformance.test.ts b/packages/trigger-sdk/test/transcript-storage-conformance.test.ts new file mode 100644 index 00000000000..22022867513 --- /dev/null +++ b/packages/trigger-sdk/test/transcript-storage-conformance.test.ts @@ -0,0 +1,34 @@ +import { runTranscriptStorageTests } from "../src/v3/test/index.js"; + +import type { TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + __setReadChatSnapshotImplForTests, + __setWriteChatSnapshotImplForTests, +} from "../src/v3/chatSnapshotIo.js"; +import { memoryTranscriptStorage, snapshotTranscriptStorage } from "../src/v3/transcriptStorage.js"; + +describe("memoryTranscriptStorage", () => { + runTranscriptStorageTests(() => memoryTranscriptStorage(), { api: { describe, it, expect } }); +}); + +describe("snapshotTranscriptStorage over an in-memory object store", () => { + const blobs = new Map(); + + beforeAll(() => { + __setReadChatSnapshotImplForTests((sessionId) => blobs.get(sessionId)); + __setWriteChatSnapshotImplForTests((sessionId, snapshot) => { + blobs.set(sessionId, snapshot as TranscriptSnapshotV2); + }); + }); + + afterAll(() => { + __setReadChatSnapshotImplForTests(undefined); + __setWriteChatSnapshotImplForTests(undefined); + }); + + runTranscriptStorageTests(() => snapshotTranscriptStorage(), { + api: { describe, it, expect }, + chatId: "snapshot-conformance", + }); +}); diff --git a/packages/trigger-sdk/test/transcript-storage-option.test.ts b/packages/trigger-sdk/test/transcript-storage-option.test.ts new file mode 100644 index 00000000000..503497df94f --- /dev/null +++ b/packages/trigger-sdk/test/transcript-storage-option.test.ts @@ -0,0 +1,93 @@ +import { mockChatAgent } from "../src/v3/test/index.js"; + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import type { UIMessage } from "ai"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it, vi } from "vitest"; +import { chat, memoryTranscriptStorage } from "../src/v3/ai.js"; + +const usage = { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, +}; + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function model(reply = "ack") { + return new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: reply }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }, + ] satisfies LanguageModelV3StreamPart[], + }), + }), + }); +} + +async function waitFor(check: () => boolean, label: string, timeoutMs = 8_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor timed out: ${label}`); +} + +describe("chat.agent({ storage })", () => { + it("persists through the configured storage and reads it back with createLoadTranscriptAction", async () => { + const storage = memoryTranscriptStorage(); + const agent = chat.agent({ + id: "storage-option", + storage, + run: async ({ messages, signal }) => + streamText({ model: model(), messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "storage-option" }); + try { + await harness.sendMessage(userMessage("hello", "u1")); + await harness.sendMessage(userMessage("again", "u2")); + await waitFor(() => storage.changesets.length === 2, "two saves"); + + expect(harness.getSnapshot()).toBeUndefined(); + + const loadTranscript = chat.createLoadTranscriptAction(storage, { limit: 3 }); + const page = await loadTranscript({ chatId: "storage-option" }); + expect(page.messages.map((m) => m.role)).toEqual(["assistant", "user", "assistant"]); + expect(page.messages[1]!.id).toBe("u2"); + expect(page.nextCursor).toBe(page.messages[0]!.id); + expect(page.cursors?.lastOutEventId).toBeDefined(); + + const rest = await loadTranscript({ chatId: "storage-option", before: page.nextCursor }); + expect(rest.messages.map((m) => m.id)).toEqual(["u1"]); + expect(rest.nextCursor).toBeUndefined(); + } finally { + await harness.close(); + } + }); + + it("refuses hydrateMessages together with storage", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => + chat.agent({ + id: "storage-option-both", + storage: memoryTranscriptStorage(), + hydrateMessages: async () => [], + run: async ({ messages, signal }) => + streamText({ model: model(), messages, abortSignal: signal }), + }) + ).toThrow(/hydrateMessages/); + vi.restoreAllMocks(); + }); + + it("requires a chatId on the load action", async () => { + const loadTranscript = chat.createLoadTranscriptAction(memoryTranscriptStorage()); + await expect(loadTranscript({ chatId: "" })).rejects.toThrow(/chatId/); + }); +}); diff --git a/packages/trigger-sdk/test/transcript-storage.test.ts b/packages/trigger-sdk/test/transcript-storage.test.ts new file mode 100644 index 00000000000..dbe06030d09 --- /dev/null +++ b/packages/trigger-sdk/test/transcript-storage.test.ts @@ -0,0 +1,359 @@ +import "../src/v3/test/index.js"; + +import type { TranscriptSnapshotV2 } from "@trigger.dev/core/v3"; +import type { UIMessage } from "ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { + __setReadChatSnapshotImplForTests, + __setWriteChatSnapshotImplForTests, +} from "../src/v3/chatSnapshotIo.js"; +import { + createTranscriptShadow, + diffTranscript, + emptyTranscriptState, + reduceTranscriptChanges, + snapshotTranscriptStorage, + type TranscriptChange, + type TranscriptStorageContext, +} from "../src/v3/transcriptStorage.js"; + +const msg = (id: string, text: string, role: UIMessage["role"] = "user"): UIMessage => ({ + id, + role, + parts: [{ type: "text", text }], +}); + +const u1 = msg("u-1", "hi"); +const a1 = msg("a-1", "hello", "assistant"); +const u2 = msg("u-2", "more"); +const a2 = msg("a-2", "sure", "assistant"); + +function applyDiff(prev: UIMessage[], next: UIMessage[], nonFinalIds?: Set) { + const shadow = createTranscriptShadow(prev); + const { changes, shadow: nextShadow } = diffTranscript(shadow, next, { nonFinalIds }); + const state = reduceTranscriptChanges( + reduceTranscriptChanges( + emptyTranscriptState(), + prev.map((m) => ({ op: "put", message: m })) + ), + changes + ); + return { changes, state, nextShadow }; +} + +describe("reduceTranscriptChanges", () => { + it("appends an unknown id and replaces a known id in place", () => { + const s1 = reduceTranscriptChanges(emptyTranscriptState(), [ + { op: "put", message: u1 }, + { op: "put", message: a1 }, + ]); + const edited = msg("u-1", "hi (edited)"); + const s2 = reduceTranscriptChanges(s1, [{ op: "put", message: edited }]); + + expect(s2.entries.map((e) => e.id)).toEqual(["u-1", "a-1"]); + expect(s2.entries[0]!.message).toEqual(edited); + expect(s2.entries.every((e) => e.final)).toBe(true); + expect(s1.entries[0]!.message).toEqual(u1); + }); + + it("records final: false from a put and defaults to true", () => { + const s = reduceTranscriptChanges(emptyTranscriptState(), [ + { op: "put", message: u1 }, + { op: "put", message: a1, final: false }, + ]); + expect(s.entries.map((e) => e.final)).toEqual([true, false]); + }); + + it("removes by id, truncates after an id, and sets state; unknown ids are no-ops", () => { + const base = reduceTranscriptChanges(emptyTranscriptState(), [ + { op: "put", message: u1 }, + { op: "put", message: a1 }, + { op: "put", message: u2 }, + { op: "put", message: a2 }, + ]); + + const removed = reduceTranscriptChanges(base, [{ op: "remove", id: "a-1" }]); + expect(removed.entries.map((e) => e.id)).toEqual(["u-1", "u-2", "a-2"]); + + const truncated = reduceTranscriptChanges(base, [{ op: "truncateAfter", afterId: "a-1" }]); + expect(truncated.entries.map((e) => e.id)).toEqual(["u-1", "a-1"]); + + const noop = reduceTranscriptChanges(base, [ + { op: "remove", id: "nope" }, + { op: "truncateAfter", afterId: "nope" }, + ]); + expect(noop.entries).toEqual(base.entries); + + const withState = reduceTranscriptChanges(base, [{ op: "state", value: { summary: "s" } }]); + expect(withState.state).toEqual({ summary: "s" }); + expect(reduceTranscriptChanges(withState, [{ op: "state", value: null }]).state).toBeNull(); + }); + + it("converges when the same changes are applied twice", () => { + const changes: TranscriptChange[] = [ + { op: "put", message: u1 }, + { op: "put", message: a1 }, + { op: "truncateAfter", afterId: "u-1" }, + { op: "put", message: msg("a-1b", "again", "assistant") }, + { op: "remove", id: "u-1" }, + ]; + const once = reduceTranscriptChanges(emptyTranscriptState(), changes); + const twice = reduceTranscriptChanges(once, changes); + expect(twice).toEqual(once); + }); +}); + +describe("diffTranscript", () => { + it("emits puts for appended messages", () => { + const { changes, state } = applyDiff([u1, a1], [u1, a1, u2, a2]); + expect(changes).toEqual([ + { op: "put", message: u2 }, + { op: "put", message: a2 }, + ]); + expect(state.entries.map((e) => e.message)).toEqual([u1, a1, u2, a2]); + }); + + it("emits an in-place put for a changed message with the same id", () => { + const a1Grown = msg("a-1", "hello there", "assistant"); + const { changes, state } = applyDiff([u1, a1], [u1, a1Grown]); + expect(changes).toEqual([{ op: "put", message: a1Grown }]); + expect(state.entries.map((e) => e.message)).toEqual([u1, a1Grown]); + }); + + it("emits nothing when nothing changed", () => { + const { changes } = applyDiff([u1, a1], [structuredClone(u1), structuredClone(a1)]); + expect(changes).toEqual([]); + }); + + it("expresses an undo as one truncateAfter", () => { + const { changes, state } = applyDiff([u1, a1, u2, a2], [u1, a1]); + expect(changes).toEqual([{ op: "truncateAfter", afterId: "a-1" }]); + expect(state.entries.map((e) => e.id)).toEqual(["u-1", "a-1"]); + }); + + it("expresses a regenerate as truncateAfter plus a put", () => { + const a2b = msg("a-2b", "better", "assistant"); + const { changes, state } = applyDiff([u1, a1, u2, a2], [u1, a1, u2, a2b]); + expect(changes).toEqual([ + { op: "truncateAfter", afterId: "u-2" }, + { op: "put", message: a2b }, + ]); + expect(state.entries.map((e) => e.id)).toEqual(["u-1", "a-1", "u-2", "a-2b"]); + }); + + it("removes everything when there is no common prefix, then puts the new list", () => { + const { changes, state } = applyDiff([u1, a1], [u2, a2]); + expect(changes).toEqual([ + { op: "remove", id: "u-1" }, + { op: "remove", id: "a-1" }, + { op: "put", message: u2 }, + { op: "put", message: a2 }, + ]); + expect(state.entries.map((e) => e.id)).toEqual(["u-2", "a-2"]); + }); + + it("reproduces an arbitrary reorder exactly", () => { + const { state } = applyDiff([u1, a1, u2, a2], [u1, u2, a1, a2]); + expect(state.entries.map((e) => e.id)).toEqual(["u-1", "u-2", "a-1", "a-2"]); + }); + + it("marks the ids in nonFinalIds as final: false", () => { + const { changes } = applyDiff([u1], [u1, a1], new Set(["a-1"])); + expect(changes).toEqual([{ op: "put", message: a1, final: false }]); + }); + + it("keeps a partial answer non-final until its content changes", () => { + const first = applyDiff([u1], [u1, a1], new Set(["a-1"])); + expect(first.changes).toEqual([{ op: "put", message: a1, final: false }]); + expect(first.state.entries[1]!.final).toBe(false); + + const unchanged = diffTranscript(first.nextShadow, [u1, a1]); + expect(unchanged.changes).toEqual([]); + expect(unchanged.shadow.nonFinal.has("a-1")).toBe(true); + + const completed = msg("a-1", "hello, finished", "assistant"); + const settled = diffTranscript(unchanged.shadow, [u1, completed]); + expect(settled.changes).toEqual([{ op: "put", message: completed }]); + expect(settled.shadow.nonFinal.has("a-1")).toBe(false); + expect(reduceTranscriptChanges(first.state, settled.changes).entries[1]!.final).toBe(true); + }); + + it("returns a shadow that makes the next diff incremental", () => { + const first = applyDiff([], [u1, a1]); + const { changes } = diffTranscript(first.nextShadow, [u1, a1, u2]); + expect(changes).toEqual([{ op: "put", message: u2 }]); + }); +}); + +describe("snapshotTranscriptStorage", () => { + const ctx = (chatId: string): TranscriptStorageContext => ({ + chatId, + clientData: undefined, + turn: 0, + trigger: "submit-message", + runId: "run_1", + ctx: {} as TranscriptStorageContext["ctx"], + }); + + let stored: TranscriptSnapshotV2 | undefined; + let reads = 0; + let writes: TranscriptSnapshotV2[] = []; + + function install(initial: unknown) { + stored = undefined; + reads = 0; + writes = []; + __setReadChatSnapshotImplForTests(() => { + reads++; + return initial; + }); + __setWriteChatSnapshotImplForTests((_id, snapshot) => { + stored = snapshot as TranscriptSnapshotV2; + writes.push(stored); + }); + } + + afterEach(() => { + __setReadChatSnapshotImplForTests(undefined); + __setWriteChatSnapshotImplForTests(undefined); + }); + + it("reports which loaded messages were saved as partial", async () => { + install({ + version: 2, + savedAt: 5, + messages: [ + { id: "u-1", final: true, message: u1 }, + { id: "a-1", final: false, message: a1 }, + ], + state: null, + }); + const storage = snapshotTranscriptStorage(); + const loaded = await storage.load({ chatId: "c1", clientData: undefined }); + expect(loaded.nonFinalIds).toEqual(["a-1"]); + expect(createTranscriptShadow(loaded.messages, new Set(loaded.nonFinalIds)).nonFinal).toEqual( + new Set(["a-1"]) + ); + }); + + it("loads a version 1 blob as messages plus cursors with null state", async () => { + install({ + version: 1, + savedAt: 5, + messages: [u1, a1], + lastOutEventId: "9", + lastInEventId: "3", + }); + const storage = snapshotTranscriptStorage(); + const loaded = await storage.load({ chatId: "c1", clientData: undefined }); + + expect(loaded.messages).toEqual([u1, a1]); + expect(loaded.state).toBeNull(); + expect(loaded.cursors).toEqual({ lastOutEventId: "9", lastInEventId: "3" }); + expect(loaded.nextCursor).toBeUndefined(); + }); + + it("loads with no snapshot as an empty transcript and no cursors", async () => { + install(undefined); + const storage = snapshotTranscriptStorage(); + const loaded = await storage.load({ chatId: "c1", clientData: undefined }); + expect(loaded).toEqual({ + messages: [], + state: null, + cursors: undefined, + nextCursor: undefined, + nonFinalIds: [], + }); + }); + + it("pages from the most recent message backwards with limit and before", async () => { + install({ + version: 2, + savedAt: 5, + messages: [u1, a1, u2, a2].map((m) => ({ id: m.id, final: true, message: m })), + state: null, + }); + const storage = snapshotTranscriptStorage(); + + const last = await storage.load({ chatId: "c1", clientData: undefined }, { limit: 2 }); + expect(last.messages.map((m) => m.id)).toEqual(["u-2", "a-2"]); + expect(last.nextCursor).toBe("u-2"); + expect(last.nonFinalIds).toEqual([]); + + const prev = await storage.load( + { chatId: "c1", clientData: undefined }, + { limit: 2, before: last.nextCursor } + ); + expect(prev.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + expect(prev.nextCursor).toBeUndefined(); + }); + + it("writes the changeset's transcript and cursors as a version 2 blob without reading", async () => { + install({ version: 1, savedAt: 5, messages: [u1, a1], lastOutEventId: "9" }); + const storage = snapshotTranscriptStorage(); + + await storage.save(ctx("c1"), { + reason: "turn-complete", + changes: [ + { op: "put", message: u2 }, + { op: "put", message: a2, final: false }, + { op: "state", value: { summary: "s" } }, + ], + transcript: { + entries: [ + { id: "u-1", final: true, message: u1 }, + { id: "a-1", final: true, message: a1 }, + { id: "u-2", final: true, message: u2 }, + { id: "a-2", final: false, message: a2 }, + ], + state: { summary: "s" }, + }, + cursors: { lastOutEventId: "12", lastInEventId: "4" }, + }); + + expect(reads).toBe(0); + expect(writes).toHaveLength(1); + expect(stored).toMatchObject({ + version: 2, + messages: [ + { id: "u-1", final: true, message: u1 }, + { id: "a-1", final: true, message: a1 }, + { id: "u-2", final: true, message: u2 }, + { id: "a-2", final: false, message: a2 }, + ], + state: { summary: "s" }, + lastOutEventId: "12", + lastInEventId: "4", + }); + expect(typeof stored!.savedAt).toBe("number"); + }); + + it("overwrites the blob with each changeset's transcript and keeps nothing in memory", async () => { + install(undefined); + const storage = snapshotTranscriptStorage(); + await storage.save(ctx("fresh"), { + reason: "turn-complete", + changes: [{ op: "put", message: u1 }], + transcript: { entries: [{ id: "u-1", final: true, message: u1 }], state: null }, + cursors: { lastOutEventId: "1" }, + }); + await storage.save(ctx("fresh"), { + reason: "action", + changes: [{ op: "truncateAfter", afterId: "u-1" }], + transcript: { entries: [{ id: "u-1", final: true, message: u1 }], state: null }, + cursors: { lastOutEventId: "1" }, + }); + await storage.save(ctx("other"), { + reason: "turn-complete", + changes: [{ op: "put", message: u2 }], + transcript: { entries: [{ id: "u-2", final: true, message: u2 }], state: null }, + }); + + expect(reads).toBe(0); + expect(writes).toHaveLength(3); + expect(writes[1]!.messages.map((e) => e.id)).toEqual(["u-1"]); + expect(writes[1]!.lastOutEventId).toBe("1"); + expect(writes[2]!.messages.map((e) => e.id)).toEqual(["u-2"]); + expect(writes[2]!.lastOutEventId).toBeUndefined(); + }); +}); diff --git a/packages/trigger-sdk/test/use-load-transcript.test.ts b/packages/trigger-sdk/test/use-load-transcript.test.ts new file mode 100644 index 00000000000..911f7c7ad10 --- /dev/null +++ b/packages/trigger-sdk/test/use-load-transcript.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { TriggerChatTransport } from "../src/v3/chat.js"; +import { seedTranscriptCursor } from "../src/v3/chat-react.js"; + +function transportWithStart() { + return new TriggerChatTransport({ + task: "my-chat", + accessToken: () => "pat", + startSession: async () => ({ publicAccessToken: "pat-from-start" }), + }); +} + +describe("seedTranscriptCursor + TriggerChatTransport resume cursor", () => { + it("does nothing when the transcript carries no cursor", () => { + const transport = transportWithStart(); + + expect(seedTranscriptCursor(transport, "chat-1", undefined)).toBe(false); + expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "" })).toBe(false); + expect(transport.getSession("chat-1")).toBeUndefined(); + }); + + it("holds a seeded cursor until the session is created, then applies it", async () => { + const transport = transportWithStart(); + + expect(seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" })).toBe(true); + expect(transport.getSession("chat-1")).toBeUndefined(); + + await transport.start("chat-1"); + expect(transport.getSession("chat-1")?.lastEventId).toBe("42"); + }); + + it("applies a seeded cursor immediately when the session exists without one", async () => { + const transport = transportWithStart(); + + await transport.start("chat-1"); + seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "99" }); + + expect(transport.getSession("chat-1")?.lastEventId).toBe("99"); + }); + + it("consumes a pending cursor when setSession creates the session state", () => { + const transport = transportWithStart(); + + seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "42" }); + transport.setSession("chat-1", { publicAccessToken: "pat" }); + + expect(transport.getSession("chat-1")?.lastEventId).toBe("42"); + }); + + it("does not move an existing cursor backward", () => { + const transport = transportWithStart(); + + transport.setSession("chat-1", { publicAccessToken: "pat", lastEventId: "50" }); + seedTranscriptCursor(transport, "chat-1", { lastOutEventId: "14" }); + + expect(transport.getSession("chat-1")?.lastEventId).toBe("50"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bbec9bd157..7c3cd6540d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -966,6 +966,9 @@ importers: ai: specifier: 6.0.116 version: 6.0.116(zod@4.5.4) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(@opentelemetry/api@1.9.1)(@prisma/client@6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@7.0.2))(typescript@7.0.2))(@types/pg@8.11.14)(better-sqlite3@11.10.0)(pg@8.15.6)(postgres@3.4.9)(prisma@6.14.0(magicast@0.3.5)(typescript@7.0.2)) zod: specifier: 4.5.4 version: 4.5.4 @@ -27198,7 +27201,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 optional: true node-abort-controller@3.1.1: {}