Merge PR #8183: fix(cursor): omit undefined exec args and keep exec-resolved under owned dialects (@jairuspace)

This commit is contained in:
can1357
2026-08-11 15:06:16 +02:00
16 changed files with 296 additions and 15 deletions
+4
View File
@@ -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
+13 -3
View File
@@ -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;
}
}
}
+2
View File
@@ -6,6 +6,8 @@
- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107) by [@svperfecta](https://github.com/svperfecta)).
- Fixed Antigravity Flash turns hanging after successful response headers when the endpoint never emitted an SSE event; the provider now cancels the stalled body and fails over after 60 seconds while retaining the longer allowance for Pro reasoning starts.
- 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
+3
View File
@@ -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 {
@@ -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<T extends Record<string, unknown>>(
args: T,
): { [K in keyof T]?: Exclude<T[K], undefined> } {
const out: Record<string, unknown> = {};
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<T[K], undefined> };
}
+5 -1
View File
@@ -210,6 +210,7 @@ import {
buildPiWriteError,
buildPiWriteRejected,
buildPiWriteResult,
omitUndefinedArgs,
piEscapeRegexLiteral,
piGrepSkip,
piJoinPath,
@@ -3668,11 +3669,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,
@@ -74,6 +74,7 @@ import type { ToolResultMessage } from "../../types";
* and their translation are consumed together.
*/
export {
omitUndefinedArgs,
piEscapeRegexLiteral,
piGrepSkip,
piJoinPath,
+18
View File
@@ -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
@@ -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);
}
/**
+86
View File
@@ -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: "." });
});
});
+34 -1
View File
@@ -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();
@@ -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");
+1
View File
@@ -22,6 +22,7 @@
- Fixed timed-out LSP requests continuing to consume server CPU and block queued requests by sending `$/cancelRequest` ([#8116](https://github.com/can1357/oh-my-pi/issues/8116)).
- Fixed `shutdownAll()` leaving the configured LSP idle checker alive and preventing short-lived SDK hosts from exiting ([#8115](https://github.com/can1357/oh-my-pi/issues/8115)).
- Fixed terminal Mermaid borders and junctions using low-contrast UI chrome colors instead of the active theme's readable content color.
- 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
+10 -5
View File
@@ -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<unknown>;
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<string, unknown>,
toolArgs as Record<string, unknown>,
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<string, unknown> = {
const toolArgs = omitUndefinedArgs({
command: args.command,
cwd: args.workingDirectory || undefined,
timeout: timeoutSeconds,
};
});
this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs });
+64 -3
View File
@@ -699,6 +699,67 @@ describe("CursorExecHandlers error results", () => {
const end = events.find(event => event.type === "tool_execution_end");
expect(end?.isError).toBe(true);
});
it("omits unset optional kwargs from shellStream start events and execute args", async () => {
// shellStream bypasses executeTool(), so omitUndefinedArgs must be
// applied here directly — otherwise absent cwd/timeout become
// present-undefined and ArkType rejects the bash call.
const events: AgentEvent[] = [];
const executeArgs: Record<string, unknown>[] = [];
const bashTool: AgentTool = {
name: "bash",
label: "bash",
description: "records args",
parameters: type({ command: "string", "cwd?": "string", "timeout?": "number" }),
execute: async (_id, args) => {
executeArgs.push({ ...args });
return { content: [{ type: "text", text: "ok" }], details: {} };
},
};
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map([["bash", bashTool]]),
emitEvent: event => events.push(event),
});
await handlers.shellStream(
create(ShellArgsSchema, {
toolCallId: "call-shell-omit",
command: "echo hi",
// Proto string defaults to ""; the bridge maps that to undefined.
workingDirectory: "",
}),
{ onStdout: () => {}, onStderr: () => {} },
);
const start = events.find(event => event.type === "tool_execution_start");
expect(start?.type).toBe("tool_execution_start");
if (start?.type !== "tool_execution_start") throw new Error("expected tool_execution_start");
expect(start.args).toEqual({ command: "echo hi" });
expect(Object.hasOwn(start.args, "cwd")).toBe(false);
expect(Object.hasOwn(start.args, "timeout")).toBe(false);
expect(executeArgs).toHaveLength(1);
expect(executeArgs[0]).toEqual({ command: "echo hi" });
expect(Object.hasOwn(executeArgs[0]!, "cwd")).toBe(false);
expect(Object.hasOwn(executeArgs[0]!, "timeout")).toBe(false);
executeArgs.length = 0;
events.length = 0;
await handlers.shellStream(
create(ShellArgsSchema, {
toolCallId: "call-shell-keep",
command: "pwd",
workingDirectory: "/tmp",
timeout: 12,
}),
{ onStdout: () => {}, onStderr: () => {} },
);
const keepStart = events.find(event => event.type === "tool_execution_start");
expect(keepStart?.type).toBe("tool_execution_start");
if (keepStart?.type !== "tool_execution_start") throw new Error("expected tool_execution_start");
expect(keepStart.args).toEqual({ command: "pwd", cwd: "/tmp", timeout: 12 });
expect(executeArgs[0]).toEqual({ command: "pwd", cwd: "/tmp", timeout: 12 });
});
});
describe("CursorExecHandlers mounted tool bridge", () => {
@@ -1537,9 +1598,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: "." },
]);
});
@@ -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",