diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 5865189c5..4bc29fb0c 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -93,6 +93,7 @@ async function executeTool( result = buildToolErrorResult(message); isError = true; } + isError ||= result.isError === true; const sanitizedFinalResult: AgentToolResult = { content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)), @@ -283,6 +284,7 @@ export class CursorExecHandlers implements ICursorExecHandlers { result = buildToolErrorResult(message); isError = true; } + isError ||= result.isError === true; // onUpdate may not fire for every chunk — flush any remaining output // from the final result that wasn't already streamed. diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index fde7857f4..9dc1c49bb 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { create } from "@bufbuild/protobuf"; +import type { AgentEvent, AgentTool } from "@oh-my-pi/pi-agent-core"; +import { ReadArgsSchema, ShellArgsSchema } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor"; import { GrepTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; function createTestSession(cwd: string, overrides: Partial = {}): ToolSession { return { @@ -65,3 +69,55 @@ describe("CursorExecHandlers.grep bridge", () => { expect((sensitiveResult.details as { matchCount?: number } | undefined)?.matchCount).toBe(1); }); }); + +describe("CursorExecHandlers error results", () => { + const rewrittenErrorTool = (name: string): AgentTool => ({ + name, + label: name, + description: "returns a rewritten tool failure", + parameters: type({}), + execute: async () => ({ + content: [{ type: "text", text: "Enriched recovery guidance" }], + details: { enriched: true }, + isError: true, + }), + }); + + it("propagates returned isError through the standard exec bridge", async () => { + const events: AgentEvent[] = []; + const handlers = new CursorExecHandlers({ + cwd: ".", + tools: new Map([["read", rewrittenErrorTool("read")]]), + emitEvent: event => events.push(event), + }); + + const result = await handlers.read(create(ReadArgsSchema, { toolCallId: "call-read", path: "ignored" })); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: "Enriched recovery guidance" }]); + const end = events.find(event => event.type === "tool_execution_end"); + expect(end?.isError).toBe(true); + }); + + it("propagates returned isError through the shell stream bridge", async () => { + const events: AgentEvent[] = []; + const stdout: string[] = []; + const handlers = new CursorExecHandlers({ + cwd: ".", + tools: new Map([["bash", rewrittenErrorTool("bash")]]), + emitEvent: event => events.push(event), + }); + + const result = await handlers.shellStream( + create(ShellArgsSchema, { toolCallId: "call-shell", command: "ignored" }), + { + onStdout: data => stdout.push(data), + onStderr: () => {}, + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: "Enriched recovery guidance" }]); + expect(stdout).toEqual(["Enriched recovery guidance"]); + const end = events.find(event => event.type === "tool_execution_end"); + expect(end?.isError).toBe(true); + }); +});