Skip to content

Commit fd3b1f6

Browse files
ericallamclaude
andauthored
feat(sdk): route chat.agent transcript persistence through a TranscriptStorage seam (#4893)
## Summary Introduces the `TranscriptStorage` seam inside `chat.agent` and makes the built-in snapshot writer its default implementation. No public option yet; the default path behaves as before, apart from the blob now being written as version 2. ## Design A storage has `load` (called once when a run boots to continue a conversation) and `save` (called after every turn, failed turn and history-changing action). `save` receives a changeset of id-addressed operations: `put` (upsert by message id), `remove`, `truncateAfter` and `state`, plus the stream cursors the next boot resumes from. The runtime keeps a shadow of the transcript it last handed to `save` (ids in order plus a fingerprint per message) and diffs the accumulator against it. A changed message in the common prefix is an in-place `put`; anything past the prefix is one `truncateAfter` on the last common id followed by `put`s in order. Applying the result reproduces the accumulator exactly for any edit, and the common cases come out as the natural operations: a turn is two `put`s, an undo is one `truncateAfter`, a regenerate is a `truncateAfter` and a `put`. The shadow only advances when `save` resolves, so a failed save is folded into the next changeset; every operation is idempotent, so a retried changeset converges. Every changeset also carries the whole transcript as it stands after the changes (entries with a `final` flag, plus the runtime's `state`). A row-per-message store applies the changes; a store that keeps the conversation as one document writes the transcript as-is and needs no state of its own between saves. The default storage is the second kind: it serialises the transcript and rewrites the blob, so a turn still costs one PUT and no GET, and nothing is held in memory between saves. The snapshot read and write helpers move to their own module so the storage can import them without a cycle; the test seams keep their import path. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd5a0fe commit fd3b1f6

14 files changed

Lines changed: 1114 additions & 321 deletions

apps/webapp/test/chat-snapshot-integration.test.ts

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,8 @@
1-
// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob
2-
// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors
3-
// the testcontainer pattern from `objectStore.test.ts`.
4-
//
5-
// What this verifies end-to-end:
6-
// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl`
7-
// to mint a presigned PUT, then PUTs JSON to it.
8-
// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a
9-
// presigned GET, then fetches and parses.
10-
// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts.
11-
// - The blob round-trips with `version: 1` shape preserved.
12-
// - 404 (no snapshot for a fresh session) returns `undefined`, not an
13-
// error.
14-
//
15-
// This is the integration safety net behind the unit tests in
16-
// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock
17-
// `fetch`; this one drives a real S3-compatible backend.
18-
191
import { postgresAndMinioTest } from "@internal/testcontainers";
20-
import { apiClientManager } from "@trigger.dev/core/v3";
2+
import { apiClientManager, type TranscriptSnapshotV2 } from "@trigger.dev/core/v3";
213
import {
224
__readChatSnapshotProductionPathForTests as readChatSnapshot,
235
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
24-
type ChatSnapshotV1,
256
} from "@trigger.dev/sdk/ai";
267
import type { UIMessage } from "ai";
278
import { afterEach, describe, expect, vi } from "vitest";
@@ -35,22 +16,24 @@ vi.setConfig({ testTimeout: 60_000 });
3516

3617
function makeSnapshot(
3718
opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}
38-
): ChatSnapshotV1 {
19+
): TranscriptSnapshotV2 {
20+
const messages = opts.messages ?? [
21+
{
22+
id: "u-1",
23+
role: "user",
24+
parts: [{ type: "text", text: "hello" }],
25+
},
26+
{
27+
id: "a-1",
28+
role: "assistant",
29+
parts: [{ type: "text", text: "world" }],
30+
},
31+
];
3932
return {
40-
version: 1,
33+
version: 2,
4134
savedAt: 1_700_000_000_000,
42-
messages: opts.messages ?? [
43-
{
44-
id: "u-1",
45-
role: "user",
46-
parts: [{ type: "text", text: "hello" }],
47-
},
48-
{
49-
id: "a-1",
50-
role: "assistant",
51-
parts: [{ type: "text", text: "world" }],
52-
},
53-
],
35+
messages: messages.map((message) => ({ id: message.id, final: true, message })),
36+
state: null,
5437
lastOutEventId: opts.lastOutEventId ?? "evt-42",
5538
};
5639
}

apps/webapp/test/replay-after-crash.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@
2424
// through it), even though the replay path itself doesn't read from S3.
2525

2626
import { postgresAndMinioTest } from "@internal/testcontainers";
27-
import { apiClientManager } from "@trigger.dev/core/v3";
27+
import { apiClientManager, type TranscriptSnapshotV2 } from "@trigger.dev/core/v3";
2828
import {
2929
__readChatSnapshotProductionPathForTests as readChatSnapshot,
3030
__replaySessionOutTailProductionPathForTests as replaySessionOutTail,
31-
type ChatSnapshotV1,
3231
} from "@trigger.dev/sdk/ai";
3332
import type { UIMessageChunk } from "ai";
3433
import { afterEach, describe, expect, vi } from "vitest";
@@ -265,13 +264,26 @@ describe("replay after crash (MinIO + SDK helpers)", () => {
265264

266265
// Pre-write a snapshot to MinIO via real apiClient stub.
267266
const sessionId = "sess_merge_round_trip";
268-
const snapshot: ChatSnapshotV1 = {
269-
version: 1,
267+
const snapshot: TranscriptSnapshotV2 = {
268+
version: 2,
270269
savedAt: 1_700_000_000_000,
271270
messages: [
272-
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] },
273-
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "stale-assistant" }] },
271+
{
272+
id: "u-1",
273+
final: true,
274+
message: { id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] },
275+
},
276+
{
277+
id: "a-1",
278+
final: true,
279+
message: {
280+
id: "a-1",
281+
role: "assistant",
282+
parts: [{ type: "text", text: "stale-assistant" }],
283+
},
284+
},
274285
],
286+
state: null,
275287
lastOutEventId: "evt-prev",
276288
};
277289

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type ChatSnapshotV1<TUIMessage = unknown> = {
4242
*/
4343
export const ChatSnapshotV1Schema = z.object({
4444
version: z.literal(1),
45-
savedAt: z.number(),
45+
savedAt: z.number().optional(),
4646
messages: z.array(z.unknown()),
4747
lastOutEventId: z.string().optional(),
4848
lastInEventId: z.string().optional(),
@@ -79,7 +79,7 @@ export type TranscriptSnapshotV2<TUIMessage extends UIMessage = UIMessage> = {
7979

8080
export const TranscriptSnapshotV2Schema = z.object({
8181
version: z.literal(2),
82-
savedAt: z.number(),
82+
savedAt: z.number().optional(),
8383
messages: z.array(
8484
z.object({
8585
id: z.string(),
@@ -100,6 +100,9 @@ export const TranscriptSnapshotV2Schema = z.object({
100100
* `message` are dropped; a version 2 entry whose `message.id` disagrees with
101101
* the envelope `id` is dropped too, since a reader keys by one and renders by
102102
* the other. A caller never sees an entry it would crash on or mis-order.
103+
* A missing `savedAt` defaults to `0` rather than rejecting the whole blob:
104+
* the field only orders snapshot history before live chunks, and dropping a
105+
* whole conversation over an absent timestamp is the wrong failure mode.
103106
* Returns `undefined` for an unknown version or a body that is not a
104107
* snapshot; callers treat that as "no snapshot".
105108
*/
@@ -117,7 +120,7 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
117120
}
118121
return {
119122
version: 2,
120-
savedAt: v2.data.savedAt,
123+
savedAt: v2.data.savedAt ?? 0,
121124
messages,
122125
state: v2.data.state ?? null,
123126
lastOutEventId: v2.data.lastOutEventId,
@@ -135,7 +138,7 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
135138
}
136139
return {
137140
version: 2,
138-
savedAt: v1.data.savedAt,
141+
savedAt: v1.data.savedAt ?? 0,
139142
messages,
140143
state: null,
141144
lastOutEventId: v1.data.lastOutEventId,

packages/core/src/v3/test/test-session-stream-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ export class TestSessionStreamManager implements SessionStreamManager {
220220
}
221221

222222
setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
223-
this.seqNums.set(keyFor(sessionId, io), seqNum);
223+
const key = keyFor(sessionId, io);
224+
const current = this.seqNums.get(key);
225+
if (current === undefined || seqNum > current) {
226+
this.seqNums.set(key, seqNum);
227+
}
224228
}
225229

226230
consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void {

0 commit comments

Comments
 (0)