feat(sdk): type chat.createStartSessionAction against your chat agent (#3684)
## Summary
Type `chat.createStartSessionAction` against the chat agent so
`clientData` is typed end-to-end on the first turn. Closes the gap where
`useTriggerChatTransport`'s `startSession` callback already hands you a
typed `clientData` (via the transport generic) but the server-side
action couldn't accept it without untyped routing through the `metadata`
field.
## Design
`ChatStartSessionParams` gains a typed `clientData` field via the new
generic:
```ts
export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
chatId: string;
clientData?: InferChatClientData<TChat>;
triggerConfig?: Partial<SessionTriggerConfig>;
metadata?: Record<string, unknown>;
};
function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
taskId: string,
options?: CreateChatStartSessionActionOptions
): (params: ChatStartSessionParams<TChat>) => Promise<ChatStartSessionResult>
```
When provided, `clientData` is folded into the first run's
`triggerConfig.basePayload.metadata`, so `onPreload` / `onChatStart` see
the same shape per-turn `metadata` carries via the transport. The opaque
session-level `metadata` field stays exactly as before — it lands on the
Session row, not the run payload.
## Usage
```ts
// actions.ts
import { chat } from "@trigger.dev/sdk/ai";
import type { myChat } from "@/trigger/chat";
export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat");
```
```tsx
// Chat.tsx
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
});
```
## Test plan
- [x] `pnpm run build --filter @trigger.dev/sdk` passes
- [ ] Verify a `chat.agent` with `clientDataSchema` reads the typed
clientData from `onPreload` payload metadata on the first turn
This commit is contained in:
@@ -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<typeof myChat>("my-chat");
|
||||
|
||||
// In the browser, threaded from the transport's typed startSession callback:
|
||||
const transport = useTriggerChatTransport<typeof myChat>({
|
||||
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.
|
||||
@@ -9040,9 +9040,17 @@ export type CreateChatStartSessionActionOptions = {
|
||||
/**
|
||||
* Params for the function returned by {@link createChatStartSessionAction}.
|
||||
*/
|
||||
export type ChatStartSessionParams = {
|
||||
export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
|
||||
/** 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<typeof myChat>(...)`.
|
||||
*/
|
||||
clientData?: InferChatClientData<TChat>;
|
||||
/**
|
||||
* 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<SessionTriggerConfig>;
|
||||
/** 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<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -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 `<typeof yourChatAgent>` 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<typeof myChat>(
|
||||
* "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<typeof myChat>({
|
||||
* 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<TChat extends AnyTask = AnyTask>(
|
||||
taskId: string,
|
||||
options?: CreateChatStartSessionActionOptions
|
||||
): (params: ChatStartSessionParams) => Promise<ChatStartSessionResult> {
|
||||
return async (params: ChatStartSessionParams): Promise<ChatStartSessionResult> => {
|
||||
): (params: ChatStartSessionParams<TChat>) => Promise<ChatStartSessionResult> {
|
||||
return async (params: ChatStartSessionParams<TChat>): Promise<ChatStartSessionResult> => {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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<CreatedSessionResponseBody> => {
|
||||
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<typeof fakeChat>("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<typeof fakeChat>("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<typeof fakeChat>("fake-chat");
|
||||
|
||||
// The clientData field is typed off the agent's schema.
|
||||
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<
|
||||
{ userId: string; plan: "free" | "pro" } | undefined
|
||||
>();
|
||||
// The agent's typed clientData is strictly narrower than `unknown`.
|
||||
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().not.toEqualTypeOf<unknown>();
|
||||
});
|
||||
|
||||
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<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<unknown>();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user