Skip to content

Commit 9e5708d

Browse files
committed
feat(sdk,core,webapp): the transcript storage option, read API and conformance suite
chat.agent takes a storage option, with the platform snapshot as the default and the TranscriptStorage types, defaultStorage, snapshotTranscriptStorage, memoryTranscriptStorage and reduceTranscriptChanges exported from @trigger.dev/sdk/ai. chat.createLoadTranscriptAction(storage) reads a conversation on the server for any storage, and useLoadTranscript renders it in the browser and seeds the transport's resume cursor. runTranscriptStorageTests from @trigger.dev/sdk/ai/test is the contract a storage implementation has to meet. A new secret-key endpoint, GET /api/v1/sessions/:id/transcript, pages the platform snapshot server-side so rendering the last page of a long conversation does not download all of it; the default storage uses it for paged reads and falls back to the whole blob.
1 parent dce7b9b commit 9e5708d

14 files changed

Lines changed: 995 additions & 24 deletions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { pageTranscriptEntries, parseTranscriptSnapshot } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { $replica } from "~/db.server";
5+
import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server";
6+
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
7+
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
8+
import { downloadPacketFromObjectStore } from "~/v3/objectStore.server";
9+
import { logger } from "~/services/logger.server";
10+
11+
const ParamsSchema = z.object({
12+
sessionId: z.string(),
13+
});
14+
15+
const SearchParamsSchema = z.object({
16+
limit: z.coerce.number().int().min(1).max(1000).optional(),
17+
before: z.string().optional(),
18+
});
19+
20+
function sessionResource(
21+
paramId: string,
22+
session: { friendlyId: string; externalId: string | null } | null | undefined
23+
) {
24+
const ids = new Set<string>([paramId]);
25+
if (session) {
26+
ids.add(session.friendlyId);
27+
if (session.externalId) ids.add(session.externalId);
28+
}
29+
return anyResource([...ids].map((id) => ({ type: "sessions" as const, id })));
30+
}
31+
32+
function isObjectNotFound(error: unknown): boolean {
33+
if (!error) return false;
34+
const name = (error as { name?: unknown }).name;
35+
if (name === "NoSuchKey" || name === "NotFound") return true;
36+
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
37+
if (status === 404) return true;
38+
const message = error instanceof Error ? error.message : String(error);
39+
return /not found|nosuchkey|404|does not exist/i.test(message);
40+
}
41+
42+
export const loader = createLoaderApiRoute(
43+
{
44+
params: ParamsSchema,
45+
searchParams: SearchParamsSchema,
46+
corsStrategy: "none",
47+
findResource: async (params, auth) =>
48+
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId),
49+
authorization: {
50+
action: "read",
51+
resource: (session, params) => sessionResource(params.sessionId, session),
52+
},
53+
},
54+
async ({ authentication, resource: session, searchParams }) => {
55+
if (!session) {
56+
return json({ error: "Session not found" }, { status: 404 });
57+
}
58+
59+
let body: unknown;
60+
try {
61+
const packet = await downloadPacketFromObjectStore(
62+
{ dataType: "application/store", data: chatSnapshotStorageKey(session) },
63+
authentication.environment
64+
);
65+
body = typeof packet.data === "string" ? JSON.parse(packet.data) : undefined;
66+
} catch (error) {
67+
// A missing blob is a valid empty transcript (a session that has not
68+
// saved yet). Any other read failure must NOT look like an empty chat:
69+
// return an error so the client falls back to the whole-blob read
70+
// instead of rendering a saved conversation as empty.
71+
if (isObjectNotFound(error)) {
72+
return json({ messages: [], state: null });
73+
}
74+
logger.error("transcript endpoint: snapshot read failed", {
75+
sessionId: session.friendlyId,
76+
error: error instanceof Error ? error.message : String(error),
77+
});
78+
return json({ error: "Failed to read transcript" }, { status: 502 });
79+
}
80+
81+
const snapshot = parseTranscriptSnapshot(body);
82+
if (!snapshot) {
83+
return json({ messages: [], state: null });
84+
}
85+
86+
const page = pageTranscriptEntries(snapshot.messages, searchParams);
87+
return json({
88+
messages: page.entries.map((entry) => entry.message),
89+
state: snapshot.state,
90+
cursors: {
91+
lastOutEventId: snapshot.lastOutEventId,
92+
lastInEventId: snapshot.lastInEventId,
93+
},
94+
nextCursor: page.nextCursor,
95+
});
96+
}
97+
);

‎packages/core/src/v3/apiClient/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
CreateSessionStreamWaitpointResponseBody,
5252
CreateStreamResponseBody,
5353
CreateUploadPayloadUrlResponseBody,
54+
SessionTranscriptResponseBody,
5455
CreateWaitpointTokenResponseBody,
5556
CreatedSessionResponseBody,
5657
DeletedScheduleObject,
@@ -709,6 +710,30 @@ export class ApiClient {
709710
);
710711
}
711712

713+
/**
714+
* One page of a `chat.agent` session's persisted transcript, most recent
715+
* messages first when `limit` is set. Secret key only.
716+
*/
717+
getSessionTranscript(
718+
sessionId: string,
719+
options?: { limit?: number; before?: string },
720+
requestOptions?: ZodFetchOptions
721+
) {
722+
const query = new URLSearchParams();
723+
if (options?.limit !== undefined) query.set("limit", String(options.limit));
724+
if (options?.before !== undefined) query.set("before", options.before);
725+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
726+
return zodfetch(
727+
SessionTranscriptResponseBody,
728+
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/transcript${suffix}`,
729+
{
730+
method: "GET",
731+
headers: this.#getHeaders(false),
732+
},
733+
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
734+
);
735+
}
736+
712737
retrieveRun(runId: string, requestOptions?: ZodFetchOptions) {
713738
return zodfetch(
714739
RetrieveRunResponse,

‎packages/core/src/v3/schemas/api.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,21 @@ export const CreateUploadPayloadUrlResponseBody = z.object({
10051005
storagePath: z.string().optional(),
10061006
});
10071007

1008+
/** One page of a `chat.agent` session transcript, from `GET /api/v1/sessions/{id}/transcript`. */
1009+
export const SessionTranscriptResponseBody = z.object({
1010+
messages: z.array(z.unknown()),
1011+
state: z.unknown().nullable(),
1012+
cursors: z
1013+
.object({
1014+
lastOutEventId: z.string().optional(),
1015+
lastInEventId: z.string().optional(),
1016+
})
1017+
.optional(),
1018+
nextCursor: z.string().optional(),
1019+
});
1020+
1021+
export type SessionTranscriptResponseBody = z.infer<typeof SessionTranscriptResponseBody>;
1022+
10081023
export const WorkersListResponseBody = z
10091024
.object({
10101025
type: z.string(),

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,29 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
142142
return undefined;
143143
}
144144

145+
/**
146+
* Select one page of transcript entries, newest last. `before` keeps only the
147+
* entries ordered before that id; `limit` keeps the last that many. The
148+
* returned `nextCursor` is the id to pass as `before` for the previous page,
149+
* absent when there is no earlier page.
150+
*/
151+
export function pageTranscriptEntries<TUIMessage extends UIMessage = UIMessage>(
152+
all: TranscriptSnapshotEntry<TUIMessage>[],
153+
opts: { limit?: number; before?: string } | undefined
154+
): { entries: TranscriptSnapshotEntry<TUIMessage>[]; nextCursor: string | undefined } {
155+
let entries = all;
156+
if (opts?.before !== undefined) {
157+
const idx = entries.findIndex((e) => e.id === opts.before);
158+
if (idx !== -1) entries = entries.slice(0, idx);
159+
}
160+
let nextCursor: string | undefined;
161+
if (opts?.limit !== undefined && opts.limit >= 0 && entries.length > opts.limit) {
162+
entries = entries.slice(entries.length - opts.limit);
163+
nextCursor = entries[0]?.id;
164+
}
165+
return { entries, nextCursor };
166+
}
167+
145168
/**
146169
* S3 key suffix for a session's snapshot blob. The webapp's presigned
147170
* URL routes prefix this with `packets/{projectRef}/{envSlug}/`.

‎packages/core/test/chatSnapshot.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
pageTranscriptEntries,
34
parseTranscriptSnapshot,
45
type ChatSnapshotV1,
6+
type TranscriptSnapshotEntry,
57
type TranscriptSnapshotV2,
68
} from "../src/v3/sessionStreams/chatSnapshot.js";
79

10+
describe("pageTranscriptEntries", () => {
11+
const entries: TranscriptSnapshotEntry[] = ["a", "b", "c", "d", "e"].map((id) => ({
12+
id,
13+
final: true,
14+
message: { id, role: "user", parts: [] },
15+
}));
16+
17+
it("returns everything with no options and no cursor", () => {
18+
expect(pageTranscriptEntries(entries, undefined)).toEqual({ entries, nextCursor: undefined });
19+
});
20+
21+
it("returns the newest `limit` entries with the cursor for the page before", () => {
22+
const page = pageTranscriptEntries(entries, { limit: 2 });
23+
expect(page.entries.map((e) => e.id)).toEqual(["d", "e"]);
24+
expect(page.nextCursor).toBe("d");
25+
});
26+
27+
it("pages backwards with `before` until the cursor runs out", () => {
28+
const page = pageTranscriptEntries(entries, { limit: 2, before: "d" });
29+
expect(page.entries.map((e) => e.id)).toEqual(["b", "c"]);
30+
expect(page.nextCursor).toBe("b");
31+
const last = pageTranscriptEntries(entries, { limit: 2, before: "b" });
32+
expect(last.entries.map((e) => e.id)).toEqual(["a"]);
33+
expect(last.nextCursor).toBeUndefined();
34+
});
35+
36+
it("ignores an unknown `before` id", () => {
37+
expect(pageTranscriptEntries(entries, { before: "zz" }).entries).toHaveLength(5);
38+
});
39+
});
40+
841
const user = { id: "u-1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] };
942
const assistant = {
1043
id: "a-1",

0 commit comments

Comments
 (0)