From 0955e5abdb72747ebee6dd9691a7e5c78f7a1669 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 24 Oct 2025 13:09:42 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20Fix=20tool=20error=20display?= =?UTF-8?q?=20and=20WRITE=20DENIED=20collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Tool error display: GenericToolCall now properly detects and displays tool errors in both formats ({ error: '...' } from AI SDK and { success: false, error: '...' } from tool implementations). Status is correctly shown as 'failed' instead of 'completed' for errors. 2. WRITE DENIED collapse: FileEditToolCall now uses useEffect to collapse when WRITE DENIED result arrives, fixing issue where initial render (before result available) would expand the tool call. Both formats are now consistently handled across all tool types. --- src/components/tools/FileEditToolCall.tsx | 10 +++++++- src/components/tools/GenericToolCall.tsx | 23 ++++++++++++++++++- .../messages/StreamingMessageAggregator.ts | 4 +++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/components/tools/FileEditToolCall.tsx b/src/components/tools/FileEditToolCall.tsx index cfb3d0034..e732beadc 100644 --- a/src/components/tools/FileEditToolCall.tsx +++ b/src/components/tools/FileEditToolCall.tsx @@ -103,9 +103,17 @@ export const FileEditToolCall: React.FC = ({ const isWriteDenied = result && !result.success && result.error?.startsWith(WRITE_DENIED_PREFIX); const initialExpanded = !isWriteDenied; - const { expanded, toggleExpanded } = useToolExpansion(initialExpanded); + const { expanded, setExpanded, toggleExpanded } = useToolExpansion(initialExpanded); const [showRaw, setShowRaw] = React.useState(false); + // Update expanded state when result changes from undefined to WRITE DENIED + // This handles the case where the component renders before the result is available + React.useEffect(() => { + if (result && !result.success && result.error?.startsWith(WRITE_DENIED_PREFIX)) { + setExpanded(false); + } + }, [result, setExpanded]); + const filePath = "file_path" in args ? args.file_path : undefined; // Copy to clipboard with feedback diff --git a/src/components/tools/GenericToolCall.tsx b/src/components/tools/GenericToolCall.tsx index bf3831930..f4c3ed4fe 100644 --- a/src/components/tools/GenericToolCall.tsx +++ b/src/components/tools/GenericToolCall.tsx @@ -33,6 +33,18 @@ export const GenericToolCall: React.FC = ({ }) => { const { expanded, toggleExpanded } = useToolExpansion(); + // Check if result contains an error + // Handles two formats: + // 1. Tool implementation errors: { success: false, error: "..." } + // 2. AI SDK tool-error events: { error: "..." } + const hasError = + result && + typeof result === "object" && + "error" in result && + typeof result.error === "string" && + result.error.length > 0 && + (!("success" in result) || result.success === false); + const hasDetails = args !== undefined || result !== undefined; return ( @@ -52,7 +64,16 @@ export const GenericToolCall: React.FC = ({ )} - {result !== undefined && ( + {hasError ? ( + + Error +
+ {String((result as { error: string }).error)} +
+
+ ) : null} + + {result !== undefined && !hasError && ( Result {formatValue(result)} diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 1af90a9ac..1ee589721 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -805,9 +805,11 @@ export class StreamingMessageAggregator { }); } else if (isDynamicToolPart(part)) { // Determine status based on part state and result + // hasFailureResult now checks both error formats: + // 1. Tool implementation errors: { success: false, error: "..." } + // 2. AI SDK tool-error events: { error: "..." } let status: "pending" | "executing" | "completed" | "failed" | "interrupted"; if (part.state === "output-available") { - // Check if result indicates failure (for tools that return { success: boolean }) status = hasFailureResult(part.output) ? "failed" : "completed"; } else if (part.state === "input-available" && message.metadata?.partial) { status = "interrupted"; From d60ee2bbf46557ed4d1944c6aa421ec6853aa117 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 10:56:22 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20Add=20ToolErrorsDisplay=20st?= =?UTF-8?q?ory=20to=20demonstrate=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows both error formats in visual storybook: 1. AI SDK errors: { error: '...' } 2. Tool implementation errors: { success: false, error: '...' } Includes examples of: - Nonexistent tool errors - Bash command failures - File read errors - WRITE DENIED errors (collapsed by default) - Policy disabled tool errors All display with 'failed' status and styled error boxes. --- src/App.stories.tsx | 191 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 9cfcdce36..bfc634f07 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -662,3 +662,194 @@ export const ActiveWorkspaceWithChat: Story = { return ; }, }; + +/** + * Tool Errors Story - Shows both error formats in GenericToolCall + */ +export const ToolErrorsDisplay: Story = { + render: () => { + const AppWithToolErrors = () => { + const initialized = useRef(false); + + if (!initialized.current) { + const workspaceId = "my-app-tool-errors"; + + // Setup mock API + setupMockAPI({ + projects: new Map([ + [ + "/home/user/projects/my-app", + { + path: "/home/user/projects/my-app", + workspaces: [], + }, + ], + ]), + workspaces: [ + { + id: workspaceId, + name: "tool-errors-demo", + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.cmux/src/my-app/tool-errors-demo", + }, + ], + apiOverrides: { + workspace: { + create: () => Promise.resolve({ success: false, error: "Mock" }), + list: () => Promise.resolve([]), + rename: () => Promise.resolve({ success: false, error: "Mock" }), + remove: () => Promise.resolve({ success: false, error: "Mock" }), + fork: () => Promise.resolve({ success: false, error: "Mock" }), + openTerminal: () => Promise.resolve(undefined), + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: () => + Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }), + onMetadata: () => () => undefined, + onChat: (workspaceId, callback) => { + // Simulate chat history with various tool errors + setTimeout(() => { + // User message + callback({ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Try calling some tools" }], + metadata: { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 120000, + }, + }); + + // Assistant response with multiple tool errors + callback({ + id: "msg-2", + role: "assistant", + parts: [ + { + type: "text", + text: "I'll try various tools to demonstrate error handling.", + }, + // Tool error format #1: AI SDK error (tool doesn't exist) + { + type: "dynamic-tool", + toolCallId: "call-1", + toolName: "nonexistent_tool", + state: "output-available", + input: { + someParam: "value", + }, + output: { + error: + "Tool 'nonexistent_tool' is not available. Available tools: bash, file_read, file_edit_replace_string, file_edit_insert, propose_plan, todo_write, todo_read, web_search", + }, + }, + // Tool error format #2: Tool implementation error (bash command fails) + { + type: "dynamic-tool", + toolCallId: "call-2", + toolName: "bash", + state: "output-available", + input: { + script: "exit 1", + }, + output: { + success: false, + error: "Command exited with code 1", + exitCode: 1, + wall_duration_ms: 42, + }, + }, + // Tool error format #3: File read error + { + type: "dynamic-tool", + toolCallId: "call-3", + toolName: "file_read", + state: "output-available", + input: { + filePath: "/nonexistent/file.txt", + }, + output: { + success: false, + error: "ENOENT: no such file or directory, open '/nonexistent/file.txt'", + }, + }, + // Tool error format #4: File edit with WRITE DENIED (should be collapsed) + { + type: "dynamic-tool", + toolCallId: "call-4", + toolName: "file_edit_replace_string", + state: "output-available", + input: { + file_path: "src/test.ts", + old_string: "const x = 1;", + new_string: "const x = 2;", + }, + output: { + success: false, + error: + "WRITE DENIED, FILE UNMODIFIED: File has been modified since it was read. Please re-read the file and try again.", + }, + }, + // Tool error format #5: Policy disabled tool + { + type: "dynamic-tool", + toolCallId: "call-5", + toolName: "file_edit_insert", + state: "output-available", + input: { + file_path: "src/new.ts", + line_offset: 0, + content: "console.log('test');", + }, + output: { + error: + "Tool execution skipped because the requested tool is disabled by policy.", + }, + }, + { + type: "text", + text: "As you can see, various tool errors are displayed with clear error messages and 'failed' status.", + }, + ], + metadata: { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 110000, + model: "anthropic:claude-sonnet-4-20250514", + }, + }); + }, 100); + + return () => undefined; + }, + }, + }, + }); + + // Set initial workspace selection + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ + workspaceId: workspaceId, + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.cmux/src/my-app/tool-errors-demo", + }) + ); + + initialized.current = true; + } + + return ; + }; + + return ; + }, +}; From c806b9ebfb5be6d1d9b5c7f903f90b6e922733d9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 11:46:19 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20Add=20tool=20error=20example?= =?UTF-8?q?s=20to=20ActiveWorkspaceWithChat=20story?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of creating separate story, add tool error examples to the existing chat story for more realistic context. Shows: - File read errors (success: false format) - WRITE DENIED errors (collapsed by default) - Unknown tool errors (AI SDK error format) --- src/App.stories.tsx | 201 ++++++++++---------------------------------- 1 file changed, 46 insertions(+), 155 deletions(-) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index bfc634f07..db7d01b8b 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -618,159 +618,35 @@ export const ActiveWorkspaceWithChat: Story = { }, }); - // Mark as caught up - callback({ type: "caught-up" }); - }, 100); - - return () => { - // Cleanup - }; - }, - onMetadata: () => () => undefined, - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: () => - Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }), - }, - }, - }); - - // Set initial workspace selection - localStorage.setItem( - "selectedWorkspace", - JSON.stringify({ - workspaceId: workspaceId, - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.cmux/src/my-app/feature", - }) - ); - - initialized.current = true; - } - - return ; - }; - - return ; - }, -}; - -/** - * Tool Errors Story - Shows both error formats in GenericToolCall - */ -export const ToolErrorsDisplay: Story = { - render: () => { - const AppWithToolErrors = () => { - const initialized = useRef(false); - - if (!initialized.current) { - const workspaceId = "my-app-tool-errors"; - - // Setup mock API - setupMockAPI({ - projects: new Map([ - [ - "/home/user/projects/my-app", - { - path: "/home/user/projects/my-app", - workspaces: [], - }, - ], - ]), - workspaces: [ - { - id: workspaceId, - name: "tool-errors-demo", - projectPath: "/home/user/projects/my-app", - projectName: "my-app", - namedWorkspacePath: "/home/user/.cmux/src/my-app/tool-errors-demo", - }, - ], - apiOverrides: { - workspace: { - create: () => Promise.resolve({ success: false, error: "Mock" }), - list: () => Promise.resolve([]), - rename: () => Promise.resolve({ success: false, error: "Mock" }), - remove: () => Promise.resolve({ success: false, error: "Mock" }), - fork: () => Promise.resolve({ success: false, error: "Mock" }), - openTerminal: () => Promise.resolve(undefined), - sendMessage: () => Promise.resolve({ success: true, data: undefined }), - resumeStream: () => Promise.resolve({ success: true, data: undefined }), - interruptStream: () => Promise.resolve({ success: true, data: undefined }), - truncateHistory: () => Promise.resolve({ success: true, data: undefined }), - replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), - getInfo: () => Promise.resolve(null), - executeBash: () => - Promise.resolve({ - success: true, - data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, - }), - onMetadata: () => () => undefined, - onChat: (workspaceId, callback) => { - // Simulate chat history with various tool errors - setTimeout(() => { - // User message + // User asking to test error handling callback({ - id: "msg-1", + id: "msg-10", role: "user", - parts: [{ type: "text", text: "Try calling some tools" }], + parts: [ + { + type: "text", + text: "Can you show me some examples of error handling?", + }, + ], metadata: { - historySequence: 1, - timestamp: STABLE_TIMESTAMP - 120000, + historySequence: 10, + timestamp: STABLE_TIMESTAMP - 160000, }, }); - // Assistant response with multiple tool errors + // Assistant response with various tool errors callback({ - id: "msg-2", + id: "msg-11", role: "assistant", parts: [ { type: "text", - text: "I'll try various tools to demonstrate error handling.", + text: "I'll demonstrate various error scenarios:", }, - // Tool error format #1: AI SDK error (tool doesn't exist) + // Tool error format #1: File read error { type: "dynamic-tool", - toolCallId: "call-1", - toolName: "nonexistent_tool", - state: "output-available", - input: { - someParam: "value", - }, - output: { - error: - "Tool 'nonexistent_tool' is not available. Available tools: bash, file_read, file_edit_replace_string, file_edit_insert, propose_plan, todo_write, todo_read, web_search", - }, - }, - // Tool error format #2: Tool implementation error (bash command fails) - { - type: "dynamic-tool", - toolCallId: "call-2", - toolName: "bash", - state: "output-available", - input: { - script: "exit 1", - }, - output: { - success: false, - error: "Command exited with code 1", - exitCode: 1, - wall_duration_ms: 42, - }, - }, - // Tool error format #3: File read error - { - type: "dynamic-tool", - toolCallId: "call-3", + toolCallId: "call-5", toolName: "file_read", state: "output-available", input: { @@ -781,10 +657,10 @@ export const ToolErrorsDisplay: Story = { error: "ENOENT: no such file or directory, open '/nonexistent/file.txt'", }, }, - // Tool error format #4: File edit with WRITE DENIED (should be collapsed) + // Tool error format #2: WRITE DENIED (should be collapsed) { type: "dynamic-tool", - toolCallId: "call-4", + toolCallId: "call-6", toolName: "file_edit_replace_string", state: "output-available", input: { @@ -798,37 +674,52 @@ export const ToolErrorsDisplay: Story = { "WRITE DENIED, FILE UNMODIFIED: File has been modified since it was read. Please re-read the file and try again.", }, }, - // Tool error format #5: Policy disabled tool + // Tool error format #3: AI SDK error (policy disabled) { type: "dynamic-tool", - toolCallId: "call-5", - toolName: "file_edit_insert", + toolCallId: "call-7", + toolName: "unknown_tool", state: "output-available", input: { - file_path: "src/new.ts", - line_offset: 0, - content: "console.log('test');", + someParam: "value", }, output: { error: - "Tool execution skipped because the requested tool is disabled by policy.", + "Tool 'unknown_tool' is not available. Available tools: bash, file_read, file_edit_replace_string", }, }, { type: "text", - text: "As you can see, various tool errors are displayed with clear error messages and 'failed' status.", + text: "As you can see, different error types are displayed with clear indicators and appropriate styling.", }, ], metadata: { - historySequence: 2, - timestamp: STABLE_TIMESTAMP - 110000, - model: "anthropic:claude-sonnet-4-20250514", + historySequence: 11, + timestamp: STABLE_TIMESTAMP - 150000, + model: "claude-sonnet-4-20250514", }, }); + + // Mark as caught up + callback({ type: "caught-up" }); }, 100); - return () => undefined; + return () => { + // Cleanup + }; }, + onMetadata: () => () => undefined, + sendMessage: () => Promise.resolve({ success: true, data: undefined }), + resumeStream: () => Promise.resolve({ success: true, data: undefined }), + interruptStream: () => Promise.resolve({ success: true, data: undefined }), + truncateHistory: () => Promise.resolve({ success: true, data: undefined }), + replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), + getInfo: () => Promise.resolve(null), + executeBash: () => + Promise.resolve({ + success: true, + data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 }, + }), }, }, }); @@ -840,7 +731,7 @@ export const ToolErrorsDisplay: Story = { workspaceId: workspaceId, projectPath: "/home/user/projects/my-app", projectName: "my-app", - namedWorkspacePath: "/home/user/.cmux/src/my-app/tool-errors-demo", + namedWorkspacePath: "/home/user/.cmux/src/my-app/feature", }) ); @@ -850,6 +741,6 @@ export const ToolErrorsDisplay: Story = { return ; }; - return ; + return ; }, };