From 3050c890cffedc29213b71690141a5574ccc3195 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 10:54:46 +0100 Subject: [PATCH 1/3] feat(sdk): type chat.createStartSessionAction against your chat agent Parameterise the action with `` to type the `clientData` field against your agent's `clientDataSchema`. `clientData` is folded into the first run's `payload.metadata` so `onPreload` / `onChatStart` see the same shape per-turn `metadata` carries via the transport. The opaque session-level `metadata` field is unchanged. Closes the type gap where the transport's `startSession` callback hands you a typed `clientData` but the server-side action couldn't accept it without untyped routing through the `metadata` field. --- ...-start-session-action-typed-client-data.md | 22 ++++++++ packages/trigger-sdk/src/v3/ai.ts | 54 +++++++++++++------ 2 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 .changeset/chat-start-session-action-typed-client-data.md diff --git a/.changeset/chat-start-session-action-typed-client-data.md b/.changeset/chat-start-session-action-typed-client-data.md new file mode 100644 index 00000000000..acd75037caf --- /dev/null +++ b/.changeset/chat-start-session-action-typed-client-data.md @@ -0,0 +1,22 @@ +--- +"@trigger.dev/sdk": patch +--- + +Type `chat.createStartSessionAction` against your chat agent so `clientData` is typed end-to-end on the first turn: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import type { myChat } from "@/trigger/chat"; + +export const startChatSession = chat.createStartSessionAction("my-chat"); + +// In the browser, threaded from the transport's typed startSession callback: +const transport = useTriggerChatTransport({ + task: "my-chat", + startSession: ({ chatId, clientData }) => + startChatSession({ chatId, clientData }), + // ... +}); +``` + +`ChatStartSessionParams` gains a typed `clientData` field — folded into the first run's `payload.metadata` so `onPreload` / `onChatStart` see the same shape per-turn `metadata` carries via the transport. The opaque session-level `metadata` field is unchanged. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 72df9d92422..d5c176a4a56 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -9040,9 +9040,17 @@ export type CreateChatStartSessionActionOptions = { /** * Params for the function returned by {@link createChatStartSessionAction}. */ -export type ChatStartSessionParams = { +export type ChatStartSessionParams = { /** Conversation id (mapped to the Session's `externalId`). */ chatId: string; + /** + * Typed client data — folded into the first run's `payload.metadata` so + * `onPreload`, `onChatStart`, etc. see the same `clientData` shape on the + * first turn as subsequent turns get via the transport's `clientData` + * option. Typed via the agent's `clientDataSchema` when the action is + * parameterised with `createStartSessionAction(...)`. + */ + clientData?: InferChatClientData; /** * Per-call trigger config. Shallow-merged over the action's default * `triggerConfig`. `basePayload` is the customer's wire payload (for @@ -9050,7 +9058,11 @@ export type ChatStartSessionParams = { * which the runtime injects automatically). */ triggerConfig?: Partial; - /** Pass-through metadata folded into the session row. */ + /** + * Opaque session-level metadata stored on the Session row. Separate from + * the per-turn `clientData` above. Use this when you want to attach + * server-side metadata that doesn't go through the agent's `clientDataSchema`. + */ metadata?: Record; }; @@ -9078,33 +9090,37 @@ export type ChatStartSessionResult = { * Wrap in a Next.js server action (or any server-side handler) so the * customer's secret key never crosses to the browser. * + * Parameterise the action with `` to type the + * `clientData` field against your agent's `clientDataSchema`. + * * @example * ```ts * // actions.ts * "use server"; * import { chat } from "@trigger.dev/sdk/ai"; + * import type { myChat } from "@/trigger/chat"; * - * export const startChatSession = chat.createStartSessionAction("my-chat", { - * triggerConfig: { machine: "small-1x" }, - * }); + * export const startChatSession = chat.createStartSessionAction( + * "my-chat", + * { triggerConfig: { machine: "small-1x" } } + * ); * ``` * - * Then in the browser: + * Then in the browser, threading the typed `clientData` from the transport: * ```tsx - * const transport = useTriggerChatTransport({ + * const transport = useTriggerChatTransport({ * task: "my-chat", - * accessToken: async ({ chatId }) => { - * const { publicAccessToken } = await startChatSession({ chatId }); - * return publicAccessToken; - * }, + * accessToken: ({ chatId }) => mintChatAccessToken(chatId), + * startSession: ({ chatId, clientData }) => + * startChatSession({ chatId, clientData }), * }); * ``` */ -function createChatStartSessionAction( +function createChatStartSessionAction( taskId: string, options?: CreateChatStartSessionActionOptions -): (params: ChatStartSessionParams) => Promise { - return async (params: ChatStartSessionParams): Promise => { +): (params: ChatStartSessionParams) => Promise { + return async (params: ChatStartSessionParams): Promise => { if (!params.chatId) { throw new Error( "chat.createStartSessionAction: params.chatId is required — used as the session externalId." @@ -9117,21 +9133,25 @@ function createChatStartSessionAction( // `onPreload` fires, the runtime opens its `.in` subscription, the // first user message arrives moments later via `.in/append`. // - // `metadata` is the customer's transport-level `clientData`, - // threaded through so the agent's `clientDataSchema` validates on - // the very first turn (the typical schema requires `userId` etc.). + // `clientData` is folded into `basePayload.metadata` so the agent's + // `clientDataSchema` validates on the very first turn against the same + // shape per-turn `metadata` carries via the transport. // Auto-tag every chat.agent run with `chat:{chatId}` so the dashboard / // run-list filter by chat works without the customer having to wire it // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path. const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? []; const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5); + const clientDataMetadata = + params.clientData !== undefined ? { metadata: params.clientData } : {}; + const triggerConfig: SessionTriggerConfig = { basePayload: { messages: [], trigger: "preload", ...(options?.triggerConfig?.basePayload ?? {}), ...(params.triggerConfig?.basePayload ?? {}), + ...clientDataMetadata, chatId: params.chatId, }, ...(options?.triggerConfig?.machine || params.triggerConfig?.machine From 2b0c24d60138fb09c538f850938e404245f5aee0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 11:01:37 +0100 Subject: [PATCH 2/3] test(sdk): cover createStartSessionAction generic + clientData fold Three runtime cases and two type assertions: - `clientData` flows into `triggerConfig.basePayload.metadata` so `onPreload` / `onChatStart` see the same shape per-turn `metadata` carries via the transport. - Omitting `clientData` leaves `basePayload.metadata` unset (no empty-object pollution). - Session-level `metadata` stays distinct from per-turn `clientData` on the session row. - The generic narrows `clientData` against the agent's `clientDataSchema`. - Without a generic, `clientData` defaults to `unknown`. All 224 tests in the SDK suite still pass. --- .../src/v3/createStartSessionAction.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/trigger-sdk/src/v3/createStartSessionAction.test.ts diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts new file mode 100644 index 00000000000..efa76d3cd0c --- /dev/null +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; +import type { CreateSessionRequestBody, CreatedSessionResponseBody } from "@trigger.dev/core/v3"; + +import { chat } from "./ai.js"; +import { + __setSessionStartImplForTests, + __setSessionOpenImplForTests, + SessionHandle, +} from "./sessions.js"; +import { apiClientManager } from "@trigger.dev/core/v3"; + +// `auth.createPublicToken` is called by the action when no start token is +// supplied. Provide a minimal API client config so the mint path doesn't +// throw before we get to assert the captured request body. +apiClientManager.setGlobalAPIClientConfiguration({ + baseURL: "https://example.invalid", + accessToken: "tr_test_secret", +}); + +// Capture the request body the action would send to `sessions.start()`. +let lastStartBody: CreateSessionRequestBody | undefined; + +function installStartFixture() { + __setSessionStartImplForTests(async (body): Promise => { + lastStartBody = body; + return { + id: "session_fixture", + externalId: body.externalId ?? null, + type: body.type, + taskIdentifier: body.taskIdentifier, + triggerConfig: body.triggerConfig, + currentRunId: "run_fixture", + tags: body.triggerConfig.tags ?? [], + metadata: body.metadata ?? null, + closedAt: null, + closedReason: null, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + runId: "run_fixture", + publicAccessToken: "tr_pat_fixture", + isCached: false, + }; + }); + __setSessionOpenImplForTests(() => new SessionHandle("session_fixture")); +} + +afterEach(() => { + __setSessionStartImplForTests(undefined); + __setSessionOpenImplForTests(undefined); + lastStartBody = undefined; +}); + +// Build a fake chat agent task shape that the generic can narrow against. +// We only need the static type — the runtime never invokes this task because +// `__setSessionStartImplForTests` intercepts the network call. +const fakeChat = chat + .withClientData({ + schema: z.object({ + userId: z.string(), + plan: z.enum(["free", "pro"]), + }), + }) + .agent({ + id: "fake-chat", + run: async () => undefined as any, + }); + +describe("chat.createStartSessionAction — runtime", () => { + it("folds typed clientData into basePayload.metadata so onChatStart sees it on the first turn", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat"); + + const result = await start({ + chatId: "chat-1", + clientData: { userId: "u-1", plan: "pro" }, + }); + + expect(result.publicAccessToken).toBe("tr_pat_fixture"); + expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({ + messages: [], + trigger: "preload", + metadata: { userId: "u-1", plan: "pro" }, + chatId: "chat-1", + }); + }); + + it("leaves basePayload.metadata unset when clientData is not provided", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat"); + await start({ chatId: "chat-2" }); + + expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata"); + }); + + it("keeps session-level metadata distinct from per-turn clientData", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat"); + await start({ + chatId: "chat-3", + clientData: { userId: "u-3", plan: "free" }, + metadata: { source: "marketing-site" }, + }); + + // Per-turn shape (visible to onPreload / onChatStart): + expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({ + metadata: { userId: "u-3", plan: "free" }, + }); + // Session-row metadata (opaque, never typed via clientDataSchema): + expect(lastStartBody?.metadata).toEqual({ source: "marketing-site" }); + }); +}); + +describe("chat.createStartSessionAction — types", () => { + it("narrows clientData against the chat agent's clientDataSchema", () => { + const start = chat.createStartSessionAction("fake-chat"); + + // The clientData field is typed off the agent's schema. + expectTypeOf(start).parameter(0).toHaveProperty("clientData"); + expectTypeOf(start).parameter(0).extract<{ clientData?: any }>().toEqualTypeOf<{ + chatId: string; + clientData?: { userId: string; plan: "free" | "pro" }; + triggerConfig?: any; + metadata?: Record; + }>(); + }); + + it("defaults clientData to unknown when called without a generic", () => { + const start = chat.createStartSessionAction("fake-chat"); + expectTypeOf(start).parameter(0).toHaveProperty("clientData"); + // Untyped variant — clientData is `unknown`. + expectTypeOf[0]["clientData"]>().toEqualTypeOf(); + }); +}); From c087368162b9e049d4850f5a1d23b535b4a5e488 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 21 May 2026 11:09:51 +0100 Subject: [PATCH 3/3] test(sdk): fix expectTypeOf assertion shape for createStartSessionAction `expectTypeOf().parameter(0).extract<...>()` was producing a constraint type that didn't match the optional-fields shape of the actual params, breaking CI typecheck. Verify the typed `clientData` field directly via `Parameters[0]["clientData"]` against the agent's clientDataSchema-derived shape (and check it's strictly narrower than `unknown`). Same coverage, sound assertion. --- .../src/v3/createStartSessionAction.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index efa76d3cd0c..2b3214b77d1 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -120,13 +120,11 @@ describe("chat.createStartSessionAction — types", () => { const start = chat.createStartSessionAction("fake-chat"); // The clientData field is typed off the agent's schema. - expectTypeOf(start).parameter(0).toHaveProperty("clientData"); - expectTypeOf(start).parameter(0).extract<{ clientData?: any }>().toEqualTypeOf<{ - chatId: string; - clientData?: { userId: string; plan: "free" | "pro" }; - triggerConfig?: any; - metadata?: Record; - }>(); + expectTypeOf[0]["clientData"]>().toEqualTypeOf< + { userId: string; plan: "free" | "pro" } | undefined + >(); + // The agent's typed clientData is strictly narrower than `unknown`. + expectTypeOf[0]["clientData"]>().not.toEqualTypeOf(); }); it("defaults clientData to unknown when called without a generic", () => {