Skip to content

Commit f307fbf

Browse files
sethconvexclaude
andcommitted
Fix tools not reporting proper errors (output-error state)
Two fixes: 1. normalizeToolResult in mapping.ts was stripping the isError flag. Now preserves isError when transforming tool result parts. 2. createAssistantUIMessage in UIMessages.ts now checks contentPart.isError in addition to message.error when determining tool state. This ensures tool results with isError: true show "output-error" state instead of incorrectly showing "output-available". Fixes #162 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 74cbf01 commit f307fbf

File tree

3 files changed

+111
-4
lines changed

3 files changed

+111
-4
lines changed

src/UIMessages.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,16 +463,19 @@ function createAssistantUIMessage<
463463
typeof contentPart.output?.type === "string"
464464
? contentPart.output.value
465465
: contentPart.output;
466+
// Check for error at both the content part level (isError) and message level
467+
const hasError = contentPart.isError || message.error;
468+
const errorText = message.error || (hasError ? String(output) : undefined);
466469
const call = allParts.find(
467470
(part) =>
468471
part.type === `tool-${contentPart.toolName}` &&
469472
"toolCallId" in part &&
470473
part.toolCallId === contentPart.toolCallId,
471474
) as ToolUIPart | undefined;
472475
if (call) {
473-
if (message.error) {
476+
if (hasError) {
474477
call.state = "output-error";
475-
call.errorText = message.error;
478+
call.errorText = errorText;
476479
call.output = output;
477480
} else {
478481
call.state = "output-available";
@@ -483,13 +486,13 @@ function createAssistantUIMessage<
483486
"Tool result without preceding tool call.. adding anyways",
484487
contentPart,
485488
);
486-
if (message.error) {
489+
if (hasError) {
487490
allParts.push({
488491
type: `tool-${contentPart.toolName}`,
489492
toolCallId: contentPart.toolCallId,
490493
state: "output-error",
491494
input: undefined,
492-
errorText: message.error,
495+
errorText,
493496
callProviderMetadata: message.providerMetadata,
494497
} satisfies ToolUIPart<TOOLS>);
495498
} else {

src/mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,8 @@ function normalizeToolResult(
549549
normalizeToolOutput("result" in part ? part.result : undefined),
550550
toolCallId: part.toolCallId,
551551
toolName: part.toolName,
552+
// Preserve isError flag for error reporting
553+
...("isError" in part && part.isError ? { isError: true } : {}),
552554
...metadata,
553555
} satisfies ToolResultPart;
554556
}

src/toUIMessages.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,4 +728,106 @@ describe("toUIMessages", () => {
728728
expect(textParts).toHaveLength(1);
729729
expect(textParts[0].text).toBe("The result is 42.");
730730
});
731+
732+
it("shows output-error state when tool result has isError: true (issue #162)", () => {
733+
const messages = [
734+
// Tool call
735+
baseMessageDoc({
736+
_id: "msg1",
737+
order: 1,
738+
stepOrder: 1,
739+
tool: true,
740+
message: {
741+
role: "assistant",
742+
content: [
743+
{
744+
type: "tool-call",
745+
toolName: "generateImage",
746+
toolCallId: "call1",
747+
args: { id: "invalid-id" },
748+
},
749+
],
750+
},
751+
}),
752+
// Tool result with error
753+
baseMessageDoc({
754+
_id: "msg2",
755+
order: 1,
756+
stepOrder: 2,
757+
tool: true,
758+
message: {
759+
role: "tool",
760+
content: [
761+
{
762+
type: "tool-result",
763+
toolCallId: "call1",
764+
toolName: "generateImage",
765+
output: {
766+
type: "text",
767+
value:
768+
'ArgumentValidationError: Value does not match validator.\nPath: .id\nValue: "invalid-id"\nValidator: v.id("images")',
769+
},
770+
isError: true,
771+
},
772+
],
773+
},
774+
}),
775+
];
776+
777+
const uiMessages = toUIMessages(messages);
778+
779+
expect(uiMessages).toHaveLength(1);
780+
expect(uiMessages[0].role).toBe("assistant");
781+
782+
const toolParts = uiMessages[0].parts.filter(
783+
(p) => p.type === "tool-generateImage",
784+
);
785+
expect(toolParts).toHaveLength(1);
786+
787+
const toolPart = toolParts[0] as any;
788+
expect(toolPart.toolCallId).toBe("call1");
789+
// Should show output-error, not output-available
790+
expect(toolPart.state).toBe("output-error");
791+
expect(toolPart.output).toContain("ArgumentValidationError");
792+
});
793+
794+
it("shows output-error when tool result has isError: true without tool call present (issue #162)", () => {
795+
// This simulates the case where the tool-call message wasn't saved
796+
const messages = [
797+
baseMessageDoc({
798+
_id: "msg1",
799+
order: 1,
800+
stepOrder: 1,
801+
tool: true,
802+
message: {
803+
role: "tool",
804+
content: [
805+
{
806+
type: "tool-result",
807+
toolCallId: "call1",
808+
toolName: "generateImage",
809+
output: {
810+
type: "text",
811+
value: "Error: Something went wrong",
812+
},
813+
isError: true,
814+
},
815+
],
816+
},
817+
}),
818+
];
819+
820+
const uiMessages = toUIMessages(messages);
821+
822+
expect(uiMessages).toHaveLength(1);
823+
expect(uiMessages[0].role).toBe("assistant");
824+
825+
const toolParts = uiMessages[0].parts.filter(
826+
(p) => p.type === "tool-generateImage",
827+
);
828+
expect(toolParts).toHaveLength(1);
829+
830+
const toolPart = toolParts[0] as any;
831+
expect(toolPart.state).toBe("output-error");
832+
});
731833
});

0 commit comments

Comments
 (0)