Skip to content

Commit c18cbc6

Browse files
authored
Merge branch 'main' into sentry-user-attribution
2 parents 487f6e3 + 89d085a commit c18cbc6

6 files changed

Lines changed: 210 additions & 27 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+
});

references/ai-chat/src/trigger/chat-client-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export const orchestratorAgent = chat
272272
stop.reset();
273273

274274
const messages = await conversation.addIncoming(
275-
currentPayload.messages,
275+
currentPayload.message ? [currentPayload.message] : [],
276276
currentPayload.trigger,
277277
turn
278278
);

references/ai-chat/src/trigger/chat.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ export const aiChatRaw = chat.customAgent({
659659
stop.reset();
660660

661661
const messages = await conversation.addIncoming(
662-
currentPayload.messages,
662+
currentPayload.message ? [currentPayload.message] : [],
663663
currentPayload.trigger,
664664
turn
665665
);
@@ -678,8 +678,7 @@ export const aiChatRaw = chat.customAgent({
678678
const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
679679

680680
const steeringSub = chat.messages.on(async (msg) => {
681-
const lastMsg = msg.messages?.[msg.messages.length - 1];
682-
if (lastMsg) await conversation.steerAsync(lastMsg);
681+
if (msg.message) await conversation.steerAsync(msg.message);
683682
});
684683

685684
const result = streamText({
@@ -1049,10 +1048,10 @@ export const cfTrustTestAgent = chat
10491048
id: "cf-trust-test",
10501049
idleTimeoutInSeconds: 60,
10511050
onTurnStart: async ({ turn, clientData }) => {
1052-
logger.info("cf-trust-test turn", { turn, cf: clientData.__cf, userId: clientData.userId });
1051+
logger.info("cf-trust-test turn", { turn, cf: clientData!.__cf, userId: clientData!.userId });
10531052
},
10541053
run: async ({ messages, clientData, signal }) => {
1055-
const cf = clientData.__cf;
1054+
const cf = clientData!.__cf;
10561055
return streamText({
10571056
model: openai("gpt-4o-mini"),
10581057
system:

references/ai-chat/src/trigger/stress-emit.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// Defaults: 1000 chunks × 10 chars, single message.
1111

1212
import { chat } from "@trigger.dev/sdk/ai";
13-
import { type UIMessage, simulateReadableStream, streamText } from "ai";
13+
import { type ModelMessage, simulateReadableStream, streamText } from "ai";
1414
import { MockLanguageModelV3 } from "ai/test";
1515
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
1616

@@ -20,10 +20,16 @@ type StressConfig = {
2020
manyMessages: boolean;
2121
};
2222

23-
function parseConfig(messages: UIMessage[]): StressConfig {
23+
function parseConfig(messages: ModelMessage[]): StressConfig {
2424
const lastUser = [...messages].reverse().find((m) => m.role === "user");
25-
const text =
26-
lastUser?.parts?.[0]?.type === "text" ? lastUser.parts[0].text.trim() : "";
25+
const content = lastUser?.content;
26+
let text = "";
27+
if (typeof content === "string") {
28+
text = content.trim();
29+
} else if (Array.isArray(content)) {
30+
const textPart = content.find((p) => p.type === "text");
31+
text = textPart && "text" in textPart ? textPart.text.trim() : "";
32+
}
2733
const parts = text.split(/\s+/);
2834
const chunkCount = Number(parts[0]);
2935
const chunkSize = Number(parts[1]);

0 commit comments

Comments
 (0)