Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/provider/runtime-interception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -875,7 +873,11 @@ function firstToolPayload(event: StreamJsonToolCallEvent): StreamJsonToolCallPay
}

function hasStreamContentArg(args: Record<string, unknown>): 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 {
Expand Down
1 change: 1 addition & 0 deletions src/provider/tool-schema-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const ARG_KEY_ALIASES = new Map<string, string>([
["data", "content"],
["payload", "content"],
["streamcontent", "content"],
["filetext", "content"],
["recursive", "force"],
["oldstring", "old_string"],
["newstring", "new_string"],
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/provider-runtime-interception.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/provider-tool-schema-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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([
[
Expand Down