Skip to content

Commit 9ff410b

Browse files
authored
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
1 parent d343727 commit 9ff410b

3 files changed

Lines changed: 195 additions & 17 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Type `chat.createStartSessionAction` against your chat agent so `clientData` is typed end-to-end on the first turn:
6+
7+
```ts
8+
import { chat } from "@trigger.dev/sdk/ai";
9+
import type { myChat } from "@/trigger/chat";
10+
11+
export const startChatSession = chat.createStartSessionAction<typeof myChat>("my-chat");
12+
13+
// In the browser, threaded from the transport's typed startSession callback:
14+
const transport = useTriggerChatTransport<typeof myChat>({
15+
task: "my-chat",
16+
startSession: ({ chatId, clientData }) =>
17+
startChatSession({ chatId, clientData }),
18+
// ...
19+
});
20+
```
21+
22+
`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.

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9040,17 +9040,29 @@ export type CreateChatStartSessionActionOptions = {
90409040
/**
90419041
* Params for the function returned by {@link createChatStartSessionAction}.
90429042
*/
9043-
export type ChatStartSessionParams = {
9043+
export type ChatStartSessionParams<TChat extends AnyTask = AnyTask> = {
90449044
/** Conversation id (mapped to the Session's `externalId`). */
90459045
chatId: string;
9046+
/**
9047+
* Typed client data — folded into the first run's `payload.metadata` so
9048+
* `onPreload`, `onChatStart`, etc. see the same `clientData` shape on the
9049+
* first turn as subsequent turns get via the transport's `clientData`
9050+
* option. Typed via the agent's `clientDataSchema` when the action is
9051+
* parameterised with `createStartSessionAction<typeof myChat>(...)`.
9052+
*/
9053+
clientData?: InferChatClientData<TChat>;
90469054
/**
90479055
* Per-call trigger config. Shallow-merged over the action's default
90489056
* `triggerConfig`. `basePayload` is the customer's wire payload (for
90499057
* `chat.agent`: anything beyond `chatId`/`messages`/`trigger`/`metadata`,
90509058
* which the runtime injects automatically).
90519059
*/
90529060
triggerConfig?: Partial<SessionTriggerConfig>;
9053-
/** Pass-through metadata folded into the session row. */
9061+
/**
9062+
* Opaque session-level metadata stored on the Session row. Separate from
9063+
* the per-turn `clientData` above. Use this when you want to attach
9064+
* server-side metadata that doesn't go through the agent's `clientDataSchema`.
9065+
*/
90549066
metadata?: Record<string, unknown>;
90559067
};
90569068

@@ -9078,33 +9090,37 @@ export type ChatStartSessionResult = {
90789090
* Wrap in a Next.js server action (or any server-side handler) so the
90799091
* customer's secret key never crosses to the browser.
90809092
*
9093+
* Parameterise the action with `<typeof yourChatAgent>` to type the
9094+
* `clientData` field against your agent's `clientDataSchema`.
9095+
*
90819096
* @example
90829097
* ```ts
90839098
* // actions.ts
90849099
* "use server";
90859100
* import { chat } from "@trigger.dev/sdk/ai";
9101+
* import type { myChat } from "@/trigger/chat";
90869102
*
9087-
* export const startChatSession = chat.createStartSessionAction("my-chat", {
9088-
* triggerConfig: { machine: "small-1x" },
9089-
* });
9103+
* export const startChatSession = chat.createStartSessionAction<typeof myChat>(
9104+
* "my-chat",
9105+
* { triggerConfig: { machine: "small-1x" } }
9106+
* );
90909107
* ```
90919108
*
9092-
* Then in the browser:
9109+
* Then in the browser, threading the typed `clientData` from the transport:
90939110
* ```tsx
9094-
* const transport = useTriggerChatTransport({
9111+
* const transport = useTriggerChatTransport<typeof myChat>({
90959112
* task: "my-chat",
9096-
* accessToken: async ({ chatId }) => {
9097-
* const { publicAccessToken } = await startChatSession({ chatId });
9098-
* return publicAccessToken;
9099-
* },
9113+
* accessToken: ({ chatId }) => mintChatAccessToken(chatId),
9114+
* startSession: ({ chatId, clientData }) =>
9115+
* startChatSession({ chatId, clientData }),
91009116
* });
91019117
* ```
91029118
*/
9103-
function createChatStartSessionAction(
9119+
function createChatStartSessionAction<TChat extends AnyTask = AnyTask>(
91049120
taskId: string,
91059121
options?: CreateChatStartSessionActionOptions
9106-
): (params: ChatStartSessionParams) => Promise<ChatStartSessionResult> {
9107-
return async (params: ChatStartSessionParams): Promise<ChatStartSessionResult> => {
9122+
): (params: ChatStartSessionParams<TChat>) => Promise<ChatStartSessionResult> {
9123+
return async (params: ChatStartSessionParams<TChat>): Promise<ChatStartSessionResult> => {
91089124
if (!params.chatId) {
91099125
throw new Error(
91109126
"chat.createStartSessionAction: params.chatId is required — used as the session externalId."
@@ -9117,21 +9133,25 @@ function createChatStartSessionAction(
91179133
// `onPreload` fires, the runtime opens its `.in` subscription, the
91189134
// first user message arrives moments later via `.in/append`.
91199135
//
9120-
// `metadata` is the customer's transport-level `clientData`,
9121-
// threaded through so the agent's `clientDataSchema` validates on
9122-
// the very first turn (the typical schema requires `userId` etc.).
9136+
// `clientData` is folded into `basePayload.metadata` so the agent's
9137+
// `clientDataSchema` validates on the very first turn against the same
9138+
// shape per-turn `metadata` carries via the transport.
91239139
// Auto-tag every chat.agent run with `chat:{chatId}` so the dashboard /
91249140
// run-list filter by chat works without the customer having to wire it
91259141
// up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path.
91269142
const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? [];
91279143
const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5);
91289144

9145+
const clientDataMetadata =
9146+
params.clientData !== undefined ? { metadata: params.clientData } : {};
9147+
91299148
const triggerConfig: SessionTriggerConfig = {
91309149
basePayload: {
91319150
messages: [],
91329151
trigger: "preload",
91339152
...(options?.triggerConfig?.basePayload ?? {}),
91349153
...(params.triggerConfig?.basePayload ?? {}),
9154+
...clientDataMetadata,
91359155
chatId: params.chatId,
91369156
},
91379157
...(options?.triggerConfig?.machine || params.triggerConfig?.machine
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
2+
import { z } from "zod";
3+
import type { CreateSessionRequestBody, CreatedSessionResponseBody } from "@trigger.dev/core/v3";
4+
5+
import { chat } from "./ai.js";
6+
import {
7+
__setSessionStartImplForTests,
8+
__setSessionOpenImplForTests,
9+
SessionHandle,
10+
} from "./sessions.js";
11+
import { apiClientManager } from "@trigger.dev/core/v3";
12+
13+
// `auth.createPublicToken` is called by the action when no start token is
14+
// supplied. Provide a minimal API client config so the mint path doesn't
15+
// throw before we get to assert the captured request body.
16+
apiClientManager.setGlobalAPIClientConfiguration({
17+
baseURL: "https://example.invalid",
18+
accessToken: "tr_test_secret",
19+
});
20+
21+
// Capture the request body the action would send to `sessions.start()`.
22+
let lastStartBody: CreateSessionRequestBody | undefined;
23+
24+
function installStartFixture() {
25+
__setSessionStartImplForTests(async (body): Promise<CreatedSessionResponseBody> => {
26+
lastStartBody = body;
27+
return {
28+
id: "session_fixture",
29+
externalId: body.externalId ?? null,
30+
type: body.type,
31+
taskIdentifier: body.taskIdentifier,
32+
triggerConfig: body.triggerConfig,
33+
currentRunId: "run_fixture",
34+
tags: body.triggerConfig.tags ?? [],
35+
metadata: body.metadata ?? null,
36+
closedAt: null,
37+
closedReason: null,
38+
expiresAt: null,
39+
createdAt: new Date(),
40+
updatedAt: new Date(),
41+
runId: "run_fixture",
42+
publicAccessToken: "tr_pat_fixture",
43+
isCached: false,
44+
};
45+
});
46+
__setSessionOpenImplForTests(() => new SessionHandle("session_fixture"));
47+
}
48+
49+
afterEach(() => {
50+
__setSessionStartImplForTests(undefined);
51+
__setSessionOpenImplForTests(undefined);
52+
lastStartBody = undefined;
53+
});
54+
55+
// Build a fake chat agent task shape that the generic can narrow against.
56+
// We only need the static type — the runtime never invokes this task because
57+
// `__setSessionStartImplForTests` intercepts the network call.
58+
const fakeChat = chat
59+
.withClientData({
60+
schema: z.object({
61+
userId: z.string(),
62+
plan: z.enum(["free", "pro"]),
63+
}),
64+
})
65+
.agent({
66+
id: "fake-chat",
67+
run: async () => undefined as any,
68+
});
69+
70+
describe("chat.createStartSessionAction — runtime", () => {
71+
it("folds typed clientData into basePayload.metadata so onChatStart sees it on the first turn", async () => {
72+
installStartFixture();
73+
74+
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
75+
76+
const result = await start({
77+
chatId: "chat-1",
78+
clientData: { userId: "u-1", plan: "pro" },
79+
});
80+
81+
expect(result.publicAccessToken).toBe("tr_pat_fixture");
82+
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
83+
messages: [],
84+
trigger: "preload",
85+
metadata: { userId: "u-1", plan: "pro" },
86+
chatId: "chat-1",
87+
});
88+
});
89+
90+
it("leaves basePayload.metadata unset when clientData is not provided", async () => {
91+
installStartFixture();
92+
93+
const start = chat.createStartSessionAction("fake-chat");
94+
await start({ chatId: "chat-2" });
95+
96+
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
97+
});
98+
99+
it("keeps session-level metadata distinct from per-turn clientData", async () => {
100+
installStartFixture();
101+
102+
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
103+
await start({
104+
chatId: "chat-3",
105+
clientData: { userId: "u-3", plan: "free" },
106+
metadata: { source: "marketing-site" },
107+
});
108+
109+
// Per-turn shape (visible to onPreload / onChatStart):
110+
expect(lastStartBody?.triggerConfig.basePayload).toMatchObject({
111+
metadata: { userId: "u-3", plan: "free" },
112+
});
113+
// Session-row metadata (opaque, never typed via clientDataSchema):
114+
expect(lastStartBody?.metadata).toEqual({ source: "marketing-site" });
115+
});
116+
});
117+
118+
describe("chat.createStartSessionAction — types", () => {
119+
it("narrows clientData against the chat agent's clientDataSchema", () => {
120+
const start = chat.createStartSessionAction<typeof fakeChat>("fake-chat");
121+
122+
// The clientData field is typed off the agent's schema.
123+
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<
124+
{ userId: string; plan: "free" | "pro" } | undefined
125+
>();
126+
// The agent's typed clientData is strictly narrower than `unknown`.
127+
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().not.toEqualTypeOf<unknown>();
128+
});
129+
130+
it("defaults clientData to unknown when called without a generic", () => {
131+
const start = chat.createStartSessionAction("fake-chat");
132+
expectTypeOf(start).parameter(0).toHaveProperty("clientData");
133+
// Untyped variant — clientData is `unknown`.
134+
expectTypeOf<Parameters<typeof start>[0]["clientData"]>().toEqualTypeOf<unknown>();
135+
});
136+
});

0 commit comments

Comments
 (0)