Skip to content

Commit 929e660

Browse files
committed
mason: cut edit write over to tool_call
1 parent 62960fe commit 929e660

12 files changed

Lines changed: 557 additions & 389 deletions

File tree

packages/aft-bridge/src/bridge.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isPassiveCommand, PASSIVE_COMMAND_TIMEOUT_MS } from "./command-timeouts
88
import type { Logger, LogMeta } from "./logger.js";
99
import type { BgCompletion, StatusCompression } from "./protocol.js";
1010
import { parseStatusBarCounts, type StatusBarCounts } from "./status-bar.js";
11-
import type { ToolCallArguments, ToolCallResult } from "./transport.js";
11+
import type { ToolCallArguments, ToolCallOptions, ToolCallResult } from "./transport.js";
1212

1313
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
1414
const BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
@@ -565,11 +565,17 @@ export class BinaryBridge {
565565
sessionId: string | undefined,
566566
name: string,
567567
rawArgs: ToolCallArguments = {},
568-
options?: SendOptions,
568+
options?: ToolCallOptions,
569569
): Promise<ToolCallResult> {
570570
const params: Record<string, unknown> = { name, arguments: rawArgs };
571571
if (sessionId) params.session_id = sessionId;
572-
return (await this.send("tool_call", params, options)) as ToolCallResult;
572+
const { preview, ...sendOptions } = options ?? {};
573+
if (preview === true) params.preview = true;
574+
return (await this.send(
575+
"tool_call",
576+
params,
577+
Object.keys(sendOptions).length > 0 ? (sendOptions as SendOptions) : undefined,
578+
)) as ToolCallResult;
573579
}
574580

575581
private async sendWithVersionMismatchRetry(

packages/aft-bridge/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export type {
151151
AftTransport,
152152
AftTransportOptions,
153153
ToolCallArguments,
154+
ToolCallOptions,
154155
ToolCallResult,
155156
} from "./transport.js";
156157
// --- aft_zoom plain-text formatter (shared by both plugin hosts) ---

packages/aft-bridge/src/pool.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { homedir } from "node:os";
22

33
import { error, getActiveLogger, log } from "./active-logger.js";
4-
import { BinaryBridge, type BridgeOptions, type BridgeRequestOptions } from "./bridge.js";
4+
import { BinaryBridge, type BridgeOptions } from "./bridge.js";
55
import type { Logger, LogMeta } from "./logger.js";
66
import { canonicalizeProjectRoot } from "./project-identity.js";
7-
import type { ToolCallArguments, ToolCallResult } from "./transport.js";
7+
import type { ToolCallArguments, ToolCallOptions, ToolCallResult } from "./transport.js";
88

99
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // evict idle bridges after 30 minutes
1010
const DEFAULT_MAX_POOL_SIZE = 8;
@@ -246,7 +246,7 @@ export class BridgePool {
246246
runtime: BridgeToolCallRuntime,
247247
name: string,
248248
rawArgs: ToolCallArguments = {},
249-
options?: BridgeRequestOptions,
249+
options?: ToolCallOptions,
250250
): Promise<ToolCallResult> {
251251
return this.getBridge(projectRoot).toolCall(runtime.sessionID, name, rawArgs, options);
252252
}

packages/aft-bridge/src/transport.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface AftTransportOptions extends BridgeRequestOptions {
2222
markConfiguredOnSuccess?: boolean;
2323
}
2424

25+
export interface ToolCallOptions extends AftTransportOptions {
26+
/** Server-owned dry-run flag placed at the top level of the tool_call request. */
27+
preview?: boolean;
28+
}
29+
2530
export interface AftTransport<ToolCallContext = string | undefined> {
2631
/** Lifecycle and raw-command path; tool dispatch uses toolCall instead. */
2732
send(
@@ -38,6 +43,6 @@ export interface AftTransport<ToolCallContext = string | undefined> {
3843
context: ToolCallContext,
3944
name: string,
4045
rawArgs: ToolCallArguments,
41-
opts?: AftTransportOptions,
46+
opts?: ToolCallOptions,
4247
): Promise<ToolCallResult>;
4348
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/// <reference path="../../bun-test.d.ts" />
2+
3+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
4+
import { readFile, writeFile } from "node:fs/promises";
5+
import type { BridgePool } from "@cortexkit/aft-bridge";
6+
import type { ToolContext, ToolResult } from "@opencode-ai/plugin";
7+
import { hoistedTools } from "../../tools/hoisted.js";
8+
import type { PluginContext } from "../../types.js";
9+
import { mockAskDeny, noopAsk } from "../test-helpers";
10+
import {
11+
cleanupHarnesses,
12+
createHarness,
13+
type E2EHarness,
14+
type PreparedBinary,
15+
prepareBinary,
16+
readTextFile,
17+
} from "./helpers.js";
18+
19+
type MutationToolResult = {
20+
output: string;
21+
title?: string;
22+
metadata?: {
23+
filediff?: {
24+
file: string;
25+
before: string;
26+
after: string;
27+
additions: number;
28+
deletions: number;
29+
};
30+
};
31+
};
32+
33+
function createMockClient(): PluginContext["client"] {
34+
return { lsp: {}, find: {} } as PluginContext["client"];
35+
}
36+
37+
function createToolContext(h: E2EHarness, ask: ToolContext["ask"] = noopAsk): ToolContext {
38+
return {
39+
messageID: "edit-write-toolcall-e2e",
40+
agent: "test",
41+
directory: h.tempDir,
42+
worktree: h.tempDir,
43+
abort: new AbortController().signal,
44+
metadata: () => {},
45+
ask,
46+
} as ToolContext;
47+
}
48+
49+
function createTools(h: E2EHarness): ReturnType<typeof hoistedTools> {
50+
const pool = { getBridge: () => h.bridge } as unknown as BridgePool;
51+
const ctx: PluginContext = {
52+
pool,
53+
client: createMockClient(),
54+
config: {} as PluginContext["config"],
55+
storageDir: h.path(".aft-test-storage"),
56+
};
57+
return hoistedTools(ctx);
58+
}
59+
60+
function asMutationResult(result: ToolResult): MutationToolResult {
61+
if (typeof result === "string") {
62+
throw new Error(`expected object ToolResult, got string: ${result}`);
63+
}
64+
return result as MutationToolResult;
65+
}
66+
67+
async function executeEdit(
68+
h: E2EHarness,
69+
args: Record<string, unknown>,
70+
ask?: ToolContext["ask"],
71+
): Promise<MutationToolResult> {
72+
return asMutationResult(await createTools(h).edit.execute(args, createToolContext(h, ask)));
73+
}
74+
75+
async function executeWrite(
76+
h: E2EHarness,
77+
args: Record<string, unknown>,
78+
ask?: ToolContext["ask"],
79+
): Promise<MutationToolResult> {
80+
return asMutationResult(await createTools(h).write.execute(args, createToolContext(h, ask)));
81+
}
82+
83+
const initialBinary = await prepareBinary();
84+
const maybeDescribe = describe.skipIf(!initialBinary.binaryPath);
85+
86+
maybeDescribe("e2e hoisted edit/write tool_call cutover", () => {
87+
let preparedBinary: PreparedBinary = initialBinary;
88+
const harnesses: E2EHarness[] = [];
89+
90+
beforeAll(async () => {
91+
preparedBinary = await prepareBinary();
92+
});
93+
94+
afterEach(async () => {
95+
await cleanupHarnesses(harnesses);
96+
});
97+
98+
async function harness(): Promise<E2EHarness> {
99+
const created = await createHarness(preparedBinary);
100+
harnesses.push(created);
101+
return created;
102+
}
103+
104+
test("edit append mutates the file and returns server text", async () => {
105+
const h = await harness();
106+
await writeFile(h.path("append.txt"), "alpha\n");
107+
108+
const result = await executeEdit(h, { filePath: "append.txt", appendContent: "beta\n" });
109+
110+
expect(await readTextFile(h.path("append.txt"))).toBe("alpha\nbeta\n");
111+
expect(result.output).toBe("Edited (+1/-0).");
112+
});
113+
114+
test("edit oldString/newString mutates the file, returns text, and preserves UI filediff", async () => {
115+
const h = await harness();
116+
await writeFile(h.path("replace.ts"), "export const value = 1;\n");
117+
118+
const result = await executeEdit(h, {
119+
filePath: "replace.ts",
120+
oldString: "value = 1",
121+
newString: "value = 2",
122+
});
123+
124+
expect(await readTextFile(h.path("replace.ts"))).toBe("export const value = 2;\n");
125+
expect(result.output).toBe("Edited (+1/-1).");
126+
expect(result.metadata?.filediff?.file.endsWith("/replace.ts")).toBe(true);
127+
expect(result.metadata?.filediff).toMatchObject({
128+
before: "export const value = 1;\n",
129+
after: "export const value = 2;\n",
130+
additions: 1,
131+
deletions: 1,
132+
});
133+
});
134+
135+
test("edit symbol+content mutates the symbol and returns server text", async () => {
136+
const h = await harness();
137+
await writeFile(
138+
h.path("symbol.ts"),
139+
"export function greet(name: string): string {\n return `Hi, ${name}`;\n}\n",
140+
);
141+
142+
const result = await executeEdit(h, {
143+
filePath: "symbol.ts",
144+
symbol: "greet",
145+
content: "export function greet(name: string): string {\n return `Hello, ${name}`;\n}\n",
146+
});
147+
148+
expect(await readTextFile(h.path("symbol.ts"))).toContain("Hello");
149+
expect(result.output).toBe("Edited (+2/-1).");
150+
});
151+
152+
test("edit edits[] batch mutates all edits and returns server text", async () => {
153+
const h = await harness();
154+
await writeFile(h.path("batch.txt"), "one\ntwo\nthree\n");
155+
156+
const result = await executeEdit(h, {
157+
filePath: "batch.txt",
158+
edits: [
159+
{ oldString: "one", newString: "ONE" },
160+
{ startLine: 3, endLine: 3, content: "THREE" },
161+
],
162+
});
163+
164+
expect(await readTextFile(h.path("batch.txt"))).toBe("ONE\ntwo\nTHREE\n");
165+
expect(result.output).toBe("Edited (+2/-2, 2 edits).");
166+
});
167+
168+
test("write creates and overwrites files through tool_call", async () => {
169+
const h = await harness();
170+
171+
const create = await executeWrite(h, {
172+
filePath: "created.ts",
173+
content: "export const created = true;\n",
174+
});
175+
expect(await readTextFile(h.path("created.ts"))).toBe("export const created = true;\n");
176+
expect(create.output).toBe("Created new file.");
177+
178+
await writeFile(h.path("overwrite.ts"), "export const value = 1;\n");
179+
const overwrite = await executeWrite(h, {
180+
filePath: "overwrite.ts",
181+
content: "export const value = 2;\n",
182+
});
183+
expect(await readTextFile(h.path("overwrite.ts"))).toBe("export const value = 2;\n");
184+
expect(overwrite.output).toBe("File updated.");
185+
});
186+
187+
test("denied preview approval returns permission_denied and leaves the file unchanged", async () => {
188+
const h = await harness();
189+
await writeFile(h.path("denied.ts"), "export const value = 1;\n");
190+
191+
const result = await createTools(h).edit.execute(
192+
{ filePath: "denied.ts", oldString: "1", newString: "2" },
193+
createToolContext(h, mockAskDeny("Denied by test.")),
194+
);
195+
196+
expect(JSON.parse(result as string)).toMatchObject({
197+
success: false,
198+
code: "permission_denied",
199+
});
200+
expect(await readTextFile(h.path("denied.ts"))).toBe("export const value = 1;\n");
201+
});
202+
203+
test("edit oldString not found throws before mutating", async () => {
204+
const h = await harness();
205+
await writeFile(h.path("missing-match.ts"), "export const value = 1;\n");
206+
207+
await expect(
208+
executeEdit(h, {
209+
filePath: "missing-match.ts",
210+
oldString: "does not exist",
211+
newString: "replacement",
212+
}),
213+
).rejects.toThrow(/not found|match/i);
214+
expect(await readFile(h.path("missing-match.ts"), "utf8")).toBe("export const value = 1;\n");
215+
});
216+
});

packages/opencode-plugin/src/__tests__/e2e/format-on-edit-edit.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ maybeDescribe("e2e format_on_edit edit tool", () => {
109109
data = response;
110110
return response;
111111
},
112+
toolCall: async (
113+
sessionID: string | undefined,
114+
name: string,
115+
rawArgs: Record<string, unknown> = {},
116+
options?: Record<string, unknown>,
117+
) => {
118+
const response = await h.bridge.toolCall(sessionID, name, rawArgs, options);
119+
if (options?.preview !== true) data = response;
120+
return response;
121+
},
112122
}),
113123
} as unknown as PluginContext["pool"];
114124
const tools = hoistedTools(createPluginContext(pool, h.path(".storage")));
@@ -203,9 +213,8 @@ maybeDescribe("e2e format_on_edit edit tool", () => {
203213
expect(finalContent).not.toContain("export const z=3");
204214
expect(data.formatted).toBe(true);
205215
expect(data.format_skipped_reason).toBeUndefined();
206-
// Hoisted `edit` tool returns JSON-stringified Rust response, so the
207-
// `formatted: true` signal is in the JSON output (the human-readable
208-
// "Auto-formatted." string is only used by hoisted `write`).
216+
// The edit tool returns the server's summary text, which contains the
217+
// auto-formatting note indicated by the raw response's `formatted` field.
209218
expect(output).toContain("Auto-formatted.");
210219
});
211220

packages/opencode-plugin/src/__tests__/e2e/format-on-edit-write.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,16 @@ maybeDescribe("e2e format_on_edit write tools", () => {
185185
if (command === "write") data = response;
186186
return response;
187187
},
188+
toolCall: async (
189+
sessionID: string | undefined,
190+
name: string,
191+
rawArgs: Record<string, unknown> = {},
192+
options?: Record<string, unknown>,
193+
) => {
194+
const response = await h.bridge.toolCall(sessionID, name, rawArgs, options);
195+
if (name === "write" && options?.preview !== true) data = response;
196+
return response;
197+
},
188198
}),
189199
} as unknown as PluginContext["pool"];
190200
const tools = hoistedTools(createPluginContext(pool, h.path(".storage")));
@@ -204,6 +214,16 @@ maybeDescribe("e2e format_on_edit write tools", () => {
204214
if (command === "write") data = response;
205215
return response;
206216
},
217+
toolCall: async (
218+
sessionID: string | undefined,
219+
name: string,
220+
rawArgs: Record<string, unknown> = {},
221+
options?: Record<string, unknown>,
222+
) => {
223+
const response = await h.bridge.toolCall(sessionID, name, rawArgs, options);
224+
if (name === "write" && options?.preview !== true) data = response;
225+
return response;
226+
},
207227
}),
208228
} as unknown as PluginContext["pool"];
209229
const tools = aftPrefixedTools(createPluginContext(pool, h.path(".storage")));

0 commit comments

Comments
 (0)