Skip to content

Commit 8b72e6c

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(sdk): replace the head-start partial in the model lane instead of appending after it
`chat.agent`: after a Head Start turn whose handed-over tool call was followed by more tool steps, the next turn no longer fails with `tool_use ids must be unique`. The completed response reuses the warm step's message id, and the runtime swaps the partial's run in the model context for the response's. The synthesized partial converts to zero model messages because its tool call has no output yet, so the swap matched an empty slice and spliced the response in behind the still-present partial. An empty old run now counts as no match, and the runtime rebuilds the model context from the merged history instead. Includes a changeset for `@trigger.dev/sdk` (patch). Mono-RevId: 1a0b38b58f96139ede347ec307437721dfc6c226
1 parent b3d0394 commit 8b72e6c

3 files changed

Lines changed: 137 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+
`chat.agent`: after a Head Start turn whose handed-over tool call was followed by more tool steps, the next turn no longer fails with `tool_use ids must be unique`. The runtime kept the warm step's pending tool call in the model context alongside the completed response that already contained it.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5359,6 +5359,12 @@ async function replaceModelRun(
53595359
): Promise<boolean> {
53605360
const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
53615361
const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
5362+
// A message that converts to nothing (a pending tool call with no output yet,
5363+
// which `ignoreIncompleteToolCalls` drops) locates no run in the lane. Matching
5364+
// an empty slice would splice the new run in without removing what the message
5365+
// actually contributed, such as a spliced head-start partial, and the lane would
5366+
// then carry the same tool call twice.
5367+
if (oldRun.length === 0) return false;
53625368
const end = lane.length - tailAfter;
53635369
const start = end - oldRun.length;
53645370
if (start < 0 || end > lane.length) return false;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* A head-start handover splices the warm step's pending tool call into the model
3+
* lane. When the agent's response then completes that same message under the same
4+
* id, the lane's copy of the partial has to be replaced, not left in front of the
5+
* response: otherwise the next turn sends the same `tool_use` id twice and the
6+
* provider rejects the request.
7+
*/
8+
import { mockChatAgent } from "../src/v3/test/index.js";
9+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
10+
import { simulateReadableStream, streamText, tool, type UIMessage } from "ai";
11+
import { MockLanguageModelV3 } from "ai/test";
12+
import { describe, expect, it } from "vitest";
13+
import { z } from "zod";
14+
import { chat } from "../src/v3/ai.js";
15+
16+
const USAGE = {
17+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
18+
outputTokens: { total: 1, text: 1, reasoning: undefined },
19+
};
20+
const finish = (r: "stop" | "tool-calls"): LanguageModelV3StreamPart => ({
21+
type: "finish",
22+
finishReason: { unified: r, raw: r },
23+
usage: USAGE,
24+
});
25+
const textStep = (t: string): LanguageModelV3StreamPart[] => [
26+
{ type: "text-start", id: "t" },
27+
{ type: "text-delta", id: "t", delta: t },
28+
{ type: "text-end", id: "t" },
29+
finish("stop"),
30+
];
31+
const toolStep = (id: string): LanguageModelV3StreamPart[] => [
32+
{ type: "tool-call", toolCallId: id, toolName: "lookup", input: "{}" },
33+
finish("tool-calls"),
34+
];
35+
const userMessage = (text: string, id: string): UIMessage => ({
36+
id,
37+
role: "user",
38+
parts: [{ type: "text", text }],
39+
});
40+
41+
function toolUseIds(prompt: unknown): string[] {
42+
const ids: string[] = [];
43+
for (const m of prompt as Array<{ role: string; content: unknown }>) {
44+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
45+
for (const p of m.content as Array<{ type: string; toolCallId?: string }>)
46+
if (p.type === "tool-call" && p.toolCallId) ids.push(p.toolCallId);
47+
}
48+
return ids;
49+
}
50+
51+
for (const withMessageId of [false, true]) {
52+
describe(`a head-start turn whose handed-over tool call is followed by more steps (messageId=${withMessageId})`, () => {
53+
it("leaves the next turn one copy of each tool call", { timeout: 30_000 }, async () => {
54+
const prompts: unknown[] = [];
55+
const steps = [toolStep("tc_2"), textStep("found it"), textStep("second answer")];
56+
let call = 0;
57+
const model = new MockLanguageModelV3({
58+
doStream: async (o) => {
59+
prompts.push(o.prompt);
60+
const c = steps[Math.min(call++, steps.length - 1)]!;
61+
return { stream: simulateReadableStream({ chunks: c }) };
62+
},
63+
});
64+
const agent = chat.agent({
65+
id: `handover-lane-replace-${withMessageId}`,
66+
tools: {
67+
lookup: tool({
68+
description: "lookup",
69+
inputSchema: z.object({}),
70+
execute: async () => ({ ok: true }),
71+
}),
72+
},
73+
run: async ({ messages, signal, tools }) =>
74+
streamText({
75+
model,
76+
messages,
77+
tools,
78+
abortSignal: signal,
79+
stopWhen: ({ steps }) => steps.length >= 5,
80+
}),
81+
});
82+
const harness = mockChatAgent(agent, {
83+
chatId: `handover-lane-replace-${withMessageId}`,
84+
mode: "handover-prepare",
85+
headStartMessages: [userMessage("what errors?", "u1")],
86+
});
87+
try {
88+
await harness.sendHandover({
89+
partialAssistantMessage: [
90+
{
91+
role: "assistant",
92+
content: [
93+
{ type: "tool-call", toolCallId: "tc_hs", toolName: "lookup", input: {} },
94+
{ type: "tool-approval-request", approvalId: "ap", toolCallId: "tc_hs" },
95+
],
96+
},
97+
{
98+
role: "tool",
99+
content: [{ type: "tool-approval-response", approvalId: "ap", approved: true }],
100+
},
101+
],
102+
isFinal: false,
103+
...(withMessageId ? { messageId: "msg_headstart" } : {}),
104+
} as never);
105+
await new Promise((r) => setTimeout(r, 50));
106+
await harness.sendMessage(userMessage("follow up", "u2"));
107+
const last = prompts[prompts.length - 1];
108+
const ids = toolUseIds(last);
109+
expect(ids).toEqual(["tc_hs", "tc_2"]);
110+
// The lane is the completed conversation, in order, with the partial gone.
111+
const roles = (last as Array<{ role: string }>).map((m) => m.role);
112+
expect(roles).toEqual([
113+
"user",
114+
"assistant",
115+
"tool",
116+
"assistant",
117+
"tool",
118+
"assistant",
119+
"user",
120+
]);
121+
} finally {
122+
await harness.close();
123+
}
124+
});
125+
});
126+
}

0 commit comments

Comments
 (0)