diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 9635b244..00ae939e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -22,7 +22,7 @@ import { import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; -import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; +import { getToolActionsSummary, getToolDisplayName, getToolErrorText, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -329,7 +329,7 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 453b1e64..ce1bc9cf 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -51,6 +51,7 @@ import { getWebSearchCardData, getComposioConnectCardData, getToolDisplayName, + getToolErrorText, groupConversationItems, isChatMessage, isErrorMessage, @@ -484,7 +485,7 @@ export function ChatSidebar({ ) } const toolTitle = getToolDisplayName(item) - const errorText = item.status === 'error' ? 'Tool error' : '' + const errorText = getToolErrorText(item) const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) return ( diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx index 28a55db5..528eceb9 100644 --- a/apps/x/apps/renderer/src/components/code/code-chat.tsx +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -10,7 +10,7 @@ import { Conversation, ConversationContent, ConversationScrollButton } from '@/c import { MessageResponse } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' -import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation' +import { toToolState, getToolDisplayName, getToolErrorText, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation' import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run' import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' @@ -84,7 +84,7 @@ function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (pa - + ) diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx index 916aad37..2625b65a 100644 --- a/apps/x/apps/renderer/src/components/coding-run.tsx +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { + AlertCircle, CheckCircle2, Circle, CircleDot, @@ -17,7 +18,7 @@ import { import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js' import { cn } from '@/lib/utils' import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' -import { toToolState, type ToolCall } from '@/lib/chat-conversation' +import { getToolErrorText, toToolState, type ToolCall } from '@/lib/chat-conversation' // ── Timeline reduction ────────────────────────────────────────────── // The raw ACP stream is a flat list of events; collapse it into ordered rows, @@ -111,14 +112,26 @@ const basename = (p: string) => p.split(/[\\/]/).pop() || p export function CodingRunTimeline({ events, + error, onOpenDiff, }: { events: CodeRunEvent[] + error?: string // When set, changed-file names become clickable (the Code section opens the diff). onOpenDiff?: (path: string) => void }) { const rows = useMemo(() => reduceEvents(events), [events]) if (rows.length === 0) { + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } return
Starting the agent…
} return ( @@ -133,12 +146,17 @@ export function CodingRunTimeline({ } if (row.kind === 'tool') { const running = row.status !== 'completed' && row.status !== 'failed' + const failed = row.status === 'failed' return (
- {running - ? - : } + {running ? ( + + ) : failed ? ( + + ) : ( + + )} {toolKindIcon(row.toolKind, row.title)} {row.title ?? row.toolKind ?? 'Tool call'}
@@ -189,6 +207,12 @@ export function CodingRunTimeline({
) })} + {error && ( +
+ + {error} +
+ )} ) } @@ -258,12 +282,13 @@ export function CodingRunBlock({ (item.result as { agent?: string } | undefined)?.agent ?? (item.input as { agent?: string } | undefined)?.agent const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent' + const error = getToolErrorText(item) return ( <> - + {item.pendingCodePermission && ( diff --git a/apps/x/apps/renderer/src/components/compact-conversation.tsx b/apps/x/apps/renderer/src/components/compact-conversation.tsx index f1367d5f..e14b7457 100644 --- a/apps/x/apps/renderer/src/components/compact-conversation.tsx +++ b/apps/x/apps/renderer/src/components/compact-conversation.tsx @@ -7,6 +7,7 @@ import { isErrorMessage, isToolCall, getToolDisplayName, + getToolErrorText, toToolState, normalizeToolOutput, } from '@/lib/chat-conversation' @@ -62,7 +63,7 @@ function CompactToolRow({ tool }: { tool: ToolCall }) { const [open, setOpen] = useState(false) const title = getToolDisplayName(tool) const state = toToolState(tool.status) - const errorText = tool.status === 'error' && typeof tool.result === 'string' ? tool.result : undefined + const errorText = getToolErrorText(tool) return ( diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index f9a00c74..0911ac28 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -121,6 +121,19 @@ export const normalizeToolOutput = ( return output } +export const getToolErrorText = (tool: ToolCall): string | undefined => { + if (tool.status !== 'error') return undefined + if (typeof tool.result === 'string' && tool.result.trim()) return tool.result + if (tool.result !== undefined) { + try { + return JSON.stringify(tool.result, null, 2) + } catch { + // Fall through to the generic label for non-serializable legacy values. + } + } + return 'Tool error' +} + export type WebSearchCardResult = { title: string; url: string; description: string } export type WebSearchCardData = { diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts index 342dc16f..34df50ba 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts @@ -190,6 +190,35 @@ describe('buildTurnConversation', () => { expect(items.map((i) => i.status)).toEqual(['pending', 'running']) }) + it('uses the tool result envelope to determine error status', () => { + const state = reduceTurn([ + created(T1, S1), + requested(T1, 0), + completed( + T1, + 0, + assistantCalls( + toolCallPart('flagged', 'echo'), + toolCallPart('normal', 'echo'), + ), + ), + invocation(T1, 'flagged', 'echo'), + { + type: 'tool_result', + turnId: T1, + ts: TS, + toolCallId: 'flagged', + toolName: 'echo', + source: 'sync', + result: { output: 'boom', isError: true }, + }, + invocation(T1, 'normal', 'echo'), + toolResult(T1, 'normal', 'echo', { success: false, message: 'payload data' }), + ]) + const items = buildTurnConversation(state).filter(isToolCall) + expect(items.map((i) => i.status)).toEqual(['error', 'completed']) + }) + it('renders user attachments and a failed turn as an error item', () => { const input = { role: 'user' as const, diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index 0d5413c1..2e8df218 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -200,7 +200,7 @@ function extractAttachments(content: UserContent): MessageAttachment[] | undefin } function toolStatus(tc: ToolCallState): ToolCall['status'] { - if (tc.result) return 'completed' + if (tc.result) return tc.result.result.isError ? 'error' : 'completed' if (tc.permission && !tc.permission.resolved) return 'pending' return 'running' } diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts index d9f15dd8..9feeed36 100644 --- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -79,7 +79,7 @@ After \`code_agent_run\` returns: - Pass through the agent's \`summary\` as-is. Do not rewrite it. - Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.) - Only add your own explanation if it failed: - - \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**. + - A tool error with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**. - \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue. --- diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.test.ts b/apps/x/packages/core/src/application/lib/builtin-tools.test.ts new file mode 100644 index 00000000..1842024c --- /dev/null +++ b/apps/x/packages/core/src/application/lib/builtin-tools.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import container from "../../di/container.js"; +import { InMemoryAbortRegistry } from "../../runs/abort-registry.js"; +import { BuiltinTools } from "./builtin-tools.js"; +import type { ToolContext } from "./exec-tool.js"; + +function context(signal: AbortSignal): ToolContext { + return { + runId: "turn-1", + toolCallId: "tool-1", + signal, + abortRegistry: new InMemoryAbortRegistry(), + publish: async () => {}, + codePolicy: "ask", + }; +} + +function mockCodeServices(runPrompt: () => Promise): void { + vi.spyOn(container, "resolve").mockImplementation(((name: string) => { + if (name === "codeModeManager") return { runPrompt }; + if (name === "codePermissionRegistry") { + return { cancelRun: vi.fn(), request: vi.fn() }; + } + throw new Error(`Unexpected dependency: ${name}`); + }) as typeof container.resolve); +} + +describe("code_agent_run", () => { + afterEach(() => vi.restoreAllMocks()); + + it("throws genuine coding-agent failures for the runtime to mark as errors", async () => { + mockCodeServices(async () => { + throw new Error("spawn Electron ENOENT"); + }); + + await expect(BuiltinTools.code_agent_run.execute( + { agent: "codex", cwd: "/repo", prompt: "Fix it" }, + context(new AbortController().signal), + )).rejects.toThrow("Coding agent failed: spawn Electron ENOENT"); + }); + + it("returns an ordinary cancellation result when the turn was aborted", async () => { + const controller = new AbortController(); + controller.abort(); + mockCodeServices(async () => { + throw new Error("cancelled"); + }); + + await expect(BuiltinTools.code_agent_run.execute( + { agent: "codex", cwd: "/repo", prompt: "Fix it" }, + context(controller.signal), + )).resolves.toMatchObject({ success: false, stopReason: "cancelled" }); + }); +}); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index f3d16e3f..2f74a9c5 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -868,7 +868,7 @@ export const BuiltinTools: z.infer = { }), execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => { if (!ctx) { - return { success: false, message: 'code_agent_run requires run context (runId / streaming).' }; + throw new Error('code_agent_run requires run context (runId / streaming).'); } // The composer chip is the source of truth for the agent. The model's `agent` // argument is only a fallback for the ask-human flow (code mode not active, no @@ -955,10 +955,7 @@ export const BuiltinTools: z.infer = { changedFiles: [...changedFiles], }; } - return { - success: false, - message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`, - }; + throw new Error(`Coding agent failed: ${error instanceof Error ? error.message : String(error)}`); } finally { ctx.signal.removeEventListener('abort', onAbort); } diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index c588042e..d46f98a2 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: vitest: 4.1.7 onlyBuiltDependencies: + - baileys - core-js - electron - electron-winstaller @@ -23,6 +24,7 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - protobufjs + patchedDependencies: '@agentclientprotocol/codex-acp@0.0.44': patches/@agentclientprotocol__codex-acp@0.0.44.patch '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch