Skip to content

Commit f4e7fb2

Browse files
committed
fix(sdk): deliver the head-start handover partial when hydrateMessages is registered
The turn-0 handover splice only ran on the default accumulation path, so agents registering hydrateMessages lost the warm route's step-1 response: pure-text turns fired onTurnComplete with no assistant message, tool-call turns re-ran step 1 from scratch under a fresh messageId, and the head-start user message never reached the hydrate hook. The first-turn history now reaches hydrateMessages as incoming messages, and the splice runs after both accumulation branches, deduped by the handover messageId.
1 parent 7b4443a commit f4e7fb2

4 files changed

Lines changed: 258 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix `chat.headStart` when `hydrateMessages` is registered. The warm route's step-1 partial now reaches the agent's accumulator on the hydrate path, so `onTurnComplete` carries the full first turn (the head-start user message included), tool-call handovers resume from step 2 instead of re-running step 1, and the assistant `messageId` stays stable across the handover.

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

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6362,6 +6362,21 @@ function chatAgent<
63626362

63636363
let cleanedUIMessages: TUIMessage[] = cleanedIncomingMessages;
63646364

6365+
// Turn-0 head-start with hydrateMessages: the boot seeding from
6366+
// `payload.headStartMessages` is non-hydrate-only, so ship the
6367+
// route handler's first-turn history to the hydrate hook as
6368+
// incoming messages instead (gated on the pending handover).
6369+
if (
6370+
turn === 0 &&
6371+
hydrateMessages &&
6372+
cleanedUIMessages.length === 0 &&
6373+
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
6374+
Array.isArray(payload.headStartMessages) &&
6375+
payload.headStartMessages.length > 0
6376+
) {
6377+
cleanedUIMessages = payload.headStartMessages as TUIMessage[];
6378+
}
6379+
63656380
// Validate/transform UIMessages before conversion — catches malformed
63666381
// messages from storage or untrusted input before they reach the model.
63676382
// Slim wire: triggers like `regenerate-message` carry no incoming
@@ -6568,46 +6583,54 @@ function chatAgent<
65686583
// `preload` / `close` / `handover-prepare` and submits
65696584
// with no incoming message fall through with the boot-
65706585
// seeded accumulator unchanged.
6586+
}
65716587

6572-
if (turn === 0) {
6573-
// Head-start handover splice (turn 0 only): the
6574-
// `chat.handover` route handler signalled a mid-turn
6575-
// handover, so splice its partial assistant response
6576-
// (text + pending tool-calls + the synthesized
6577-
// tool-approval round) onto the accumulator.
6578-
// `streamText` then hits AI SDK's initial-tool-
6579-
// execution branch, runs the agent-side tool executes,
6580-
// and resumes from step 2 — skipping the first model
6581-
// call (already done by the handler).
6582-
//
6583-
// We also synthesize a UIMessage form of the partial
6584-
// assistant and push it to `accumulatedUIMessages` so
6585-
// AI SDK's `processUIMessageStream` (invoked when the
6586-
// run loop calls `runResult.toUIMessageStream({
6587-
// onFinish })`) can initialize `state.message` from
6588-
// the trailing assistant in `originalMessages`. Without
6589-
// that, the `tool-output-available` chunks emitted by
6590-
// the initial-tool-execution branch can't find their
6591-
// matching tool-call in state and AI SDK throws
6592-
// `UIMessageStreamError: No tool invocation found`.
6593-
const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
6594-
if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
6588+
if (turn === 0) {
6589+
// Head-start handover splice (turn 0 only, BOTH
6590+
// accumulation branches — hydrate and default): the
6591+
// `chat.handover` route handler signalled a mid-turn
6592+
// handover, so splice its partial assistant response
6593+
// (text + pending tool-calls + the synthesized
6594+
// tool-approval round) onto the accumulator.
6595+
// `streamText` then hits AI SDK's initial-tool-
6596+
// execution branch, runs the agent-side tool executes,
6597+
// and resumes from step 2 — skipping the first model
6598+
// call (already done by the handler).
6599+
//
6600+
// We also synthesize a UIMessage form of the partial
6601+
// assistant and push it to `accumulatedUIMessages` so
6602+
// AI SDK's `processUIMessageStream` (invoked when the
6603+
// run loop calls `runResult.toUIMessageStream({
6604+
// onFinish })`) can initialize `state.message` from
6605+
// the trailing assistant in `originalMessages`. Without
6606+
// that, the `tool-output-available` chunks emitted by
6607+
// the initial-tool-execution branch can't find their
6608+
// matching tool-call in state and AI SDK throws
6609+
// `UIMessageStreamError: No tool invocation found`.
6610+
const pendingHandoverPartial = locals.get(chatHandoverPartialKey);
6611+
if (pendingHandoverPartial && pendingHandoverPartial.length > 0) {
6612+
const handoverMessageId = locals.get(chatHandoverMessageIdKey);
6613+
// Skip if the hydrated chain already persisted the
6614+
// partial under the handover messageId.
6615+
const alreadyInChain =
6616+
handoverMessageId !== undefined &&
6617+
accumulatedUIMessages.some((m) => m.id === handoverMessageId);
6618+
if (!alreadyInChain) {
65956619
accumulatedMessages.push(...pendingHandoverPartial);
6596-
const handoverMessageId = locals.get(chatHandoverMessageIdKey);
65976620
const partialUI = synthesizeHandoverUIMessage(
65986621
pendingHandoverPartial,
65996622
handoverMessageId
66006623
);
66016624
if (partialUI) {
66026625
accumulatedUIMessages.push(partialUI as TUIMessage);
66036626
}
6604-
locals.set(chatHandoverPartialKey, []); // consume once
66056627
}
6628+
locals.set(chatHandoverPartialKey, []); // consume once
66066629
}
6607-
6608-
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
66096630
}
66106631

6632+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
6633+
66116634
} // end if (trigger !== "action")
66126635

66136636
// ── Action result handling ──────────────────────────────

packages/trigger-sdk/src/v3/test/mock-chat-agent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ export type MockChatAgentOptions = {
9292
* `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal.
9393
*/
9494
mode?: "preload" | "submit-message" | "handover-prepare" | "continuation";
95+
/**
96+
* First-turn UIMessage history shipped on the BOOT payload. Only
97+
* meaningful with `mode: "handover-prepare"` — mirrors the
98+
* `chat.headStart` route handler's `basePayload.headStartMessages`.
99+
*/
100+
headStartMessages?: UIMessage[];
95101
/**
96102
* Pre-seed the snapshot the agent reads at run boot. The runtime's
97103
* snapshot read is replaced with one that returns this snapshot
@@ -501,6 +507,7 @@ export function mockChatAgent(
501507
metadata: clientData,
502508
...(!isContinuationMode && options.continuation ? { continuation: true } : {}),
503509
...(options.previousRunId ? { previousRunId: options.previousRunId } : {}),
510+
...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}),
504511
};
505512

506513
sendSessionInput = drivers.sessions.in.send;

packages/trigger-sdk/test/chatHandover.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,202 @@ describe("chat.handover", () => {
260260
}
261261
});
262262

263+
it("pure-text head-start (isFinal: true) with hydrateMessages persists the partial (TRI-10715)", async () => {
264+
// Same as the pure-text case above, but the customer registers
265+
// `hydrateMessages` (the documented DB-as-source-of-truth pattern).
266+
// The head-start user message must reach the hydrate hook as
267+
// `incomingMessages`, and the warm route's partial must land in the
268+
// accumulator so `onTurnComplete` carries the full first turn.
269+
const runFn = vi.fn();
270+
const stored: { id: string; role: string; parts: unknown[] }[] = [];
271+
const hydrateIncomingRoles: string[] = [];
272+
let captured:
273+
| { responseId?: string; responseText?: string; roles?: string[] }
274+
| undefined;
275+
276+
const agent = chat.agent({
277+
id: "chat.handover.hydrate-pure-text",
278+
hydrateMessages: async ({ incomingMessages }) => {
279+
hydrateIncomingRoles.push(...incomingMessages.map((m) => m.role));
280+
for (const m of incomingMessages) {
281+
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
282+
}
283+
return [...stored] as never;
284+
},
285+
onTurnComplete: ({ responseMessage, uiMessages }) => {
286+
captured = {
287+
responseId: responseMessage?.id,
288+
responseText: (responseMessage?.parts ?? [])
289+
.filter((p) => p.type === "text")
290+
.map((p) => (p as { text?: string }).text || "")
291+
.join(""),
292+
roles: uiMessages.map((m) => m.role),
293+
};
294+
},
295+
run: async ({ messages, signal }) => {
296+
runFn();
297+
return streamText({
298+
model: new MockLanguageModelV3({
299+
doStream: async () => ({ stream: textStream("should-not-run") }),
300+
}),
301+
messages,
302+
abortSignal: signal,
303+
});
304+
},
305+
});
306+
307+
const harness = mockChatAgent(agent, {
308+
chatId: "test-handover-hydrate-final",
309+
mode: "handover-prepare",
310+
headStartMessages: [
311+
{ id: "hs-user-1", role: "user", parts: [{ type: "text", text: "say hi" }] },
312+
],
313+
});
314+
315+
try {
316+
await harness.sendHandover({
317+
partialAssistantMessage: [
318+
{
319+
role: "assistant",
320+
content: [{ type: "text", text: "Hi there, hope you're well." }],
321+
},
322+
],
323+
messageId: "asst-hydrate-1",
324+
isFinal: true,
325+
});
326+
await new Promise((r) => setTimeout(r, 30));
327+
328+
// isFinal — the agent never calls the user's run().
329+
expect(runFn).not.toHaveBeenCalled();
330+
331+
// The head-start user message reached the hydrate hook as incoming.
332+
expect(hydrateIncomingRoles).toContain("user");
333+
334+
// onTurnComplete carries the full first turn: user + the warm
335+
// route's assistant, under the handover messageId.
336+
expect(captured).toBeDefined();
337+
expect(captured!.roles).toEqual(["user", "assistant"]);
338+
expect(captured!.responseId).toBe("asst-hydrate-1");
339+
expect(captured!.responseText).toBe("Hi there, hope you're well.");
340+
} finally {
341+
await harness.close();
342+
}
343+
});
344+
345+
it("tool-call handover (isFinal: false) with hydrateMessages resumes from step 2 (TRI-10715)", async () => {
346+
// Hydrate variant of the schema-only tool-call case: the spliced
347+
// partial (assistant + approval round) must reach the agent's
348+
// streamText so AI SDK executes the pending tool instead of
349+
// re-running step 1 from scratch against an empty/short prompt.
350+
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
351+
const weatherTool = tool({
352+
description: "Look up weather",
353+
inputSchema: z.object({ city: z.string() }),
354+
execute: toolExecute,
355+
});
356+
357+
const stored: { id: string; role: string; parts: unknown[] }[] = [];
358+
let runMessageRoles: string[] | undefined;
359+
let captured: { roles?: string[]; assistantIds?: (string | undefined)[] } | undefined;
360+
361+
const agent = chat.agent({
362+
id: "chat.handover.hydrate-schema-only-tool",
363+
hydrateMessages: async ({ incomingMessages }) => {
364+
for (const m of incomingMessages) {
365+
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
366+
}
367+
return [...stored] as never;
368+
},
369+
onTurnComplete: ({ uiMessages }) => {
370+
captured = {
371+
roles: uiMessages.map((m) => m.role),
372+
assistantIds: uiMessages.filter((m) => m.role === "assistant").map((m) => m.id),
373+
};
374+
},
375+
run: async ({ messages, signal }) => {
376+
runMessageRoles = messages.map((m) => m.role);
377+
return streamText({
378+
model: new MockLanguageModelV3({
379+
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
380+
}),
381+
messages,
382+
tools: { weather: weatherTool },
383+
abortSignal: signal,
384+
});
385+
},
386+
});
387+
388+
const harness = mockChatAgent(agent, {
389+
chatId: "test-handover-hydrate-tool",
390+
mode: "handover-prepare",
391+
headStartMessages: [
392+
{ id: "hs-user-2", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] },
393+
],
394+
});
395+
396+
try {
397+
const turn = await harness.sendHandover({
398+
isFinal: false,
399+
messageId: "asst-hydrate-2",
400+
partialAssistantMessage: [
401+
{
402+
role: "assistant",
403+
content: [
404+
{ type: "text", text: "let me check the weather" },
405+
{
406+
type: "tool-call",
407+
toolCallId: "tc-h1",
408+
toolName: "weather",
409+
input: { city: "tokyo" },
410+
},
411+
{
412+
type: "tool-approval-request",
413+
approvalId: "handover-approval-h1",
414+
toolCallId: "tc-h1",
415+
},
416+
],
417+
},
418+
{
419+
role: "tool",
420+
content: [
421+
{
422+
type: "tool-approval-response",
423+
approvalId: "handover-approval-h1",
424+
approved: true,
425+
},
426+
],
427+
},
428+
],
429+
});
430+
await new Promise((r) => setTimeout(r, 30));
431+
432+
// The resume prompt contained the full splice: user + partial
433+
// assistant + approval round — NOT an empty/user-only prompt.
434+
expect(runMessageRoles).toEqual(["user", "assistant", "tool"]);
435+
436+
// AI SDK's initial-tool-execution branch ran the agent-side
437+
// execute (no step-1 re-run).
438+
expect(toolExecute).toHaveBeenCalledWith(
439+
expect.objectContaining({ city: "tokyo" }),
440+
expect.anything()
441+
);
442+
443+
// Step-2 text streamed through session.out.
444+
const text = turn.chunks
445+
.filter((c) => c.type === "text-delta")
446+
.map((c) => (c as { delta: string }).delta)
447+
.join("");
448+
expect(text).toContain("tokyo");
449+
450+
// One assistant in the final chain, under the handover messageId.
451+
expect(captured).toBeDefined();
452+
expect(captured!.roles).toEqual(["user", "assistant"]);
453+
expect(captured!.assistantIds).toEqual(["asst-hydrate-2"]);
454+
} finally {
455+
await harness.close();
456+
}
457+
});
458+
263459
it("onTurnStart fires after the handover signal arrives (lazy)", async () => {
264460
// Hooks should not fire during the wait — only once handover lands
265461
// and a real turn begins. Verifies the order so customers can

0 commit comments

Comments
 (0)