diff --git a/src/provider/runtime-interception.ts b/src/provider/runtime-interception.ts index 0fc3644..a3a2821 100644 --- a/src/provider/runtime-interception.ts +++ b/src/provider/runtime-interception.ts @@ -818,11 +818,9 @@ function detectSuspiciousStreamContentWrite( : typeof args.filePath === "string" ? args.filePath : null; - const content = typeof args.content === "string" - ? args.content - : typeof args.fileText === "string" - ? args.fileText - : null; + // Rerouted write args always carry `content` (see buildWriteArguments); a + // fileText payload has already been normalized to content by this point. + const content = typeof args.content === "string" ? args.content : null; if (!filePath || content === null || !existsSync(filePath)) { return null; } @@ -875,7 +873,11 @@ function firstToolPayload(event: StreamJsonToolCallEvent): StreamJsonToolCallPay } function hasStreamContentArg(args: Record): boolean { - return Object.keys(args).some((key) => key.toLowerCase().replace(/[^a-z0-9]/g, "") === "streamcontent"); + return Object.keys(args).some((key) => { + const token = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + // cursor-agent 2026.07.17 renamed the full-file body field: streamContent -> fileText. + return token === "streamcontent" || token === "filetext"; + }); } function countLogicalLines(value: string): number { diff --git a/src/provider/tool-schema-compat.ts b/src/provider/tool-schema-compat.ts index 7d96e67..46f2193 100644 --- a/src/provider/tool-schema-compat.ts +++ b/src/provider/tool-schema-compat.ts @@ -38,6 +38,7 @@ const ARG_KEY_ALIASES = new Map([ ["data", "content"], ["payload", "content"], ["streamcontent", "content"], + ["filetext", "content"], ["recursive", "force"], ["oldstring", "old_string"], ["newstring", "new_string"], diff --git a/tests/unit/provider-runtime-interception.test.ts b/tests/unit/provider-runtime-interception.test.ts index 4d958ee..eb3607e 100644 --- a/tests/unit/provider-runtime-interception.test.ts +++ b/tests/unit/provider-runtime-interception.test.ts @@ -684,6 +684,46 @@ describe("provider runtime interception fallback", () => { expect(readFileSync(target, "utf-8").split("\n").slice(47, 52)).toEqual(["48", "49", "50", "51", "52"]); }); + it("skips suspicious fileText write reroutes that would shrink an existing file", async () => { + // cursor-agent 2026.07.17 renamed the full-file body field to fileText; + // the overwrite guard must apply to those payloads too. + const projectDir = mkdtempSync(join(tmpdir(), "runtime-file-text-shrink-")); + const target = join(projectDir, "test.txt"); + writeFileSync(target, Array.from({ length: 100 }, (_, index) => String(index + 1)).join("\n") + "\n"); + const toolResults: any[] = []; + let interceptedCount = 0; + const result = await handleToolLoopEventV1({ + ...createBaseOptions({ + event: { + type: "tool_call", + call_id: "c_edit_path_file_text_shrink", + tool_call: { + editToolCall: { + args: { + path: target, + fileText: "49\ntest\n51", + }, + }, + }, + } as any, + allowedToolNames: new Set(["edit", "write"]), + toolSchemaMap: OPENCODE_EDIT_WRITE_SCHEMA_MAP, + onToolResult: async (toolResult) => { + toolResults.push(toolResult); + }, + onInterceptedToolCall: async () => { + interceptedCount += 1; + }, + }), + boundary: createProviderBoundary("v1", "cursor-acp"), + }); + + expect(result).toEqual({ intercepted: false, skipConverter: true }); + expect(interceptedCount).toBe(0); + expect(toolResults).toHaveLength(0); + expect(readFileSync(target, "utf-8").split("\n").slice(47, 52)).toEqual(["48", "49", "50", "51", "52"]); + }); + it("records completed Cursor-owned edits when skipping suspicious streamContent reroutes", async () => { const projectDir = mkdtempSync(join(tmpdir(), "runtime-cursor-owned-edit-")); const target = join(projectDir, "test.txt"); diff --git a/tests/unit/provider-tool-schema-compat.test.ts b/tests/unit/provider-tool-schema-compat.test.ts index 63989f6..36b1617 100644 --- a/tests/unit/provider-tool-schema-compat.test.ts +++ b/tests/unit/provider-tool-schema-compat.test.ts @@ -162,6 +162,42 @@ describe("tool schema compatibility", () => { expect(result.validation.ok).toBe(true); }); + it("normalizes fileText (cursor-agent full-file content) to content", () => { + // cursor-agent 2026.07.17 carries full-file edit/write bodies under `fileText` + // (it replaced the earlier `streamContent` field). Without this alias the body + // is dropped and the write/edit reroute loses the content. + const result = applyToolSchemaCompat( + { + id: "c1", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ + filePath: "/tmp/a.txt", + fileText: "hello world", + }), + }, + }, + new Map([ + [ + "write", + { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + ], + ]), + ); + + expect(result.normalizedArgs.content).toBe("hello world"); + expect(result.normalizedArgs.fileText).toBeUndefined(); + expect(result.validation.ok).toBe(true); + }); + it("normalizes write path to filePath when schema requires filePath", () => { const result = applyToolSchemaCompat( { @@ -895,6 +931,30 @@ describe("tool schema compatibility", () => { expect(args.path).toBeUndefined(); }); + it("tryRerouteEditToWrite handles opencode path plus fileText edit payloads", () => { + // cursor-agent 2026.07.17 emits full-file edit bodies under `fileText`; + // the alias must carry the content through the edit-to-write reroute. + const toolSchemaMap = buildOpencodeEditWriteSchemaMap(); + const call = editToolCall({ + path: "/tmp/x", + fileText: "49\ntest\n51", + }); + const compat = applyToolSchemaCompat(call, toolSchemaMap); + const rerouted = tryRerouteEditToWrite( + call, + compat, + new Set(["edit", "write"]), + toolSchemaMap, + ); + + expect(compat.validation.missing).toEqual(["oldString"]); + expect(rerouted?.function.name).toBe("write"); + const args = JSON.parse(rerouted?.function.arguments ?? "{}"); + expect(args.filePath).toBe("/tmp/x"); + expect(args.content).toBe("49\ntest\n51"); + expect(args.path).toBeUndefined(); + }); + it("tryRerouteEditToWrite uses oc_write when fallback tools are active", () => { const toolSchemaMap = new Map([ [