From 024f49220ebf08d98091f9b28d45dd69355bf27e Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Jul 2026 08:02:31 +0200 Subject: [PATCH] fix(coding-agent): handled xdev execution errors with mounted tool renderer - Catch execution errors in XdevRegistry and render them using the mounted tool's error handling. - Preserve xdev dispatch context when device execution fails. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tools/xdev.ts | 55 ++++++++++++------- .../test/write-xdev-dispatch.test.ts | 48 +++++++++++++++- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3eb747761..4b9e2a659 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed `xd://` device execution failures rendering as `write` errors instead of using the mounted tool's own error renderer. - Fixed the clipboard image-paste keybind mangling copied URL text into a bogus path error on macOS (e.g. `Image not found at /https/::i.can.ac:CE4Ek3.png` for a copied `https://i.can.ac/CE4Ek3.png`). AppleScript's `the clipboard as «class furl»` coerces plain *text* into a file URL by treating the string as an HFS path (`:`↔`/` swap), so `readMacFileUrlsFromClipboard` returned a garbage path that dead-ended in `handleImagePathPaste` instead of falling through to the text paste. The script now bails early via `clipboard info for «class furl»` unless the pasteboard actually carries a `public.file-url` representation, so URL/text clipboards paste as text. - Fixed spilled tool-output artifact descriptors leaking on error/abort paths. `OutputSink.dump()` was the only path that closed the spill `Bun.FileSink`, but the bash and Python executors re-throw on failure and their `finally` blocks never closed the sink, so a large-output command that errored leaked the artifact descriptor until an unrelated read (e.g. a `SKILL.md` load) hit `EMFILE`. `OutputSink` now exposes an idempotent `dispose()` that closes the sink exactly once, wired into every executor's `finally` ([#6463](https://github.com/can1357/oh-my-pi/issues/6463)). - Fixed the first submitted prompt stalling while the local tiny-title worker started: the interactive submit handler now paints the pending user row before starting title generation, and startup prewarms an idle, unref'd worker so the first submit reuses a live subprocess instead of paying spawn latency ahead of the first frame ([#6462](https://github.com/can1357/oh-my-pi/issues/6462)). diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index caa8b29b4..2bbe72f38 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -33,7 +33,7 @@ import type { Theme } from "../modes/theme/theme"; import type { Tool } from "./index"; import { replaceTabs } from "./render-utils"; import type { ToolRenderer } from "./renderers"; -import { ToolError } from "./tool-errors"; +import { renderError, ToolAbortError, ToolError } from "./tool-errors"; /** * Discoverable built-ins that must stay top-level even when xdev mounting is @@ -389,28 +389,45 @@ export class XdevRegistry { onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise<{ result: AgentToolResult; xdev: XdevDispatch }> { - const inst = this.#resolve(name); + let xdev: XdevDispatch = { tool: name, mode: "execute" }; + try { + const inst = this.#resolve(name); - if (HELP_CONTENT_RE.test(content)) { + if (HELP_CONTENT_RE.test(content)) { + return { + result: { content: [{ type: "text", text: renderDocs(inst) }] }, + xdev: { tool: name, mode: "help" }, + }; + } + + const validated = parseDeviceArgs(inst as AiTool, content, toolCallId, () => renderDocs(inst)); + xdev = { ...xdev, args: validated }; + const innerOnUpdate: AgentToolUpdateCallback | undefined = onUpdate + ? partial => + onUpdate({ + content: partial.content, + details: { xdev: { ...xdev, inner: partial.details } }, + isError: partial.isError, + }) + : undefined; + const result = await inst.execute(toolCallId, validated as never, signal, innerOnUpdate, context); + return { result, xdev: { ...xdev, inner: result.details } }; + } catch (error) { + if ( + error instanceof ToolAbortError || + signal?.aborted || + (error instanceof Error && error.name === "AbortError") + ) { + throw error; + } return { - result: { content: [{ type: "text", text: renderDocs(inst) }] }, - xdev: { tool: name, mode: "help" }, + result: { + content: [{ type: "text", text: renderError(error) }], + isError: true, + }, + xdev, }; } - - const validated = parseDeviceArgs(inst as AiTool, content, toolCallId, () => renderDocs(inst)); - - const xdevBase: XdevDispatch = { tool: name, mode: "execute", args: validated }; - const innerOnUpdate: AgentToolUpdateCallback | undefined = onUpdate - ? partial => - onUpdate({ - content: partial.content, - details: { xdev: { ...xdevBase, inner: partial.details } }, - isError: partial.isError, - }) - : undefined; - const result = await inst.execute(toolCallId, validated as never, signal, innerOnUpdate, context); - return { result, xdev: { ...xdevBase, inner: result.details } }; } } diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index 1ab6bae85..f293b3738 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -6,9 +6,12 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write"; +import { githubToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/gh-renderer"; +import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; +import { WriteTool, writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write"; import { XdevRegistry } from "@oh-my-pi/pi-coding-agent/tools/xdev"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; // xdev mounting is default-on: discoverable tools like ast_edit unmount into // xd://, and a plain `write xd://ast_edit` dispatches them. These guard the @@ -182,6 +185,49 @@ describe("read and write route xd:// device URLs", () => { expect(rendered).toBeDefined(); }); + it("renders device execution errors as the mounted tool instead of write", async () => { + await themeModule.initTheme(); + const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light")); + if (!uiTheme) throw new Error("expected an initialized theme"); + + const githubDevice = { + name: "github", + label: "GitHub", + description: "fixture", + parameters: type({ op: "string" }), + ...githubToolRenderer, + async execute() { + throw new ToolError("gh: Not Found (HTTP 404)"); + }, + }; + const registry = new XdevRegistry([githubDevice]); + const write = new WriteTool(xdevSession(process.cwd(), { xdevRegistry: registry })); + const content = JSON.stringify({ op: "repo_view" }); + + const result = await write.execute("write-xdev-error", { path: "xd://github", content }); + expect(result.isError).toBe(true); + expect(result.details?.xdev).toMatchObject({ + tool: "github", + mode: "execute", + args: { op: "repo_view" }, + }); + + const component = writeToolRenderer.renderResult( + result, + { + expanded: false, + isPartial: false, + renderContext: { resolveXdevMounted: name => registry.get(name) }, + }, + uiTheme, + { path: "xd://github", content }, + ); + const rendered = Bun.stripANSI(component.render(80).join("\n")); + expect(rendered).toContain("GitHub Repo"); + expect(rendered).toContain("gh: Not Found (HTTP 404)"); + expect(rendered).not.toContain("Write"); + }); + it("docsAll inlines small device docs and falls back to a listing past the caps", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-docs-")); try {