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.
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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<unknown>; 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 } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user