fix(cursor): propagated returned tool error status

Cursor exec bridges derived failure state only from thrown exceptions, so structured AgentToolResult.isError failures were emitted as successes. Propagate the returned flag through standard and streaming shell execution, with regression coverage for both paths.
This commit is contained in:
roboomp
2026-07-14 20:20:46 +00:00
parent 5303c3852e
commit 1be025bb72
2 changed files with 58 additions and 0 deletions
+2
View File
@@ -93,6 +93,7 @@ async function executeTool(
result = buildToolErrorResult(message);
isError = true;
}
isError ||= result.isError === true;
const sanitizedFinalResult: AgentToolResult<unknown> = {
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.
@@ -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> = {}): 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);
});
});