Skip to content

Commit 02e4a2b

Browse files
committed
fix(sdk): tolerate AI SDK assistant-id regen on HITL addToolOutput resume
When the AI SDK regenerates the assistant message id on an addToolOutput-driven HITL continuation, our id-merge in hydrateMessages fails to attach the tool answer to the existing head — duplicating the assistant in the accumulator. Reported by Arena AI, who maintains a content-match workaround in their hydrateMessages keyed on tool_call_id. Add a run-scoped `toolCallId -> head messageId` map populated whenever an assistant message containing tool parts lands in the accumulator. The submit-message id-merge now falls back to this map when id-match fails: walk the incoming message's tool parts, look up by toolCallId, rewrite the incoming id back to the recorded head, then retry id-match. Could not reproduce on current AI SDK 6.0.116 (id is preserved through addToolOutput) but ship the mapping so the merge stays robust against older versions and edge cases we haven't observed. Customer-side content-match workarounds become unnecessary. Closes TRI-9137.
1 parent 51ece8f commit 02e4a2b

2 files changed

Lines changed: 205 additions & 2 deletions

File tree

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,56 @@ const chatUIStreamPerTurnKey = locals.create<ChatUIMessageStreamOptions<UIMessag
12721272
"chat.uiMessageStreamOptions.perTurn"
12731273
);
12741274

1275+
/**
1276+
* Run-scoped `toolCallId → assistant messageId` map. Records the head
1277+
* assistant id whenever the accumulator absorbs an assistant message
1278+
* containing tool parts. Used as a fallback in the id-merge for
1279+
* incoming tool-answer messages — if the AI SDK regenerates the
1280+
* assistant id on a HITL `addToolOutput` resume, we look up the
1281+
* original head id by `toolCallId` and rewrite it before the merge.
1282+
*
1283+
* Customer-side workaround for the same case is documented in Arena
1284+
* AI's chat-agent task; lifting it into the SDK so customers don't
1285+
* have to. See TRI-9137.
1286+
* @internal
1287+
*/
1288+
const chatToolCallToMessageIdKey = locals.create<Map<string, string>>(
1289+
"chat.toolCallToMessageId"
1290+
);
1291+
1292+
function recordToolCallIdsFromMessage(message: { id?: string; role?: string; parts?: unknown[] } | undefined) {
1293+
if (!message || message.role !== "assistant" || !message.id) return;
1294+
let map = locals.get(chatToolCallToMessageIdKey);
1295+
if (!map) {
1296+
map = new Map();
1297+
locals.set(chatToolCallToMessageIdKey, map);
1298+
}
1299+
for (const part of message.parts ?? []) {
1300+
if (typeof part !== "object" || part == null) continue;
1301+
const toolCallId = (part as { toolCallId?: unknown }).toolCallId;
1302+
if (typeof toolCallId === "string" && toolCallId.length > 0) {
1303+
map.set(toolCallId, message.id);
1304+
}
1305+
}
1306+
}
1307+
1308+
function rewriteIncomingIdViaToolCallMap<T extends { id?: string; parts?: unknown[] }>(
1309+
incoming: T
1310+
): T {
1311+
const map = locals.get(chatToolCallToMessageIdKey);
1312+
if (!map || map.size === 0) return incoming;
1313+
for (const part of incoming.parts ?? []) {
1314+
if (typeof part !== "object" || part == null) continue;
1315+
const toolCallId = (part as { toolCallId?: unknown }).toolCallId;
1316+
if (typeof toolCallId !== "string" || toolCallId.length === 0) continue;
1317+
const headId = map.get(toolCallId);
1318+
if (headId && headId !== incoming.id) {
1319+
return { ...incoming, id: headId };
1320+
}
1321+
}
1322+
return incoming;
1323+
}
1324+
12751325
// ---------------------------------------------------------------------------
12761326
// Token usage helpers (internal)
12771327
// ---------------------------------------------------------------------------
@@ -4539,18 +4589,36 @@ function chatAgent<
45394589
// IDs match because we always pass generateMessageId + originalMessages
45404590
// to toUIMessageStream, so the backend's start chunk carries the same
45414591
// messageId that the frontend uses.
4592+
//
4593+
// Fallback for HITL `addToolOutput` continuations where the AI SDK
4594+
// regenerates the assistant id (Arena AI report, TRI-9137): if the
4595+
// id-match fails, look up the head messageId via toolCallId and
4596+
// rewrite the incoming id before retrying. The mapping is
4597+
// populated whenever an assistant containing tool parts lands in
4598+
// the accumulator.
45424599
let replaced = false;
4543-
for (const incoming of cleanedUIMessages) {
4544-
const idx = accumulatedUIMessages.findIndex(
4600+
for (const raw of cleanedUIMessages) {
4601+
let incoming = raw;
4602+
let idx = accumulatedUIMessages.findIndex(
45454603
(m) => m.id === incoming.id
45464604
);
4605+
if (idx === -1) {
4606+
const rewritten = rewriteIncomingIdViaToolCallMap(incoming);
4607+
if (rewritten.id !== incoming.id) {
4608+
incoming = rewritten as typeof raw;
4609+
idx = accumulatedUIMessages.findIndex(
4610+
(m) => m.id === incoming.id
4611+
);
4612+
}
4613+
}
45474614
if (idx !== -1) {
45484615
accumulatedUIMessages[idx] = incoming as TUIMessage;
45494616
replaced = true;
45504617
} else {
45514618
accumulatedUIMessages.push(incoming as TUIMessage);
45524619
turnNewUIMessages.push(incoming as TUIMessage);
45534620
}
4621+
recordToolCallIdsFromMessage(incoming);
45544622
}
45554623
if (replaced) {
45564624
// Reconvert all model messages since a replacement changes the structure
@@ -5006,6 +5074,12 @@ function chatAgent<
50065074
}
50075075
turnNewUIMessages.push(capturedResponseMessage);
50085076
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
5077+
// Record toolCallId → head messageId so a HITL
5078+
// continuation next turn can recover the head id
5079+
// even if the AI SDK regenerates it. See
5080+
// `chatToolCallToMessageIdKey` for the full
5081+
// rationale (TRI-9137).
5082+
recordToolCallIdsFromMessage(capturedResponseMessage);
50095083
try {
50105084
const responseModelMessages = await toModelMessages([
50115085
stripProviderMetadata(capturedResponseMessage),

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,135 @@ describe("mockChatAgent", () => {
178178
}
179179
});
180180

181+
it("merges HITL tool answer onto head assistant when AI SDK regenerates the id", async () => {
182+
// Regression for TRI-9137: customers (Arena AI) report that the AI SDK
183+
// intermittently mints a fresh id on `addToolOutput` resume, breaking
184+
// id-based dedup. Our SDK records `toolCallId → head messageId` whenever
185+
// an assistant with tool parts lands in the accumulator and uses that
186+
// map as a fallback in the merge so a fresh-id incoming still attaches
187+
// to the right head.
188+
const { z } = await import("zod");
189+
const { tool } = await import("ai");
190+
191+
const askUserTool = tool({
192+
description: "Ask the user a question.",
193+
inputSchema: z.object({ question: z.string() }),
194+
// No execute — HITL round-trip via addToolOutput.
195+
});
196+
197+
const HEAD_TOOL_CALL_ID = "tc_regression_9137";
198+
199+
// Turn 1: model emits a tool-call for askUser. No text, no finish-reason
200+
// logic beyond `tool-calls`. Agent's response will carry a tool-input-
201+
// available part with HEAD_TOOL_CALL_ID.
202+
const turn1Stream = simulateReadableStream({
203+
chunks: [
204+
{ type: "tool-input-start", id: HEAD_TOOL_CALL_ID, toolName: "askUser" },
205+
{
206+
type: "tool-input-delta",
207+
id: HEAD_TOOL_CALL_ID,
208+
delta: JSON.stringify({ question: "what color?" }),
209+
},
210+
{ type: "tool-input-end", id: HEAD_TOOL_CALL_ID },
211+
{
212+
type: "tool-call",
213+
toolCallId: HEAD_TOOL_CALL_ID,
214+
toolName: "askUser",
215+
input: JSON.stringify({ question: "what color?" }),
216+
},
217+
{
218+
type: "finish",
219+
finishReason: { unified: "tool-calls", raw: "tool_calls" },
220+
usage: {
221+
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
222+
outputTokens: { total: 10, text: 0, reasoning: undefined },
223+
},
224+
},
225+
] as LanguageModelV3StreamPart[],
226+
});
227+
228+
// Turn 2: model produces a final text response — exercises the post-HITL
229+
// continuation streamText after the tool answer is merged in.
230+
const turn2Stream = textStream("blue is great");
231+
232+
let callIdx = 0;
233+
const model = new MockLanguageModelV3({
234+
doStream: async () => ({ stream: callIdx++ === 0 ? turn1Stream : turn2Stream }),
235+
});
236+
237+
const turnsSeen: { turn: number; uiMessages: any[] }[] = [];
238+
239+
const agent = chat.agent({
240+
id: "mockChatAgent.hitl-id-regen",
241+
tools: { askUser: askUserTool },
242+
onTurnComplete: async ({ turn, uiMessages }) => {
243+
turnsSeen.push({
244+
turn,
245+
uiMessages: uiMessages.map((m) => ({
246+
id: m.id,
247+
role: m.role,
248+
toolStates: (m.parts ?? [])
249+
.filter((p: any) => typeof p?.toolCallId === "string")
250+
.map((p: any) => ({ toolCallId: p.toolCallId, state: p.state })),
251+
})),
252+
});
253+
},
254+
run: async ({ messages, signal }) => {
255+
return streamText({ model, messages, tools: { askUser: askUserTool }, abortSignal: signal });
256+
},
257+
});
258+
259+
const harness = mockChatAgent(agent, { chatId: "test-hitl-id-regen" });
260+
try {
261+
// Turn 1: user message → agent emits tool-input-available for askUser
262+
await harness.sendMessage(userMessage("hi"));
263+
await new Promise((r) => setTimeout(r, 50));
264+
265+
// Capture the head assistant id the agent produced.
266+
const turn1 = turnsSeen.at(-1);
267+
const headAssistant = turn1?.uiMessages.find(
268+
(m) => m.role === "assistant" && m.toolStates.length > 0
269+
);
270+
expect(headAssistant?.id).toBeTruthy();
271+
const HEAD_ID = headAssistant!.id as string;
272+
273+
// Turn 2: simulate AI SDK regenerating the assistant id on
274+
// addToolOutput resume — fresh id, but the same toolCallId in
275+
// tool-output-available state.
276+
const FRESH_ID = "regenerated-by-ai-sdk-" + Math.random().toString(36).slice(2);
277+
const toolAnswerMessage = {
278+
id: FRESH_ID,
279+
role: "assistant" as const,
280+
parts: [
281+
{
282+
type: "tool-askUser",
283+
toolCallId: HEAD_TOOL_CALL_ID,
284+
state: "output-available" as const,
285+
input: { question: "what color?" },
286+
output: { color: "blue" },
287+
},
288+
],
289+
};
290+
await harness.sendMessage(toolAnswerMessage as any);
291+
await new Promise((r) => setTimeout(r, 50));
292+
293+
// The merge must rewrite FRESH_ID back to HEAD_ID via the toolCallId
294+
// map, attaching the tool answer to the existing head — no duplicate.
295+
const turn2 = turnsSeen.at(-1);
296+
expect(turn2).toBeTruthy();
297+
const assistantsWithToolCall = turn2!.uiMessages.filter(
298+
(m) =>
299+
m.role === "assistant" &&
300+
m.toolStates.some((t: any) => t.toolCallId === HEAD_TOOL_CALL_ID)
301+
);
302+
expect(assistantsWithToolCall).toHaveLength(1);
303+
expect(assistantsWithToolCall[0]!.id).toBe(HEAD_ID);
304+
expect(turn2!.uiMessages.find((m) => m.id === FRESH_ID)).toBeUndefined();
305+
} finally {
306+
await harness.close();
307+
}
308+
});
309+
181310
it("routes custom actions through actionSchema + onAction", async () => {
182311
const model = new MockLanguageModelV3({
183312
doStream: async () => ({ stream: textStream("ok") }),

0 commit comments

Comments
 (0)