Repository navigation
feat(tools): structured error wrapper + TUI cause/recovery parsing #1051
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
35783d7
104f11b
fa37405
3ede42f
a173e43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| // @vitest-environment jsdom | ||
| import React from "react"; | ||
| import { cleanup, render } from "@testing-library/react"; | ||
| import { afterEach, expect, it, vi } from "vitest"; | ||
| import { EventItem } from "./EventItem.js"; | ||
| import type { AgentEvent } from "../types.js"; | ||
|
|
||
| vi.mock("ink", async () => { | ||
| const React = await import("react"); | ||
| return { | ||
| Box: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children), | ||
| Text: ({ children }: { children: React.ReactNode }) => React.createElement("span", null, children), | ||
| useInput: () => {}, | ||
| }; | ||
| }); | ||
| vi.mock("../shell/ScreenContext.js", () => ({ useScreen: () => "transcript" })); | ||
| vi.mock("./BashResult.js", () => ({ BashResult: () => null })); | ||
| vi.mock("./TodoList.js", () => ({ TodoList: () => null, parseTodos: () => [] })); | ||
|
|
||
| afterEach(() => cleanup()); | ||
|
|
||
| const structuredEnvelope = JSON.stringify({ | ||
| is_error: true, | ||
| tool: "ops_start", | ||
| cause: "opscontrol_unreachable: missing socket", | ||
| recovery: "bring it up via `decepticon start`", | ||
| }); | ||
|
|
||
| function toolResultEvent(content: string): AgentEvent { | ||
| return { | ||
| id: "e-1", | ||
| type: "tool_result", | ||
| content, | ||
| toolName: "ops_start", | ||
| toolArgs: { workload: "ad" }, | ||
| status: "success", | ||
| timestamp: 0, | ||
| }; | ||
| } | ||
|
|
||
| it("renders a structured tool error with cause and recovery", () => { | ||
| const view = render(<EventItem event={toolResultEvent(structuredEnvelope)} />); | ||
| const text = view.container.textContent ?? ""; | ||
| expect(text).toContain("error>"); | ||
| expect(text).toContain("[ops_start]"); | ||
| expect(text).toContain("opscontrol_unreachable"); | ||
| expect(text).toContain("Recovery:"); | ||
| }); | ||
|
|
||
| it("keeps plain tool results on the raw tool display", () => { | ||
| const view = render(<EventItem event={toolResultEvent('{"state":"running"}') as AgentEvent} />); | ||
| const text = view.container.textContent ?? ""; | ||
| expect(text).not.toContain("Recovery:"); | ||
| expect(text).toContain('{"state":"running"}'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import React from "react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { render } from "ink-testing-library"; | ||
| import { ErrorMessage } from "./ErrorMessage.js"; | ||
| import { ScreenProvider } from "../shell/ScreenContext.js"; | ||
|
|
||
| describe("ErrorMessage — real Ink rendering", () => { | ||
| it("renders tool, cause, recovery, and dismiss hint", () => { | ||
| const content = JSON.stringify({ | ||
| is_error: true, | ||
| tool: "Bash", | ||
| cause: "sandbox HTTP daemon not responding", | ||
| recovery: "Run decepticon doctor --deep to diagnose", | ||
| }); | ||
| const rendered = render( | ||
| <ScreenProvider value="prompt"> | ||
| <ErrorMessage content={content} onDismiss={() => undefined} /> | ||
| </ScreenProvider>, | ||
| ); | ||
| const frame = rendered.lastFrame() ?? ""; | ||
| expect(frame).toContain("[Bash]"); | ||
| expect(frame).toContain("sandbox HTTP daemon not responding"); | ||
| expect(frame).toContain("Recovery: Run decepticon doctor --deep"); | ||
| expect(frame).toContain("esc to dismiss"); | ||
| rendered.unmount(); | ||
| }); | ||
|
|
||
| it("preserves plain-text error fallback", () => { | ||
| const rendered = render( | ||
| <ScreenProvider value="prompt"> | ||
| <ErrorMessage content="Some plain error from the agent." /> | ||
| </ScreenProvider>, | ||
| ); | ||
| const frame = rendered.lastFrame() ?? ""; | ||
| expect(frame).toContain("error>"); | ||
| expect(frame).toContain("Some plain error from the agent."); | ||
| expect(frame).not.toContain("Recovery:"); | ||
| rendered.unmount(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| * | ||
| * Claude Code pattern: MAX_API_ERROR_CHARS = 1000, head/tail split, | ||
| * red color coding, "(ctrl+o to expand)" hint on truncation. | ||
| * When the tool result is a structured error (from tools/errors.py), | ||
| * cause and recovery are displayed separately for readability. | ||
| * Esc dismisses the banner when onDismiss is provided. | ||
| */ | ||
|
|
||
|
|
@@ -13,6 +15,31 @@ import { useScreen } from "../shell/ScreenContext.js"; | |
|
|
||
| const MAX_ERROR_CHARS = 1000; | ||
|
|
||
| interface StructuredError { | ||
| is_error: true; | ||
| tool: string; | ||
| cause: string; | ||
| recovery?: string; | ||
| } | ||
|
|
||
| export function parseStructured(content: string): StructuredError | null { | ||
| try { | ||
| const parsed = JSON.parse(content); | ||
| if ( | ||
| parsed && | ||
| typeof parsed === "object" && | ||
| parsed.is_error === true && | ||
| typeof parsed.tool === "string" && | ||
| typeof parsed.cause === "string" | ||
| ) { | ||
| return parsed as StructuredError; | ||
| } | ||
| } catch { | ||
| // not JSON | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| interface Props { | ||
| content: string; | ||
| /** When provided, esc dismisses the banner. */ | ||
|
|
@@ -38,6 +65,24 @@ export const ErrorMessage = React.memo(function ErrorMessage({ | |
| { isActive: onDismiss !== undefined }, | ||
| ); | ||
|
|
||
| const structured = parseStructured(display); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tool returns the new Useful? React with 👍 / 👎. |
||
|
|
||
| if (structured) { | ||
| return ( | ||
| <Box flexDirection="column" marginTop={1}> | ||
| <Text> | ||
| <Text color="red" bold>{"error> "}</Text> | ||
| <Text bold color="yellow">{`[${structured.tool}] `}</Text> | ||
| <Text color="red" wrap="wrap">{structured.cause}</Text> | ||
| </Text> | ||
| {structured.recovery && ( | ||
| <Text dimColor wrap="wrap">{` Recovery: ${structured.recovery}`}</Text> | ||
| )} | ||
| {onDismiss && <Text dimColor>{" esc to dismiss"}</Text>} | ||
| </Box> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Box flexDirection="column" marginTop={1}> | ||
| <Text> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| """Structured error result for tools. | ||
|
|
||
| Claude Code pattern: errors carry the cause and a recovery path — not | ||
| just a message string. Tools return ``tool_error(...)`` on failure; the | ||
| TUI parses the envelope and displays cause and recovery separately. | ||
|
|
||
| Usage in a tool implementation: | ||
|
|
||
| from decepticon.tools.errors import tool_error | ||
|
|
||
| # On failure, return a structured error instead of raising: | ||
| return tool_error("Bash", cause="sandbox HTTP daemon not responding", | ||
| recovery="Run `decepticon doctor --deep` to diagnose") | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
|
|
||
| class ToolErrorResult(BaseModel): | ||
| """Structured error returned as a tool result. | ||
|
|
||
| The TUI parses this to display cause and recovery separately, | ||
| mirroring Claude Code's error display pattern. | ||
| """ | ||
|
|
||
| is_error: bool = True | ||
| tool: str | ||
| cause: str | ||
| recovery: str = "" | ||
|
|
||
|
|
||
| def tool_error(tool: str, *, cause: str, recovery: str = "") -> dict[str, Any]: | ||
| """Build a structured error tool result. | ||
|
|
||
| Args: | ||
| tool: Tool name (e.g. "Bash", "WebFetch"). | ||
| cause: What went wrong. | ||
| recovery: How the operator or agent should fix it. | ||
|
|
||
| Returns: | ||
| A dict that the TUI parses for cause/recovery display. | ||
| """ | ||
| return ToolErrorResult(tool=tool, cause=cause, recovery=recovery).model_dump() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This commit changes operator-visible TUI error presentation, but
CHAGELOG.mdis absent from the changed files. Add a proposed, PR-linked entry underUnreleasedbefore merge as required by the repository's versioned-change policy.AGENTS.md reference: AGENTS.md:L3-L8
Useful? React with 👍 / 👎.