Skip to content

Commit 9faf99a

Browse files
authored
fix(ai): add missing results for trailing tool calls (#52421)
1 parent 19f2038 commit 9faf99a

7 files changed

Lines changed: 139 additions & 109 deletions

File tree

‎packages/ai/src/tool-history.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
2727
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
2828
}
2929
}
30+
appendMissingResults()
3031

3132
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
3233
? messages

‎packages/ai/test/provider/bedrock-converse.test.ts‎

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -501,25 +501,23 @@ describe("Bedrock Converse route", () => {
501501
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
502502
)
503503

504-
expect(prepared.body.messages).toEqual([
505-
{
506-
role: "assistant",
507-
content: [
508-
{
509-
toolUse: {
510-
toolUseId: "tool_1",
511-
name: "edit",
512-
input: {
513-
path: "file.ts",
514-
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
515-
nested: { empty: {}, onlyEmpty: {} },
516-
" ": "preserve whitespace key",
517-
},
504+
expect(prepared.body.messages[0]).toEqual({
505+
role: "assistant",
506+
content: [
507+
{
508+
toolUse: {
509+
toolUseId: "tool_1",
510+
name: "edit",
511+
input: {
512+
path: "file.ts",
513+
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
514+
nested: { empty: {}, onlyEmpty: {} },
515+
" ": "preserve whitespace key",
518516
},
519517
},
520-
],
521-
},
522-
])
518+
},
519+
],
520+
})
523521
expect(input).toEqual(original)
524522
expect(call.input).toBe(input)
525523
}),
@@ -540,15 +538,13 @@ describe("Bedrock Converse route", () => {
540538
}),
541539
)
542540

543-
expect(prepared.body.messages).toEqual([
544-
{
545-
role: "assistant",
546-
content: [
547-
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
548-
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
549-
],
550-
},
551-
])
541+
expect(prepared.body.messages[0]).toEqual({
542+
role: "assistant",
543+
content: [
544+
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
545+
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
546+
],
547+
})
552548
}),
553549
)
554550

@@ -1099,6 +1095,12 @@ describe("Bedrock Converse route", () => {
10991095
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
11001096
],
11011097
},
1098+
{
1099+
role: "user",
1100+
content: [
1101+
{ toolResult: { toolUseId: "call_1", content: [{ text: "Tool result missing" }], status: "error" } },
1102+
],
1103+
},
11021104
])
11031105
}),
11041106
)

‎packages/ai/test/provider/gemini.test.ts‎

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,25 +1285,23 @@ describe("Gemini route", () => {
12851285
}),
12861286
)
12871287

1288-
expect(prepared.body.contents).toEqual([
1289-
{
1290-
role: "model",
1291-
parts: [
1292-
{
1293-
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
1294-
thoughtSignature: "parallel_signature",
1295-
},
1296-
{
1297-
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
1298-
thoughtSignature: undefined,
1299-
},
1300-
{
1301-
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
1302-
thoughtSignature: undefined,
1303-
},
1304-
],
1305-
},
1306-
])
1288+
expect(prepared.body.contents[0]).toEqual({
1289+
role: "model",
1290+
parts: [
1291+
{
1292+
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
1293+
thoughtSignature: "parallel_signature",
1294+
},
1295+
{
1296+
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
1297+
thoughtSignature: undefined,
1298+
},
1299+
{
1300+
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
1301+
thoughtSignature: undefined,
1302+
},
1303+
],
1304+
})
13071305
}),
13081306
)
13091307

@@ -1321,21 +1319,19 @@ describe("Gemini route", () => {
13211319
}),
13221320
)
13231321

1324-
expect(prepared.body.contents).toEqual([
1325-
{
1326-
role: "model",
1327-
parts: [
1328-
{
1329-
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
1330-
thoughtSignature: "skip_thought_signature_validator",
1331-
},
1332-
{
1333-
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
1334-
thoughtSignature: "skip_thought_signature_validator",
1335-
},
1336-
],
1337-
},
1338-
])
1322+
expect(prepared.body.contents[0]).toEqual({
1323+
role: "model",
1324+
parts: [
1325+
{
1326+
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
1327+
thoughtSignature: "skip_thought_signature_validator",
1328+
},
1329+
{
1330+
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
1331+
thoughtSignature: "skip_thought_signature_validator",
1332+
},
1333+
],
1334+
})
13391335
}),
13401336
)
13411337

‎packages/ai/test/provider/mistral-chat.test.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,11 @@ describe("Mistral Chat", () => {
215215
],
216216
}),
217217
)
218-
expect(prepared.body.messages).toEqual([
219-
{
220-
role: "assistant",
221-
content: "",
222-
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
223-
},
224-
])
218+
expect(prepared.body.messages[0]).toEqual({
219+
role: "assistant",
220+
content: "",
221+
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
222+
})
225223
}),
226224
)
227225

@@ -315,7 +313,9 @@ describe("Mistral Chat", () => {
315313
expect(response.reasoning).toBe("Consider")
316314
expect(response.text).toBe("Answer")
317315
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
318-
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
316+
expect(
317+
response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata]),
318+
).toEqual([
319319
["Con", undefined],
320320
["sider", undefined],
321321
])

‎packages/ai/test/provider/openai-chat.test.ts‎

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,21 +1257,19 @@ describe("OpenAI Chat route", () => {
12571257
})
12581258

12591259
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
1260-
expect(replay.body.messages).toEqual([
1261-
{
1262-
role: "assistant",
1263-
content: null,
1264-
reasoning: "thinking",
1265-
reasoning_details: details,
1266-
tool_calls: [
1267-
{
1268-
id: "call_1",
1269-
type: "function",
1270-
function: { name: "lookup", arguments: '{"query":"weather"}' },
1271-
},
1272-
],
1273-
},
1274-
])
1260+
expect(replay.body.messages[0]).toEqual({
1261+
role: "assistant",
1262+
content: null,
1263+
reasoning: "thinking",
1264+
reasoning_details: details,
1265+
tool_calls: [
1266+
{
1267+
id: "call_1",
1268+
type: "function",
1269+
function: { name: "lookup", arguments: '{"query":"weather"}' },
1270+
},
1271+
],
1272+
})
12751273
}),
12761274
)
12771275

@@ -1374,18 +1372,16 @@ describe("OpenAI Chat route", () => {
13741372
})
13751373

13761374
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
1377-
expect(replay.body.messages).toEqual([
1378-
{
1379-
role: "assistant",
1380-
content: null,
1381-
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
1382-
reasoning_content: "Let me think",
1383-
reasoning_details: [
1384-
{ type: "summary", summary: "Plan tools" },
1385-
{ type: "encrypted", encrypted: "opaque" },
1386-
],
1387-
},
1388-
])
1375+
expect(replay.body.messages[0]).toEqual({
1376+
role: "assistant",
1377+
content: null,
1378+
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
1379+
reasoning_content: "Let me think",
1380+
reasoning_details: [
1381+
{ type: "summary", summary: "Plan tools" },
1382+
{ type: "encrypted", encrypted: "opaque" },
1383+
],
1384+
})
13891385
}),
13901386
)
13911387

‎packages/ai/test/provider/openai-responses.test.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3804,6 +3804,8 @@ describe("OpenAI Responses route", () => {
38043804
name: "lookup",
38053805
arguments: '{"query":"news"}',
38063806
},
3807+
{ type: "function_call_output", call_id: "call_1", output: "Tool result missing" },
3808+
{ type: "function_call_output", call_id: "call_2", output: "Tool result missing" },
38073809
])
38083810
}),
38093811
)
@@ -4098,15 +4100,13 @@ describe("OpenAI Responses route", () => {
40984100
])
40994101

41004102
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
4101-
expect(prepared.body.input).toEqual([
4102-
{
4103-
type: "function_call",
4104-
id: "fc_item_1",
4105-
call_id: "call_1",
4106-
name: "lookup",
4107-
arguments: '{"query":"weather"}',
4108-
},
4109-
])
4103+
expect(prepared.body.input[0]).toEqual({
4104+
type: "function_call",
4105+
id: "fc_item_1",
4106+
call_id: "call_1",
4107+
name: "lookup",
4108+
arguments: '{"query":"weather"}',
4109+
})
41104110
}),
41114111
)
41124112

@@ -4998,11 +4998,11 @@ describe("OpenAI Responses route", () => {
49984998

49994999
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
50005000
Effect.gen(function* () {
5001-
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
5001+
const body = JSON.stringify({
5002+
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
5003+
})
50025004
const error = yield* LLMClient.generate(request).pipe(
5003-
Effect.provide(
5004-
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
5005-
),
5005+
Effect.provide(fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } })),
50065006
Effect.flip,
50075007
)
50085008

‎packages/ai/test/tool-history.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,47 @@ describe("tool history normalization", () => {
1515
Message.assistant(toolCall("trailing")),
1616
])
1717

18-
expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"])
18+
expect(normalized.map((message) => message.role)).toEqual([
19+
"assistant",
20+
"tool",
21+
"tool",
22+
"user",
23+
"assistant",
24+
"tool",
25+
])
1926
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
2027
expect(normalized[2]?.content).toEqual([
2128
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
2229
])
2330
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
31+
expect(normalized[5]?.content).toEqual([
32+
{
33+
type: "tool-result",
34+
id: "trailing",
35+
name: "trailing",
36+
result: { type: "error", value: "Tool result missing" },
37+
},
38+
])
39+
})
40+
41+
test("fills missing results for trailing calls without replacing available results", () => {
42+
const calls = Message.assistant([toolCall("answered"), toolCall("unanswered")])
43+
const answered = toolResult("answered", "done", "answered", "text")
44+
const hosted = ToolCallPart.make({ id: "hosted", name: "web_search", input: {}, providerExecuted: true })
45+
46+
expect(normalizeToolHistory([calls, answered])).toEqual([
47+
calls,
48+
answered,
49+
Message.tool(
50+
ToolResultPart.make({
51+
id: "unanswered",
52+
name: "unanswered",
53+
result: "Tool result missing",
54+
resultType: "error",
55+
}),
56+
),
57+
])
58+
expect(normalizeToolHistory([Message.assistant(hosted)])).toEqual([Message.assistant(hosted)])
2459
})
2560

2661
test("normalizes empty results without changing whitespace or media", () => {

0 commit comments

Comments
 (0)