diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 56c6c404d..4366960d5 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor sessions re-executing settled tools when an owned dialect projector rebuilds toolCall blocks: `snapshotAssistantContentBlock` now copies `kCursorExecResolved` explicitly so agent-loop still skips already-settled calls. + ## [17.2.10] - 2026-08-06 ### Fixed diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 9cd8b1671..a01e42393 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -31,7 +31,11 @@ import { wrapInbandToolStream, } from "@oh-my-pi/pi-ai/dialect"; import * as AIError from "@oh-my-pi/pi-ai/error"; -import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; +import { + type CursorExecResolvedCarrier, + copyCursorExecResolved, + kCursorExecResolved, +} from "@oh-my-pi/pi-ai/utils/block-symbols"; import { createHarmonyAuditEvent, detectHarmonyLeakInAssistantMessage, @@ -356,12 +360,18 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC return { ...block, block: structuredCloneJSON(block.block) }; case "fallback": return { ...block, from: { ...block.from }, to: { ...block.to } }; - case "toolCall": - return { + case "toolCall": { + const snap = { ...block, arguments: structuredCloneJSON(block.arguments), providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata), }; + // Object spread copies enumerable symbols in Bun, but the Cursor + // exec-resolved marker is load-bearing for skip-on-dispatch — copy + // it explicitly so a projector/snapshot path cannot drop it. + copyCursorExecResolved(snap, block); + return snap; + } } } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0e26ffbf0..b4c63a7a5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor exec-bridge bash/grep calls failing ArkType validation when the server omitted optional frame fields: synthesized and executed tool args now drop `undefined` keys (`cwd`, `case`, `skip`, `timeout`) instead of writing `optional: value || undefined`. +- Fixed Cursor sessions double-executing settled tools when `tools.format` is an owned dialect (e.g. `gemini`): `wrapInbandToolStream` rebuilt toolCall blocks without copying `kCursorExecResolved`, so agent-loop re-ran bash/grep/todo and appended a second result for the same call id. + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/ai/src/dialect/owned-stream.ts b/packages/ai/src/dialect/owned-stream.ts index 2c40aa961..d9bcfe36b 100644 --- a/packages/ai/src/dialect/owned-stream.ts +++ b/packages/ai/src/dialect/owned-stream.ts @@ -7,6 +7,7 @@ import type { } from "../types"; import { clearStreamingPartialJson, + copyCursorExecResolved, getStreamingPartialJson, type StreamingPartialJsonCarrier, setStreamingPartialJson, @@ -54,6 +55,7 @@ function cloneToolCall(source: StreamingToolCall): StreamingToolCall { }; const partialJson = getStreamingPartialJson(source); if (partialJson !== undefined) setStreamingPartialJson(block, partialJson); + copyCursorExecResolved(block, source); return block; } @@ -65,6 +67,7 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi const partialJson = getStreamingPartialJson(source); if (partialJson === undefined) clearStreamingPartialJson(target); else setStreamingPartialJson(target, partialJson); + copyCursorExecResolved(target, source); } function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall { diff --git a/packages/ai/src/providers/cursor-pi-args.ts b/packages/ai/src/providers/cursor-pi-args.ts index c88275b8f..5725c41b6 100644 --- a/packages/ai/src/providers/cursor-pi-args.ts +++ b/packages/ai/src/providers/cursor-pi-args.ts @@ -163,3 +163,25 @@ export function piLimit(limit: number | undefined): number | undefined { export function piTimeout(timeout: number | undefined): number | undefined { return timeout !== undefined && timeout >= 0 ? timeout : undefined; } + +/** + * Drop keys whose value is `undefined` so optional local-tool kwargs stay + * absent rather than present-as-undefined. + * + * The Cursor exec bridge historically wrote forms like + * `cwd: workingDirectory || undefined` and + * `case: caseInsensitive === true ? false : undefined`. ArkType rejects a + * present `undefined` on an optional field (`was undefined`) even though + * omitting the key is valid — which flooded Cursor sessions with bash/grep + * validation errors for otherwise fine frames. + */ +export function omitUndefinedArgs>( + args: T, +): { [K in keyof T]?: Exclude } { + const out: Record = {}; + for (const key of Object.keys(args)) { + const value = args[key]; + if (value !== undefined) out[key] = value; + } + return out as { [K in keyof T]?: Exclude }; +} diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index d276a27b0..91dd92650 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -210,6 +210,7 @@ import { buildPiWriteError, buildPiWriteRejected, buildPiWriteResult, + omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, @@ -3592,11 +3593,14 @@ export function synthesizeCursorExecToolCall( ): void { endCurrentTextBlock(output, stream, state); endCurrentThinkingBlock(output, stream, state); + // Exec-frame translators often write `optional: value || undefined`. A + // present `undefined` fails ArkType optional-field validation; drop those + // keys so the transcript block matches what a model-native call would omit. const block: ToolCallState = { type: "toolCall", id: toolCallId, name: toolName, - arguments: args, + arguments: omitUndefinedArgs(args), [kStreamingBlockIndex]: output.content.length, [kStreamingBlockKind]: "cursor-exec", [kCursorExecResolved]: true, diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index 621f00a6e..67ddcbead 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -74,6 +74,7 @@ import type { ToolResultMessage } from "../../types"; * and their translation are consumed together. */ export { + omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, diff --git a/packages/ai/src/utils/block-symbols.ts b/packages/ai/src/utils/block-symbols.ts index 1792cdb71..d1dc30898 100644 --- a/packages/ai/src/utils/block-symbols.ts +++ b/packages/ai/src/utils/block-symbols.ts @@ -59,6 +59,24 @@ export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved"); /** Carries the resolved marker without exposing a string-keyed property. */ export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true }; +/** True when a toolCall block was already executed by Cursor's exec channel. */ +export function isCursorExecResolved(block: CursorExecResolvedCarrier | null | undefined): boolean { + return block?.[kCursorExecResolved] === true; +} + +/** + * Copy {@link kCursorExecResolved} onto a cloned/projected toolCall block. + * + * Stream projectors (owned/in-band dialect, leaked-thinking heal) rebuild + * toolCall objects field-by-field. Dropping this marker lets `agent-loop.ts` + * re-execute a call Cursor already settled — duplicate toolResults and a + * second bash/write/delete. Partial-JSON is already copied explicitly; this + * marker is the other load-bearing symbol that must survive the same way. + */ +export function copyCursorExecResolved(target: CursorExecResolvedCarrier, source: CursorExecResolvedCarrier): void { + if (source[kCursorExecResolved] === true) target[kCursorExecResolved] = true; +} + /** * Marks a text block synthesized by cross-model thinking demotion in * `transformMessages`. Converters that flatten adjacent text blocks into one diff --git a/packages/ai/src/utils/leaked-thinking-stream.ts b/packages/ai/src/utils/leaked-thinking-stream.ts index 5780606bf..93729a82d 100644 --- a/packages/ai/src/utils/leaked-thinking-stream.ts +++ b/packages/ai/src/utils/leaked-thinking-stream.ts @@ -36,6 +36,7 @@ import type { } from "../types"; import { clearStreamingPartialJson, + copyCursorExecResolved, getStreamingPartialJson, type StreamingPartialJsonCarrier, setStreamingPartialJson, @@ -49,6 +50,7 @@ function cloneToolCall(source: StreamingToolCall): StreamingToolCall { const block: StreamingToolCall = { ...source, arguments: source.arguments }; const partialJson = getStreamingPartialJson(source); if (partialJson !== undefined) setStreamingPartialJson(block, partialJson); + copyCursorExecResolved(block, source); return block; } @@ -57,6 +59,7 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi const partialJson = getStreamingPartialJson(source); if (partialJson === undefined) clearStreamingPartialJson(target); else setStreamingPartialJson(target, partialJson); + copyCursorExecResolved(target, source); } /** diff --git a/packages/ai/test/cursor-pi-args.test.ts b/packages/ai/test/cursor-pi-args.test.ts new file mode 100644 index 000000000..0766ff737 --- /dev/null +++ b/packages/ai/test/cursor-pi-args.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; +import { omitUndefinedArgs, piGrepSkip } from "../src/providers/cursor-pi-args"; +import type { Tool } from "../src/types"; +import { validateToolArguments } from "../src/utils/validation"; + +describe("omitUndefinedArgs", () => { + it("drops keys whose value is undefined and keeps defined optionals", () => { + expect( + omitUndefinedArgs({ + command: "pwd", + cwd: undefined, + timeout: 30, + }), + ).toEqual({ command: "pwd", timeout: 30 }); + expect( + omitUndefinedArgs({ + pattern: "needle", + path: ".", + case: false, + skip: piGrepSkip(undefined), + }), + ).toEqual({ pattern: "needle", path: ".", case: false }); + }); + + it("makes Cursor-style bash/grep frames pass ArkType optional-field validation", () => { + const bashTool: Tool = { + name: "bash", + description: "", + parameters: type({ + command: type("string").describe("command to execute"), + "timeout?": type("number").describe("timeout"), + "cwd?": type("string").describe("working directory"), + }), + }; + const grepTool: Tool = { + name: "grep", + description: "", + parameters: type({ + pattern: type("string").describe("regex pattern"), + "path?": type("string").describe("path"), + "case?": type("boolean").describe("case-sensitive search"), + "skip?": type("number").or("null").describe("files to skip"), + }), + }; + + // Mirrors the Cursor bridge: empty workingDirectory → `cwd: undefined`. + const workingDirectory = ""; + const rawBash = { + command: "git status", + cwd: workingDirectory || undefined, + timeout: 30, + }; + expect(() => + validateToolArguments(bashTool, { type: "toolCall", id: "b1", name: "bash", arguments: rawBash }), + ).toThrow(/cwd must be working directory \(was undefined\)/); + expect( + validateToolArguments(bashTool, { + type: "toolCall", + id: "b2", + name: "bash", + arguments: omitUndefinedArgs(rawBash), + }), + ).toEqual({ command: "git status", timeout: 30 }); + + // Mirrors the Cursor bridge: caseInsensitive unset → `case: undefined`. + const caseInsensitive: boolean | undefined = undefined; + const rawGrep = { + pattern: "needle", + path: ".", + case: caseInsensitive === true ? false : undefined, + skip: piGrepSkip(undefined), + }; + expect(() => + validateToolArguments(grepTool, { type: "toolCall", id: "g1", name: "grep", arguments: rawGrep }), + ).toThrow(/case must be case-sensitive search \(was undefined\)/); + expect( + validateToolArguments(grepTool, { + type: "toolCall", + id: "g2", + name: "grep", + arguments: omitUndefinedArgs(rawGrep), + }), + ).toEqual({ pattern: "needle", path: "." }); + }); +}); diff --git a/packages/ai/test/cursor-streaming-args.test.ts b/packages/ai/test/cursor-streaming-args.test.ts index 4101a7457..b9be3de7d 100644 --- a/packages/ai/test/cursor-streaming-args.test.ts +++ b/packages/ai/test/cursor-streaming-args.test.ts @@ -412,11 +412,44 @@ describe("synthesizeCursorExecToolCall (issue #4348)", () => { type: "toolCall", id: "t2", name: "bash", - arguments: { command: "echo hi", cwd: undefined, timeout: undefined }, + // Undefined optional kwargs are dropped so ArkType optional-field + // validation does not reject the synthesized block. + arguments: { command: "echo hi" }, }); expect(t3).toMatchObject({ type: "text", text: "done" }); }); + it("omits undefined optional kwargs from synthesized exec tool args", () => { + const h = newHarness(); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "bash-1", "bash", { + command: "pwd", + cwd: undefined, + timeout: 30, + }); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "grep-1", "grep", { + pattern: "needle", + path: ".", + case: undefined, + skip: undefined, + }); + const [bashCall, grepCall] = h.output.content; + expect(bashCall).toMatchObject({ + type: "toolCall", + id: "bash-1", + name: "bash", + arguments: { command: "pwd", timeout: 30 }, + }); + expect(Object.hasOwn((bashCall as { arguments: object }).arguments, "cwd")).toBe(false); + expect(grepCall).toMatchObject({ + type: "toolCall", + id: "grep-1", + name: "grep", + arguments: { pattern: "needle", path: "." }, + }); + expect(Object.hasOwn((grepCall as { arguments: object }).arguments, "case")).toBe(false); + expect(Object.hasOwn((grepCall as { arguments: object }).arguments, "skip")).toBe(false); + }); + it("emits toolcall events at the exact index the block occupies in content", () => { const h = newHarness(); diff --git a/packages/ai/test/owned-stream-native-toolcall.test.ts b/packages/ai/test/owned-stream-native-toolcall.test.ts index ec0b7f357..1db464a74 100644 --- a/packages/ai/test/owned-stream-native-toolcall.test.ts +++ b/packages/ai/test/owned-stream-native-toolcall.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "bun:test"; import { wrapInbandToolStream } from "../src/dialect/owned-stream"; import type { AssistantMessage, AssistantMessageEvent, ThinkingContent, ToolCall, Usage } from "../src/types"; -import { getStreamingPartialJson, setStreamingPartialJson } from "../src/utils/block-symbols"; +import { + getStreamingPartialJson, + isCursorExecResolved, + kCursorExecResolved, + setStreamingPartialJson, +} from "../src/utils/block-symbols"; import { AssistantMessageEventStream } from "../src/utils/event-stream"; const TOOLS = [ @@ -299,6 +304,29 @@ describe("wrapInbandToolStream native tool-call passthrough", () => { expect(events).toContain("toolcall_end"); }); + it("preserves kCursorExecResolved across the owned/in-band projector", async () => { + // Cursor + tools.format: gemini wraps every provider stream in + // wrapInbandToolStream. The projector rebuilds toolCall objects + // field-by-field; dropping the exec-resolved marker lets agent-loop + // re-run a call Cursor already settled. + const inner = drive((push, out) => { + const block: ToolCall = { + type: "toolCall", + id: "cursor-bash-1", + name: "bash", + arguments: { command: "echo hi" }, + }; + (block as ToolCall & { [kCursorExecResolved]?: true })[kCursorExecResolved] = true; + out.content.push(block); + push({ type: "toolcall_start", contentIndex: 0, partial: out }); + push({ type: "toolcall_end", contentIndex: 0, toolCall: block, partial: out }); + }); + const { message } = await collect(wrapInbandToolStream(inner, TOOLS, "gemini")); + const calls = message.content.filter((b): b is ToolCall => b.type === "toolCall"); + expect(calls).toHaveLength(1); + expect(isCursorExecResolved(calls[0])).toBe(true); + }); + it("drops a nameless native ghost but keeps the real native call", async () => { const { message } = await collect(wrapInbandToolStream(ghostThenRealNative(), TOOLS, "gemini")); const calls = message.content.filter((b): b is ToolCall => b.type === "toolCall"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..5ab7a1955 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor provider sessions flooding bash/grep validation errors (`cwd`/`case`/`skip` "was undefined") when Cursor omitted optional exec-frame fields; the exec bridge now omits unset optional kwargs before tool execution and transcript synthesis. + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 9527fbd2e..158e025e9 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -18,6 +18,7 @@ import type { ToolResultMessage, } from "@oh-my-pi/pi-ai"; import { + omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, @@ -239,7 +240,11 @@ async function executeTool( return createToolResultMessage(toolCallId, toolName, result, true); } - options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args }); + // Same rule as synthesizeCursorExecToolCall: optional kwargs must be absent, + // not `undefined`, or ArkType validation rejects the call. + const toolArgs = omitUndefinedArgs(args); + + options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs }); let result: AgentToolResult; let isError = false; @@ -254,7 +259,7 @@ async function executeTool( type: "tool_execution_update", toolCallId, toolName, - args, + args: toolArgs, partialResult: sanitizedResult, }); } @@ -263,7 +268,7 @@ async function executeTool( try { result = await tool.execute( toolCallId, - args as Record, + toolArgs as Record, undefined, onUpdate, options.getToolContext?.(), @@ -509,11 +514,11 @@ export class CursorExecHandlers implements ICursorExecHandlers { } const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined; - const toolArgs: Record = { + const toolArgs = omitUndefinedArgs({ command: args.command, cwd: args.workingDirectory || undefined, timeout: timeoutSeconds, - }; + }); this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs }); diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 601d72cc8..434b3739b 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -1537,9 +1537,9 @@ describe("CursorExecHandlers Pi frame translation", () => { expect(calls).toEqual([ { pattern: "x", path: ".", case: false }, - // Case-sensitive is the local default, so `false` maps to "unset", - // not to `case: true`. - { pattern: "x", path: ".", case: undefined }, + // Case-sensitive is the local default, so `false` maps to unset — + // the key is omitted rather than written as `case: undefined`. + { pattern: "x", path: "." }, ]); }); diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index 5f6e9061f..03f69b633 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -118,7 +118,7 @@ function cursorTurn(): AgentMessage[] { type: "toolCall", id: "tc-bash", name: "bash", - arguments: { command: "ls -1", cwd: undefined, timeout: undefined }, + arguments: { command: "ls -1" }, }, ], api: "cursor-agent",