From fa2149ea255a7c655e525ef9b60a3772a9f7afb0 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 11 Aug 2026 18:50:10 +0500 Subject: [PATCH] fix(aws-strands): carry ToolMessage.error onto the TypeScript adapter's tool results The TypeScript adapter never read ToolMessage.error. #2306 listed four conversion sites and #2317 fixed all of them, but aws-strands/typescript was not on that list, so a client-reported frontend tool failure still reached the model as a success. Three sites: _buildStrandsHistory and convertMessagesForStrandsSeed both hardcoded the Bedrock toolResult status to success, and buildSnapshotMessages rebuilt the client's own tool message without its error and encryptedValue fields. --- .../src/__tests__/history-replay.test.ts | 67 +++++++++++++++++++ .../src/__tests__/messages-snapshot.test.ts | 43 ++++++++++++ .../src/__tests__/seed-messages.test.ts | 54 +++++++++++++++ .../aws-strands/typescript/src/agent.ts | 25 +++++-- 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/integrations/aws-strands/typescript/src/__tests__/history-replay.test.ts b/integrations/aws-strands/typescript/src/__tests__/history-replay.test.ts index f0a980d24e..7024b074cc 100644 --- a/integrations/aws-strands/typescript/src/__tests__/history-replay.test.ts +++ b/integrations/aws-strands/typescript/src/__tests__/history-replay.test.ts @@ -227,4 +227,71 @@ describe("replayHistoryIntoStrands", () => { expect(calls[0]!.args).toBe("hello"); expect(calls[0]!.messages).toHaveLength(0); }); + it("carries a client-reported tool failure onto the toolResult status", async () => { + // AG-UI models the failure as ToolMessage.error; Bedrock models it as + // toolResult.status. Hardcoding "success" tells the model a failed + // frontend tool succeeded. Python parity: agent.py's _build_strands_history + // (#2317). + const { stub, calls } = recordingAgent(); + const agent = makeAgent(stub); + await collect( + agent, + minimalRunInput({ + messages: [ + { id: "u1", role: "user", content: "do something" }, + { + id: "a1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "tc1", + type: "function", + function: { name: "render_chart", arguments: "{}" }, + }, + ], + }, + { + id: "t1", + role: "tool", + content: "tool failed: invalid id", + toolCallId: "tc1", + error: "invalid id", + }, + ], + }), + ); + const history = calls[0]!.messages as Array<{ content: unknown[] }>; + const block = history[2]!.content[0] as { status: string }; + expect(block.status).toBe("error"); + }); + + it("keeps a successful tool result on the success status", async () => { + const { stub, calls } = recordingAgent(); + const agent = makeAgent(stub); + await collect( + agent, + minimalRunInput({ + messages: [ + { id: "u1", role: "user", content: "do something" }, + { + id: "a1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "tc1", + type: "function", + function: { name: "render_chart", arguments: "{}" }, + }, + ], + }, + { id: "t1", role: "tool", content: "ok", toolCallId: "tc1" }, + ], + }), + ); + const history = calls[0]!.messages as Array<{ content: unknown[] }>; + const block = history[2]!.content[0] as { status: string }; + expect(block.status).toBe("success"); + }); }); diff --git a/integrations/aws-strands/typescript/src/__tests__/messages-snapshot.test.ts b/integrations/aws-strands/typescript/src/__tests__/messages-snapshot.test.ts index 610eaf0fab..68c78c5f28 100644 --- a/integrations/aws-strands/typescript/src/__tests__/messages-snapshot.test.ts +++ b/integrations/aws-strands/typescript/src/__tests__/messages-snapshot.test.ts @@ -252,6 +252,49 @@ describe("buildSnapshotMessages — standalone helper", () => { expect((out[0] as { id: string }).id.length).toBeGreaterThan(0); }); + it("preserves error and encryptedValue on the tool echo", () => { + // This is an AG-UI -> AG-UI rebuild of the client's own message, so + // dropping its own fields loses the failure signal a MESSAGES_SNAPSHOT is + // supposed to make durable. Python parity: agent.py's + // _build_snapshot_messages (#2317). + const out = buildSnapshotMessages([ + { + id: "t1", + role: "tool", + content: "tool failed: invalid id", + toolCallId: "tc1", + error: "invalid id", + encryptedValue: "enc", + } as never, + ]); + expect(out).toHaveLength(1); + expect(out[0]).toEqual({ + id: "t1", + role: "tool", + content: "tool failed: invalid id", + toolCallId: "tc1", + error: "invalid id", + encryptedValue: "enc", + }); + }); + + it("omits error and encryptedValue when the client did not set them", () => { + const out = buildSnapshotMessages([ + { + id: "t1", + role: "tool", + content: "ok", + toolCallId: "tc1", + } as never, + ]); + expect(out[0]).toEqual({ + id: "t1", + role: "tool", + content: "ok", + toolCallId: "tc1", + }); + }); + it("drops developer / system / reasoning / activity roles", () => { const out = buildSnapshotMessages([ { id: "s1", role: "system", content: "sys" } as never, diff --git a/integrations/aws-strands/typescript/src/__tests__/seed-messages.test.ts b/integrations/aws-strands/typescript/src/__tests__/seed-messages.test.ts index 99ec7920d7..2939d00fff 100644 --- a/integrations/aws-strands/typescript/src/__tests__/seed-messages.test.ts +++ b/integrations/aws-strands/typescript/src/__tests__/seed-messages.test.ts @@ -121,6 +121,60 @@ describe("convertMessagesForStrandsSeed", () => { ]); }); + it("carries a client-reported tool failure onto the seeded toolResult status", async () => { + // Same rule as _buildStrandsHistory: the flag comes from ToolMessage.error, + // and each result is stamped independently. + const seed = await convertMessagesForStrandsSeed([ + { id: "u", role: "user", content: "lookup" } as unknown as AguiMessage, + { + id: "a", + role: "assistant", + content: "", + toolCalls: [ + { + id: "tc-1", + type: "function", + function: { name: "s1", arguments: "{}" }, + }, + { + id: "tc-2", + type: "function", + function: { name: "s2", arguments: "{}" }, + }, + ], + } as unknown as AguiMessage, + { + id: "t1", + role: "tool", + toolCallId: "tc-1", + content: "ok", + } as unknown as AguiMessage, + { + id: "t2", + role: "tool", + toolCallId: "tc-2", + content: "tool failed: invalid id", + error: "invalid id", + } as unknown as AguiMessage, + ]); + expect(seed[2].content).toEqual([ + { + toolResult: { + toolUseId: "tc-1", + status: "success", + content: [{ text: "ok" }], + }, + }, + { + toolResult: { + toolUseId: "tc-2", + status: "error", + content: [{ text: "tool failed: invalid id" }], + }, + }, + ]); + }); + it("drops orphaned tool messages whose call id wasn't announced", async () => { const seed = await convertMessagesForStrandsSeed([ { id: "u", role: "user", content: "hi" } as unknown as AguiMessage, diff --git a/integrations/aws-strands/typescript/src/agent.ts b/integrations/aws-strands/typescript/src/agent.ts index ec09ac7c7c..c793dd5438 100644 --- a/integrations/aws-strands/typescript/src/agent.ts +++ b/integrations/aws-strands/typescript/src/agent.ts @@ -297,12 +297,22 @@ export function buildSnapshotMessages( out.push(assistant); } else { const toolCallId = (msg as { toolCallId?: string }).toolCallId ?? ""; - out.push({ + const { error, encryptedValue } = msg as { + error?: string; + encryptedValue?: string; + }; + const tool = { id: msgId, role: "tool", content: _coerceText(msg.content), toolCallId, - } as AguiToolMessage); + } as AguiToolMessage; + // This is an AG-UI -> AG-UI rebuild of the client's own message, so + // preserve its error/encryptedValue on the snapshot echo instead of + // silently dropping the client's own fields. + if (error !== undefined) tool.error = error; + if (encryptedValue !== undefined) tool.encryptedValue = encryptedValue; + out.push(tool); } } return out; @@ -408,7 +418,12 @@ async function _buildStrandsHistory( toolResult: { toolUseId: toolCallId, content: [_buildToolResultContent(msg.content)], - status: "success" as const, + // Carry the AG-UI failure signal onto Bedrock's toolResult status, + // so a client-reported tool failure is not asserted to the model as + // a success. + status: (msg as { error?: string }).error + ? ("error" as const) + : ("success" as const), }, }, ], @@ -2728,7 +2743,9 @@ export async function convertMessagesForStrandsSeed( pendingToolResults.push({ toolResult: { toolUseId: toolCallId, - status: "success" as const, + status: (msg as { error?: string }).error + ? ("error" as const) + : ("success" as const), content: [{ text: textContent }], }, });