Skip to content

Commit cff5c89

Browse files
committed
Unwrap tool-result outputs; add tests
Improve handling of tool-result outputs by unwrapping different output types (text, error-text, json, error-json, execution-denied, and content parts) and returning appropriate plain strings instead of serializing wrapper objects. Add unit tests covering error-text/error-json/execution-denied/content cases and adjust existing expectations. Extend E2E fixtures and tests to exercise multi-step agentic tool loops and toolChoice="required" behavior (including new routes in the binding worker, summary table updates, and per-model checks). Also update a test description in text-generation.test and remove a couple of models from E2E model lists.
1 parent 29087ad commit cff5c89

6 files changed

Lines changed: 428 additions & 21 deletions

File tree

packages/workers-ai-provider/src/convert-to-workersai-chat-messages.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,17 +184,35 @@ export function convertToWorkersAIChatMessages(prompt: LanguageModelV3Prompt): {
184184
case "tool": {
185185
for (const toolResponse of content) {
186186
if (toolResponse.type === "tool-result") {
187-
// toolResponse.output is LanguageModelV3ToolResultOutput — a tagged
188-
// union. We must extract the value rather than stringifying the
189-
// wrapper object, which would send e.g. {"type":"text","value":"..."}
190-
// to the model instead of the actual tool result.
191187
const output = toolResponse.output;
192-
const content =
193-
output.type === "text"
194-
? output.value
195-
: "value" in output
196-
? JSON.stringify(output.value)
197-
: "";
188+
let content: string;
189+
switch (output.type) {
190+
case "text":
191+
case "error-text":
192+
content = output.value;
193+
break;
194+
case "json":
195+
case "error-json":
196+
content = JSON.stringify(output.value);
197+
break;
198+
case "execution-denied":
199+
content = output.reason
200+
? `Tool execution denied: ${output.reason}`
201+
: "Tool execution was denied.";
202+
break;
203+
case "content":
204+
content = output.value
205+
.filter(
206+
(p): p is { type: "text"; text: string } =>
207+
p.type === "text",
208+
)
209+
.map((p) => p.text)
210+
.join("\n");
211+
break;
212+
default:
213+
content = "";
214+
break;
215+
}
198216
messages.push({
199217
content,
200218
name: toolResponse.toolName,

packages/workers-ai-provider/test/convert-to-workersai-chat-messages.test.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ describe("convertToWorkersAIChatMessages", () => {
551551
]);
552552

553553
expect(messages[0].content).toBe(
554-
JSON.stringify({ city: "Tokyo", temp: 22, condition: "sunny" })
554+
JSON.stringify({ city: "Tokyo", temp: 22, condition: "sunny" }),
555555
);
556556
expect(messages[0].content).not.toContain('"type":"json"');
557557
});
@@ -565,7 +565,10 @@ describe("convertToWorkersAIChatMessages", () => {
565565
type: "tool-result" as const,
566566
toolCallId: "call-1",
567567
toolName: "getUserInfo",
568-
output: { type: "text", value: '{"id":"u123","username":"alice"}' } as any,
568+
output: {
569+
type: "text",
570+
value: '{"id":"u123","username":"alice"}',
571+
} as any,
569572
},
570573
{
571574
type: "tool-result" as const,
@@ -581,5 +584,135 @@ describe("convertToWorkersAIChatMessages", () => {
581584
expect(messages[0].content).toBe('{"id":"u123","username":"alice"}');
582585
expect(messages[1].content).toBe(JSON.stringify({ balance: 1234.56 }));
583586
});
587+
588+
it("should unwrap error-text output as a plain string — not double-quoted", () => {
589+
const { messages } = convertToWorkersAIChatMessages([
590+
{
591+
role: "tool" as const,
592+
content: [
593+
{
594+
type: "tool-result" as const,
595+
toolCallId: "call-1",
596+
toolName: "fetchData",
597+
output: { type: "error-text", value: "Connection timed out" } as any,
598+
},
599+
],
600+
},
601+
]);
602+
603+
expect(messages[0].content).toBe("Connection timed out");
604+
expect(messages[0].content).not.toBe('"Connection timed out"');
605+
});
606+
607+
it("should surface execution-denied with reason", () => {
608+
const { messages } = convertToWorkersAIChatMessages([
609+
{
610+
role: "tool" as const,
611+
content: [
612+
{
613+
type: "tool-result" as const,
614+
toolCallId: "call-1",
615+
toolName: "deleteFile",
616+
output: {
617+
type: "execution-denied",
618+
reason: "User rejected the action",
619+
} as any,
620+
},
621+
],
622+
},
623+
]);
624+
625+
expect(messages[0].content).toBe("Tool execution denied: User rejected the action");
626+
});
627+
628+
it("should surface execution-denied without reason", () => {
629+
const { messages } = convertToWorkersAIChatMessages([
630+
{
631+
role: "tool" as const,
632+
content: [
633+
{
634+
type: "tool-result" as const,
635+
toolCallId: "call-1",
636+
toolName: "deleteFile",
637+
output: { type: "execution-denied" } as any,
638+
},
639+
],
640+
},
641+
]);
642+
643+
expect(messages[0].content).toBe("Tool execution was denied.");
644+
});
645+
646+
it("should serialize error-json output — not double-wrap", () => {
647+
const { messages } = convertToWorkersAIChatMessages([
648+
{
649+
role: "tool" as const,
650+
content: [
651+
{
652+
type: "tool-result" as const,
653+
toolCallId: "call-1",
654+
toolName: "apiCall",
655+
output: {
656+
type: "error-json",
657+
value: { code: 404, message: "Not found" },
658+
} as any,
659+
},
660+
],
661+
},
662+
]);
663+
664+
expect(messages[0].content).toBe(JSON.stringify({ code: 404, message: "Not found" }));
665+
expect(messages[0].content).not.toContain('"type":"error-json"');
666+
});
667+
668+
it("should extract text from content output parts", () => {
669+
const { messages } = convertToWorkersAIChatMessages([
670+
{
671+
role: "tool" as const,
672+
content: [
673+
{
674+
type: "tool-result" as const,
675+
toolCallId: "call-1",
676+
toolName: "screenshotTool",
677+
output: {
678+
type: "content",
679+
value: [
680+
{ type: "text", text: "Screenshot captured successfully" },
681+
{ type: "file-data", data: "iVBOR...", mediaType: "image/png" },
682+
{ type: "text", text: "Dimensions: 1920x1080" },
683+
],
684+
} as any,
685+
},
686+
],
687+
},
688+
]);
689+
690+
expect(messages[0].content).toBe(
691+
"Screenshot captured successfully\nDimensions: 1920x1080",
692+
);
693+
});
694+
695+
it("should handle content output with no text parts", () => {
696+
const { messages } = convertToWorkersAIChatMessages([
697+
{
698+
role: "tool" as const,
699+
content: [
700+
{
701+
type: "tool-result" as const,
702+
toolCallId: "call-1",
703+
toolName: "screenshotTool",
704+
output: {
705+
type: "content",
706+
value: [
707+
{ type: "file-data", data: "iVBOR...", mediaType: "image/png" },
708+
],
709+
} as any,
710+
},
711+
],
712+
},
713+
]);
714+
715+
expect(messages[0].content).toBe("");
716+
});
584717
});
585718
});

packages/workers-ai-provider/test/e2e/fixtures/binding-worker/src/index.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,73 @@ export default {
153153
});
154154
}
155155

156+
// ----- Multi-step agentic tool loop -----
157+
case "/chat/tool-multistep": {
158+
const result = await generateText({
159+
model: provider(model as any),
160+
messages: [
161+
{
162+
role: "user",
163+
content:
164+
"I need two calculations done separately. First, what is 2 + 3? Second, what is 10 + 20? You MUST use the calculator tool for EACH calculation. Do NOT do math in your head.",
165+
},
166+
],
167+
tools: {
168+
calculator: {
169+
description:
170+
"Add two numbers together. Returns their sum. You MUST use this tool for every math operation.",
171+
inputSchema: z.object({
172+
a: z.number().describe("first number"),
173+
b: z.number().describe("second number"),
174+
}),
175+
execute: async ({ a, b }: { a: number; b: number }) => ({
176+
result: a + b,
177+
}),
178+
},
179+
},
180+
stopWhen: stepCountIs(4),
181+
});
182+
183+
const toolCallCount = result.steps.reduce(
184+
(sum, step) => sum + (step.toolCalls?.length || 0),
185+
0,
186+
);
187+
return jsonResponse({
188+
text: result.text,
189+
steps: result.steps.length,
190+
toolCallCount,
191+
});
192+
}
193+
194+
// ----- toolChoice: "required" -----
195+
case "/chat/tool-required": {
196+
const result = await generateText({
197+
model: provider(model as any),
198+
messages: [
199+
{
200+
role: "user",
201+
content: "What is 7 + 8? You MUST use the calculator tool.",
202+
},
203+
],
204+
tools: {
205+
calculator: {
206+
description: "Add two numbers. Returns their sum.",
207+
inputSchema: z.object({
208+
a: z.number().describe("first number"),
209+
b: z.number().describe("second number"),
210+
}),
211+
},
212+
},
213+
toolChoice: "required",
214+
});
215+
216+
return jsonResponse({
217+
text: result.text,
218+
toolCalls: result.toolCalls,
219+
finishReason: result.finishReason,
220+
});
221+
}
222+
156223
// ----- Structured output -----
157224
case "/chat/structured": {
158225
const result = await generateText({

0 commit comments

Comments
 (0)