Skip to content

Commit 881cd8e

Browse files
committed
fix(chat): narrow custom agent validation contract
1 parent cb35a3a commit 881cd8e

6 files changed

Lines changed: 103 additions & 19 deletions

File tree

docs/ai-chat/client-protocol.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,9 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
832832
}
833833
```
834834
835-
Actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/ai-chat/actions) for the agent-side schema setup.
835+
For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/ai-chat/actions) for the agent-side schema setup.
836+
837+
Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
836838
837839
### Regenerating the last response
838840

docs/ai-chat/custom-agents.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@ Inside the wrapper, pick one of two loop styles:
2121

2222
### Validating client data
2323

24-
Use `chat.withClientData({ schema })` to validate `payload.metadata`. Custom agents parse the initial payload and later message and action frames before passing them to `run`, `chat.messages`, or `chat.createSession`. Schema defaults and transforms are included in the value your code receives. Close frames are not validated.
24+
Use `chat.withClientData({ schema })` to validate `payload.metadata`. Custom agents parse the metadata on the initial payload and every later non-close input frame before passing it to `run`, `chat.messages`, or `chat.createSession`. Schema defaults and transforms are included in the value your code receives.
2525

26-
If validation fails for a submitted turn or an async read such as `wait()`, the SDK writes an error chunk followed by `turn-complete` and skips the invalid frame. The invalid value never reaches your run handler or turn loop. The task then waits for the next valid frame. A messageless preload or continuation boot has no submitted turn to complete, so the SDK reports the error through the task log and `onClientDataValidationError` while it waits.
26+
This only validates `metadata`. A raw custom agent does not expose an action schema, so `payload.action` remains `unknown`. Validate the full frame or action payload in your own loop when you need that boundary.
27+
28+
If validation fails for a submitted turn or an async read such as `wait()`, the SDK consumes and skips the invalid frame, writes an `Invalid client data` error followed by `turn-complete`, then waits for the next valid frame. The invalid value is not returned to the raw caller. The detailed validator error is available in the task log and `onClientDataValidationError`, but it is not sent to the client.
29+
30+
This convenience path settles the invalid input before the read returns. If your raw loop needs to coordinate validation with persistence or settlement, omit `withClientData({ schema })` and validate the full wire frame in the loop instead. A messageless preload or continuation boot has no submitted turn to complete, so the SDK reports the error through the task log and callback while it waits.
2731

2832
An invalid [head-start handover](/ai-chat/fast-starts#handover-with-custom-agents) boot fails closed. The SDK waits for the warm handler to finish so stream ordering stays intact. A handover skip ends the run. A real handover writes the validation error and `turn-complete` after the warm output, then ends the run. Without a schema, metadata is passed through unchanged.
2933

30-
`chat.messages.on()` is different because a subscribed frame can arrive while the current response is still streaming. Ending the turn at that point would cut off the response. While the subscription is active, the SDK skips the frame, logs the validation error, and calls `onClientDataValidationError` if you set it. This also applies to the steering subscription created by `chat.createSession({ pendingMessages })`. Calling `off()` prevents queued validation from invoking your handler or error callback.
34+
`chat.messages.on()` is different because a subscribed frame can arrive while the current response is still streaming. Ending the turn at that point would cut off the response. While the subscription is active, the SDK skips an invalid frame, logs the validation error, and calls `onClientDataValidationError` if you set it. This also applies to the steering subscription created by `chat.createSession({ pendingMessages })`.
35+
36+
Calling `off()` stops the subscription from accepting new frames. A valid frame accepted before `off()` still finishes validation and is delivered to the handler. An invalid frame that finishes validation after `off()` is logged without calling the handler or error callback.
3137

3238
```ts
3339
import { chat } from "@trigger.dev/sdk/ai";

docs/ai-chat/reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ Send a custom action to the agent. Actions wake the agent from suspension and fi
786786
transport.sendAction(chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>>
787787
```
788788

789-
The action payload is validated against the agent's `actionSchema` on the backend.
789+
For managed `chat.agent()` tasks, the action payload is validated against the agent's `actionSchema` on the backend. Raw `chat.customAgent()` tasks receive it as `unknown` and must validate it themselves.
790790

791791
```tsx
792792
// Undo button

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,8 @@ type ChatCustomAgentClientDataErrorHandler = (event: {
14411441
payload: ChatTaskWirePayload;
14421442
}) => Promise<void> | void;
14431443

1444+
const CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT = "Invalid client data";
1445+
14441446
const chatCustomAgentClientDataParserKey = locals.create<ChatCustomAgentClientDataParser>(
14451447
"chat.customAgentClientDataParser"
14461448
);
@@ -1503,13 +1505,14 @@ function getChatCustomAgentSyncSchemaParseFn(schema: TaskSchema): (value: unknow
15031505
}
15041506

15051507
async function writeChatCustomAgentClientDataErrorToStream(
1506-
payload: ChatTaskWirePayload,
1507-
error: unknown
1508+
payload: ChatTaskWirePayload
15081509
): Promise<void> {
1509-
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
15101510
try {
15111511
await withChatWriter((writer) => {
1512-
writer.write({ type: "error", errorText } as any);
1512+
writer.write({
1513+
type: "error",
1514+
errorText: CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT,
1515+
} as any);
15131516
});
15141517
await chatWriteTurnComplete();
15151518
} catch (signalError) {
@@ -1552,7 +1555,7 @@ async function reportChatCustomAgentClientDataError(
15521555
if (!options.writeToStream) {
15531556
return;
15541557
}
1555-
await writeChatCustomAgentClientDataErrorToStream(payload, error);
1558+
await writeChatCustomAgentClientDataErrorToStream(payload);
15561559
}
15571560

15581561
type ChatCustomAgentPayloadValidationResult<TPayload extends ChatTaskWirePayload> =
@@ -1796,7 +1799,8 @@ const messagesInput: RealtimeDefinedInputStream<ChatTaskWirePayload> = {
17961799
return subscribeToRawChatMessages(handler);
17971800
}
17981801

1799-
return subscribeToValidatedChatMessages((payload) => handler(payload));
1802+
const deliver = (payload: ChatTaskWirePayload) => handler(payload);
1803+
return subscribeToValidatedChatMessages(deliver, { onAfterOff: deliver });
18001804
},
18011805
once(options) {
18021806
const ctx = taskContext.ctx;
@@ -5594,6 +5598,7 @@ type ChatCustomAgentOptions<
55945598
* error chunk followed by `turn-complete`. Messageless boots and active
55955599
* subscriptions use `onClientDataValidationError` and the task log because
55965600
* there is no submitted turn to complete or a response may still be streaming.
5601+
* This validates `metadata` only; raw `action` payloads remain `unknown`.
55975602
*/
55985603
clientDataSchema?: TClientDataSchema;
55995604
/**
@@ -5719,7 +5724,7 @@ function chatCustomAgent<
57195724

57205725
// The head-start writer flushes before sending this signal. Writing
57215726
// the terminal error now preserves stream order and closes the stitch.
5722-
await writeChatCustomAgentClientDataErrorToStream(payload, validated.error);
5727+
await writeChatCustomAgentClientDataErrorToStream(payload);
57235728
return;
57245729
}
57255730

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadat
9595
| "handover-prepare";
9696
messageId?: string;
9797
metadata?: TMetadata;
98-
/** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */
98+
/**
99+
* Custom action payload when `trigger` is `"action"`. Managed agents validate
100+
* this against `actionSchema`; raw custom agents must validate it themselves.
101+
*/
99102
action?: unknown;
100103
/** Whether this run is continuing an existing chat whose previous run ended. */
101104
continuation?: boolean;

packages/trigger-sdk/test/custom-agent-client-data-validation.test.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe("chat.customAgent clientData validation", () => {
9090
};
9191
let started = false;
9292
const receivedClientData: unknown[] = [];
93+
const validationErrors: unknown[] = [];
9394

9495
const agent = chat
9596
.withClientData({
@@ -100,6 +101,9 @@ describe("chat.customAgent clientData validation", () => {
100101
})
101102
.customAgent({
102103
id: "custom-agent-client-data-invalid-frame",
104+
onClientDataValidationError: ({ error }) => {
105+
validationErrors.push(error);
106+
},
103107
run: async (payload, { signal }) => {
104108
started = true;
105109
const session = chat.createSession(payload, {
@@ -126,8 +130,10 @@ describe("chat.customAgent clientData validation", () => {
126130

127131
expect(receivedClientData).toHaveLength(0);
128132
expect(invalidTurn.chunks).toEqual([
129-
expect.objectContaining({ type: "error", errorText: expect.any(String) }),
133+
expect.objectContaining({ type: "error", errorText: "Invalid client data" }),
130134
]);
135+
expect(validationErrors).toHaveLength(1);
136+
expect(validationErrors[0]).toBeInstanceOf(z.ZodError);
131137
expect(invalidTurn.rawChunks).toContainEqual(
132138
expect.objectContaining({ type: "trigger:turn-complete" })
133139
);
@@ -229,7 +235,7 @@ describe("chat.customAgent clientData validation", () => {
229235

230236
expect(runCalls).toBe(0);
231237
expect(harness.allChunks).toEqual([
232-
expect.objectContaining({ type: "error", errorText: expect.any(String) }),
238+
expect.objectContaining({ type: "error", errorText: "Invalid client data" }),
233239
]);
234240

235241
clientData.userId = "user_123";
@@ -301,7 +307,7 @@ describe("chat.customAgent clientData validation", () => {
301307
}
302308
});
303309

304-
it("does not deliver frames or validation callbacks after chat.messages.on is removed", async () => {
310+
it("does not report an invalid frame whose validation finishes after chat.messages.on is removed", async () => {
305311
const clientData = { blocked: false };
306312
const parserStarted = deferred();
307313
const releaseParser = deferred();
@@ -365,6 +371,68 @@ describe("chat.customAgent clientData validation", () => {
365371
}
366372
});
367373

374+
it("delivers a valid frame accepted before chat.messages.on is removed", async () => {
375+
const clientData = { blocked: false };
376+
const parserStarted = deferred();
377+
const releaseParser = deferred();
378+
const delivered = deferred();
379+
let removeSubscription: (() => void) | undefined;
380+
let receivedMetadata: unknown;
381+
let handlerCalls = 0;
382+
let started = false;
383+
384+
const agent = chat
385+
.withClientData({
386+
schema: async (value: unknown) => {
387+
const blocked = (value as { blocked: boolean }).blocked;
388+
if (blocked) {
389+
parserStarted.resolve();
390+
await releaseParser.promise;
391+
}
392+
return { blocked, parsed: true as const };
393+
},
394+
})
395+
.customAgent({
396+
id: "custom-agent-client-data-deliver-pending-after-off",
397+
run: async (_payload, { signal }) => {
398+
started = true;
399+
const subscription = chat.messages.on(async (payload) => {
400+
handlerCalls++;
401+
receivedMetadata = payload.metadata;
402+
await chat.writeTurnComplete();
403+
delivered.resolve();
404+
});
405+
removeSubscription = () => subscription.off();
406+
await new Promise<void>((resolve) => {
407+
signal.addEventListener("abort", () => resolve(), { once: true });
408+
});
409+
},
410+
});
411+
412+
const harness = mockChatAgent(agent, {
413+
chatId: "custom-agent-client-data-deliver-pending-after-off-chat",
414+
clientData,
415+
});
416+
417+
try {
418+
await waitFor(() => started);
419+
clientData.blocked = true;
420+
const send = harness.sendMessage(userMessage("hello", "message-1"));
421+
await parserStarted.promise;
422+
423+
removeSubscription!();
424+
releaseParser.resolve();
425+
426+
await send;
427+
await delivered.promise;
428+
expect(handlerCalls).toBe(1);
429+
expect(receivedMetadata).toEqual({ blocked: true, parsed: true });
430+
} finally {
431+
releaseParser.resolve();
432+
await harness.close();
433+
}
434+
});
435+
368436
it("throws from chat.messages.peek when an object parser returns a promise", async () => {
369437
const clientData = { userId: "user_123" };
370438
let started = false;
@@ -464,7 +532,7 @@ describe("chat.customAgent clientData validation", () => {
464532

465533
expect(receivedClientData).toEqual([{ attempt: 1 }]);
466534
expect(harness.allChunks).toContainEqual(
467-
expect.objectContaining({ type: "error", errorText: expect.any(String) })
535+
expect.objectContaining({ type: "error", errorText: "Invalid client data" })
468536
);
469537
} finally {
470538
releaseFirstTurn.resolve();
@@ -626,7 +694,7 @@ describe("chat.customAgent clientData validation", () => {
626694
expect(lateFrameParseCalls).toBe(1);
627695
expect(receivedSequences).toEqual([1]);
628696
expect(harness.allChunks).toContainEqual(
629-
expect.objectContaining({ type: "error", errorText: "invalid late frame" })
697+
expect.objectContaining({ type: "error", errorText: "Invalid client data" })
630698
);
631699
} finally {
632700
releaseFirstTurn.resolve();
@@ -754,7 +822,7 @@ describe("chat.customAgent clientData validation", () => {
754822

755823
expect(runCalls).toBe(0);
756824
expect(handover.chunks).toEqual([
757-
expect.objectContaining({ type: "error", errorText: expect.any(String) }),
825+
expect.objectContaining({ type: "error", errorText: "Invalid client data" }),
758826
]);
759827
expect(handover.rawChunks).toContainEqual(
760828
expect.objectContaining({ type: "trigger:turn-complete" })

0 commit comments

Comments
 (0)