Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/chat-start-session-action-typed-client-data.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 37 additions & 17 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9040,17 +9040,29 @@ 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
* `chat.agent`: anything beyond `chatId`/`messages`/`trigger`/`metadata`,
* 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>;
};

Expand Down Expand Up @@ -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."
Expand All @@ -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
Expand Down
136 changes: 136 additions & 0 deletions packages/trigger-sdk/src/v3/createStartSessionAction.test.ts
Original file line number Diff line number Diff line change
@@ -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>();
});
Comment thread
ericallam marked this conversation as resolved.
});