Skip to content

Commit 80790a7

Browse files
committed
fix(chat): harden custom agent validation recovery
1 parent 50c86cd commit 80790a7

3 files changed

Lines changed: 123 additions & 3 deletions

File tree

docs/ai-chat/custom-agents.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Inside the wrapper, pick one of two loop styles:
2323

2424
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.
2525

26-
If validation fails in `run`, `chat.createSession()`, 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. If the initial payload is invalid, the task waits for the next valid frame instead of starting `run` with bad data. Without a schema, metadata is passed through unchanged.
26+
If validation fails in `run`, `chat.createSession()`, 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. If the initial payload is invalid, the task waits for the next valid frame instead of starting `run` with bad data. On a [head-start handover](/ai-chat/fast-starts#handover-with-custom-agents) boot with invalid client data, the SDK drains the warm handover signal first: a skip ends the run, and a real handover partial is discarded with a logged warning. Without a schema, metadata is passed through unchanged.
2727

2828
`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. The SDK skips the frame, logs the validation error, and calls `onClientDataValidationError` if you set it:
2929

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5539,10 +5539,13 @@ type ChatCustomAgentOptions<
55395539
* `chat.messages.on()` cannot safely complete a turn that may still be
55405540
* streaming, so subscribed frames are reported through this callback and
55415541
* the task log instead.
5542+
*
5543+
* `payload.metadata` is typed `unknown`: this callback only fires when
5544+
* the metadata failed to parse, so it can be any shape the client sent.
55425545
*/
55435546
onClientDataValidationError?: (event: {
55445547
error: unknown;
5545-
payload: ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>;
5548+
payload: ChatTaskWirePayload<TUIMessage, unknown>;
55465549
}) => Promise<void> | void;
55475550
run: TaskOptions<
55485551
TIdentifier,
@@ -5628,6 +5631,28 @@ function chatCustomAgent<
56285631
);
56295632
}
56305633

5634+
// A handover-prepare boot parks the warm handler's signal on
5635+
// `session.in`. Drain it with the handover facade BEFORE the message
5636+
// wait below — that facade consumes-and-discards non-message chunks
5637+
// and would swallow the signal (see `waitForHandover`). The warm
5638+
// partial cannot be spliced without valid clientData: mirror the
5639+
// normal flow for skip/crash (exit without a turn) and drop a real
5640+
// partial — the error chunk above already reported the failure.
5641+
if (payload.trigger === "handover-prepare") {
5642+
const signal = await waitForHandover({
5643+
payload,
5644+
timeout: "1h",
5645+
spanName: "waiting for handover signal (invalid clientData)",
5646+
});
5647+
if (!signal || signal.kind === "handover-skip") {
5648+
return;
5649+
}
5650+
logger.warn(
5651+
"chat.customAgent: dropping head-start handover partial — clientData failed validation",
5652+
{ chatId: payload.chatId, isFinal: signal.isFinal }
5653+
);
5654+
}
5655+
56315656
// The Session base payload is sticky across continuation runs. If it is
56325657
// invalid, returning here would boot the same bad metadata again on the
56335658
// next message. Stay attached and wait for a valid wire frame instead.
@@ -8561,7 +8586,10 @@ export interface ChatBuilder<
85618586
options: ChatCustomAgentOptions<TId, undefined, TUIMessage>
85628587
) => Task<TId, ChatTaskWirePayload<TUIMessage, undefined>, unknown>
85638588
: <TId extends string>(
8564-
options: ChatCustomAgentOptions<TId, TClientDataSchema, TUIMessage>
8589+
options: Omit<
8590+
ChatCustomAgentOptions<TId, TClientDataSchema, TUIMessage>,
8591+
"clientDataSchema"
8592+
>
85658593
) => Task<TId, ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>, unknown>;
85668594
}
85678595

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,98 @@ describe("chat.customAgent clientData validation", () => {
421421
}
422422
});
423423

424+
it("exits without a turn when a handover-prepare boot has invalid clientData and the warm handler skips", async () => {
425+
const clientData: { userId: unknown } = { userId: 123 };
426+
let runCalls = 0;
427+
428+
const agent = chat.withClientData({ schema: z.object({ userId: z.string() }) }).customAgent({
429+
id: "custom-agent-client-data-handover-skip",
430+
run: async () => {
431+
runCalls++;
432+
await chat.writeTurnComplete();
433+
},
434+
});
435+
436+
const harness = mockChatAgent(agent, {
437+
chatId: "custom-agent-client-data-handover-skip-chat",
438+
mode: "handover-prepare",
439+
clientData,
440+
});
441+
442+
try {
443+
await waitFor(() =>
444+
harness.allChunks.some((chunk) => (chunk as { type?: string }).type === "error")
445+
);
446+
expect(runCalls).toBe(0);
447+
448+
// The recovery path must drain the skip via the handover facade and
449+
// end the run, mirroring the normal handover-skip exit.
450+
await harness.sendHandoverSkip();
451+
452+
// The run has exited — a valid frame must NOT boot the loop. (Without
453+
// the drain, the run would still be sitting in the message wait and
454+
// would process it.) Fire-and-forget: no turn-complete will arrive.
455+
clientData.userId = "user_123";
456+
void harness.sendMessage(userMessage("late", "message-1")).catch(() => {});
457+
await new Promise((resolve) => setTimeout(resolve, 100));
458+
expect(runCalls).toBe(0);
459+
} finally {
460+
await harness.close();
461+
}
462+
});
463+
464+
it("drops the head-start partial and recovers on the next valid frame when a handover-prepare boot has invalid clientData", async () => {
465+
const clientData: { userId: unknown } = { userId: 123 };
466+
let runCalls = 0;
467+
let receivedTrigger: string | undefined;
468+
let receivedClientData: unknown;
469+
470+
const agent = chat.withClientData({ schema: z.object({ userId: z.string() }) }).customAgent({
471+
id: "custom-agent-client-data-handover-drop",
472+
run: async (payload) => {
473+
runCalls++;
474+
receivedTrigger = payload.trigger;
475+
receivedClientData = payload.metadata;
476+
await chat.writeTurnComplete();
477+
},
478+
});
479+
480+
const harness = mockChatAgent(agent, {
481+
chatId: "custom-agent-client-data-handover-drop-chat",
482+
mode: "handover-prepare",
483+
clientData,
484+
});
485+
486+
try {
487+
await waitFor(() =>
488+
harness.allChunks.some((chunk) => (chunk as { type?: string }).type === "error")
489+
);
490+
expect(runCalls).toBe(0);
491+
492+
// Resolves on the next turn-complete — the recovered message turn below.
493+
const handover = harness.sendHandover({
494+
partialAssistantMessage: [
495+
{ role: "assistant", content: [{ type: "text", text: "warm partial" }] },
496+
],
497+
});
498+
// Let the recovery drain consume the handover signal before the
499+
// message frame goes out — a frame arriving mid-drain would be
500+
// discarded by the handover facade (same as the pre-existing turn-0
501+
// handover wait in chat.createSession).
502+
await new Promise((resolve) => setTimeout(resolve, 50));
503+
504+
clientData.userId = "user_123";
505+
await harness.sendMessage(userMessage("retry", "message-1"));
506+
await handover;
507+
508+
expect(runCalls).toBe(1);
509+
expect(receivedTrigger).toBe("submit-message");
510+
expect(receivedClientData).toEqual({ userId: "user_123" });
511+
} finally {
512+
await harness.close();
513+
}
514+
});
515+
424516
it("passes clientData through unchanged when no schema is configured", async () => {
425517
const clientData = { userId: "user_123", nested: { enabled: true } };
426518
let initialClientData: unknown;

0 commit comments

Comments
 (0)