Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 123 additions & 4 deletions src/api/transform/__tests__/bedrock-converse-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ describe("convertToBedrockConverseMessages", () => {
}
})

it("converts tool result messages correctly", () => {
it("converts tool result messages to XML text format (default, useNativeTools: false)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
role: "user",
content: [
{
type: "tool_result",
Expand All @@ -155,14 +155,50 @@ describe("convertToBedrockConverseMessages", () => {
},
]

// Default behavior (useNativeTools: false) converts tool_result to XML text format
// This fixes the Bedrock error "toolConfig field must be defined when using toolUse and toolResult content blocks"
const result = convertToBedrockConverseMessages(messages)

if (!result[0] || !result[0].content) {
expect.fail("Expected result to have content")
return
}

expect(result[0].role).toBe("assistant")
expect(result[0].role).toBe("user")
const textBlock = result[0].content[0] as ContentBlock
if ("text" in textBlock) {
expect(textBlock.text).toContain("<tool_result>")
expect(textBlock.text).toContain("<tool_use_id>test-id</tool_use_id>")
expect(textBlock.text).toContain("File contents here")
expect(textBlock.text).toContain("</tool_result>")
} else {
expect.fail("Expected text block with XML content not found")
}
})

it("converts tool result messages to native format (useNativeTools: true)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "test-id",
content: [{ type: "text", text: "File contents here" }],
},
],
},
]

// With useNativeTools: true, keeps tool_result as native format
const result = convertToBedrockConverseMessages(messages, { useNativeTools: true })

if (!result[0] || !result[0].content) {
expect.fail("Expected result to have content")
return
}

expect(result[0].role).toBe("user")
const resultBlock = result[0].content[0] as ContentBlock
if ("toolResult" in resultBlock && resultBlock.toolResult) {
const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
Expand All @@ -176,7 +212,7 @@ describe("convertToBedrockConverseMessages", () => {
}
})

it("converts tool result messages with string content correctly", () => {
it("converts tool result messages with string content to XML text format (default)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
Expand All @@ -197,6 +233,39 @@ describe("convertToBedrockConverseMessages", () => {
return
}

expect(result[0].role).toBe("user")
const textBlock = result[0].content[0] as ContentBlock
if ("text" in textBlock) {
expect(textBlock.text).toContain("<tool_result>")
expect(textBlock.text).toContain("<tool_use_id>test-id</tool_use_id>")
expect(textBlock.text).toContain("File: test.txt")
expect(textBlock.text).toContain("Hello World")
} else {
expect.fail("Expected text block with XML content not found")
}
})

it("converts tool result messages with string content to native format (useNativeTools: true)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "test-id",
content: "File: test.txt\nLines 1-5:\nHello World",
} as any, // Anthropic types don't allow string content but runtime can have it
],
},
]

const result = convertToBedrockConverseMessages(messages, { useNativeTools: true })

if (!result[0] || !result[0].content) {
expect.fail("Expected result to have content")
return
}

expect(result[0].role).toBe("user")
const resultBlock = result[0].content[0] as ContentBlock
if ("toolResult" in resultBlock && resultBlock.toolResult) {
Expand All @@ -210,6 +279,56 @@ describe("convertToBedrockConverseMessages", () => {
}
})

it("converts both tool_use and tool_result consistently when native tools disabled", () => {
// This test ensures tool_use AND tool_result are both converted to XML text
// when useNativeTools is false, preventing Bedrock toolConfig errors
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call-123",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-123",
content: "File contents here",
} as any,
],
},
]

const result = convertToBedrockConverseMessages(messages) // default useNativeTools: false

// Both should be text blocks, not native toolUse/toolResult
const assistantContent = result[0]?.content?.[0] as ContentBlock
const userContent = result[1]?.content?.[0] as ContentBlock

// tool_use should be XML text
expect("text" in assistantContent).toBe(true)
if ("text" in assistantContent) {
expect(assistantContent.text).toContain("<tool_use>")
}

// tool_result should also be XML text (this is what the fix addresses)
expect("text" in userContent).toBe(true)
if ("text" in userContent) {
expect(userContent.text).toContain("<tool_result>")
}

// Neither should have native format
expect("toolUse" in assistantContent).toBe(false)
expect("toolResult" in userContent).toBe(false)
})

it("handles text content correctly", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
Expand Down
45 changes: 44 additions & 1 deletion src/api/transform/bedrock-converse-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,50 @@ export function convertToBedrockConverseMessages(
}

if (messageBlock.type === "tool_result") {
// Handle content field - can be string or array
// When NOT using native tools, convert tool_result to text format
// This matches how tool_use is converted to XML text when native tools are disabled.
// Without this, Bedrock will error with "toolConfig field must be defined when using
// toolUse and toolResult content blocks" because toolResult blocks require toolConfig.
if (!useNativeTools) {
let toolResultContent: string
if (messageBlock.content) {
if (typeof messageBlock.content === "string") {
toolResultContent = messageBlock.content
} else if (Array.isArray(messageBlock.content)) {
toolResultContent = messageBlock.content
.map((item) => (typeof item === "string" ? item : item.text || String(item)))
.join("\n")
} else {
toolResultContent = String(messageBlock.output || "")
}
} else if (messageBlock.output) {
if (typeof messageBlock.output === "string") {
toolResultContent = messageBlock.output
} else if (Array.isArray(messageBlock.output)) {
toolResultContent = messageBlock.output
.map((part) => {
if (typeof part === "object" && "text" in part) {
return part.text
}
if (typeof part === "object" && "type" in part && part.type === "image") {
return "(see following message for image)"
}
return String(part)
})
.join("\n")
} else {
toolResultContent = String(messageBlock.output)
}
} else {
toolResultContent = ""
}

return {
text: `<tool_result>\n<tool_use_id>${messageBlock.tool_use_id || ""}</tool_use_id>\n<output>${toolResultContent}</output>\n</tool_result>`,
} as ContentBlock
}

// Handle content field - can be string or array (native tool format)
if (messageBlock.content) {
// Content is a string
if (typeof messageBlock.content === "string") {
Expand Down
Loading