Skip to content

Commit ba8bab3

Browse files
committed
fix(sdk): preserve reasoning parts across the head-start handover
synthesizeHandoverUIMessage only mapped text and tool-call parts, so an extended-thinking model's step-1 reasoning streamed to the browser but never reached the durable session history: onTurnComplete, chat.history, and reloads all lost it. Reasoning parts now map through with provider metadata so Anthropic thinking signatures survive the UIMessage round trip on hydrate replays.
1 parent f4e7fb2 commit ba8bab3

3 files changed

Lines changed: 85 additions & 0 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+
Preserve reasoning parts across the `chat.headStart` handover. Extended-thinking models' step-1 reasoning now lands in the durable session history (and `onTurnComplete`) under the same assistant `messageId`, with provider metadata intact so Anthropic thinking signatures survive replays.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,9 @@ const chatHandoverIsFinalKey = locals.create<boolean>("chat.handoverIsFinal");
18101810
* `tool-approval-response` rows are AI-SDK-internal and don't need a
18111811
* UIMessage representation. We map:
18121812
* - `text` parts → `{ type: "text", text }`
1813+
* - `reasoning` parts → `{ type: "reasoning", text, state: "done" }`
1814+
* (provider metadata carried so an Anthropic thinking signature
1815+
* survives a UIMessage → ModelMessage round trip)
18131816
* - `tool-call` parts → `{ type: "tool-${name}", toolCallId,
18141817
* state: "input-available", input }`
18151818
* - `tool-approval-request` parts → skipped (AI SDK derives the
@@ -1831,9 +1834,17 @@ function synthesizeHandoverUIMessage(
18311834
toolCallId?: string;
18321835
toolName?: string;
18331836
input?: unknown;
1837+
providerOptions?: unknown;
18341838
}>) {
18351839
if (part.type === "text" && typeof part.text === "string") {
18361840
parts.push({ type: "text", text: part.text } as UIMessage["parts"][number]);
1841+
} else if (part.type === "reasoning" && typeof part.text === "string") {
1842+
parts.push({
1843+
type: "reasoning",
1844+
text: part.text,
1845+
state: "done",
1846+
...(part.providerOptions ? { providerMetadata: part.providerOptions } : {}),
1847+
} as unknown as UIMessage["parts"][number]);
18371848
} else if (part.type === "tool-call" && part.toolCallId && part.toolName) {
18381849
parts.push({
18391850
type: `tool-${part.toolName}`,

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

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

263+
it("pure-text head-start preserves reasoning parts in the response (TRI-10716)", async () => {
264+
// Extended-thinking models stream a reasoning part in step 1. The
265+
// synthesized partial must carry it (with provider metadata, so an
266+
// Anthropic signature survives a UIMessage -> ModelMessage round
267+
// trip) or the durable history loses the step-1 thinking.
268+
let captured:
269+
| { partTypes?: string[]; reasoningText?: string; meta?: unknown }
270+
| undefined;
271+
272+
const agent = chat.agent({
273+
id: "chat.handover.reasoning",
274+
onTurnComplete: ({ responseMessage }) => {
275+
const parts = responseMessage?.parts ?? [];
276+
captured = {
277+
partTypes: parts.map((p) => p.type),
278+
reasoningText: parts
279+
.filter((p) => p.type === "reasoning")
280+
.map((p) => (p as { text?: string }).text || "")
281+
.join(""),
282+
meta: (parts.find((p) => p.type === "reasoning") as
283+
| { providerMetadata?: unknown }
284+
| undefined)?.providerMetadata,
285+
};
286+
},
287+
run: async ({ messages, signal }) => {
288+
return streamText({
289+
model: new MockLanguageModelV3({
290+
doStream: async () => ({ stream: textStream("should-not-run") }),
291+
}),
292+
messages,
293+
abortSignal: signal,
294+
});
295+
},
296+
});
297+
298+
const harness = mockChatAgent(agent, {
299+
chatId: "test-handover-reasoning",
300+
mode: "handover-prepare",
301+
});
302+
303+
try {
304+
await harness.sendHandover({
305+
partialAssistantMessage: [
306+
{
307+
role: "assistant",
308+
content: [
309+
{
310+
type: "reasoning",
311+
text: "thinking about the greeting",
312+
providerOptions: { anthropic: { signature: "sig-abc" } },
313+
},
314+
{ type: "text", text: "Hello!" },
315+
],
316+
},
317+
],
318+
messageId: "asst-reason-1",
319+
isFinal: true,
320+
});
321+
await new Promise((r) => setTimeout(r, 30));
322+
323+
expect(captured).toBeDefined();
324+
expect(captured!.partTypes).toEqual(["reasoning", "text"]);
325+
expect(captured!.reasoningText).toBe("thinking about the greeting");
326+
expect(captured!.meta).toEqual({ anthropic: { signature: "sig-abc" } });
327+
} finally {
328+
await harness.close();
329+
}
330+
});
331+
263332
it("pure-text head-start (isFinal: true) with hydrateMessages persists the partial (TRI-10715)", async () => {
264333
// Same as the pure-text case above, but the customer registers
265334
// `hydrateMessages` (the documented DB-as-source-of-truth pattern).

0 commit comments

Comments
 (0)