From f2cbcacf00a26f7c2c26438ed35a801d9bc8d9f2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 12:54:29 -0400 Subject: [PATCH] fix(tool): align multiedit schema with single-file edits --- packages/opencode/src/tool/multiedit.ts | 17 +--- .../__snapshots__/parameters.test.ts.snap | 5 -- packages/opencode/test/tool/multiedit.test.ts | 78 +++++++++++++++++++ .../opencode/test/tool/parameters.test.ts | 2 +- 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/test/tool/multiedit.test.ts diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7454556cd6..3c0986fd55 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -10,7 +10,6 @@ export const Parameters = Schema.Struct({ edits: Schema.mutable( Schema.Array( Schema.Struct({ - filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), oldString: Schema.String.annotate({ description: "The text to replace" }), newString: Schema.String.annotate({ description: "The text to replace it with (must be different from oldString)", @@ -32,17 +31,10 @@ export const MultiEditTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: ( - params: { - filePath: string - edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> - }, - ctx: Tool.Context, - ) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const results = [] - for (const [, entry] of params.edits.entries()) { - const result = yield* edit.execute( + const results = yield* Effect.forEach(params.edits, (entry) => + edit.execute( { filePath: params.filePath, oldString: entry.oldString, @@ -51,8 +43,7 @@ export const MultiEditTool = Tool.define( }, ctx, ) - results.push(result) - } + ) return { title: path.relative(Instance.worktree, params.filePath), metadata: { diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index ea3f3262eb..1623acc6ad 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -228,10 +228,6 @@ exports[`tool parameters JSON Schema (wire shape) multiedit 1`] = ` "description": "Array of edit operations to perform sequentially on the file", "items": { "properties": { - "filePath": { - "description": "The absolute path to the file to modify", - "type": "string", - }, "newString": { "description": "The text to replace it with (must be different from oldString)", "type": "string", @@ -246,7 +242,6 @@ exports[`tool parameters JSON Schema (wire shape) multiedit 1`] = ` }, }, "required": [ - "filePath", "oldString", "newString", ], diff --git a/packages/opencode/test/tool/multiedit.test.ts b/packages/opencode/test/tool/multiedit.test.ts new file mode 100644 index 0000000000..9313fbd85e --- /dev/null +++ b/packages/opencode/test/tool/multiedit.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Format } from "../../src/format" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { MultiEditTool } from "../../src/tool/multiedit" +import { Truncate, Tool } from "../../src/tool" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const ctx = { + sessionID: SessionID.make("ses_test-multiedit-session"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect( + Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + CrossSpawnSpawner.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const init = Effect.fn("MultiEditToolTest.init")(function* () { + const info = yield* MultiEditTool + return yield* info.init() +}) + +const run = Effect.fn("MultiEditToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +describe("tool.multiedit", () => { + it.live("applies multiple edits to the same file in sequence", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filePath = path.join(dir, "file.txt") + yield* Effect.promise(() => fs.writeFile(filePath, "alpha\nbeta\ngamma\n", "utf-8")) + + yield* run({ + filePath, + edits: [ + { oldString: "alpha", newString: "delta" }, + { oldString: "gamma", newString: "omega" }, + ], + }) + + const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8")) + expect(content).toBe("delta\nbeta\nomega\n") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 92ef21a2f9..c2516deb73 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -179,7 +179,7 @@ describe("tool parameters", () => { test("accepts an edit entry", () => { const parsed = parse(MultiEdit, { filePath: "/a", - edits: [{ filePath: "/a", oldString: "x", newString: "y" }], + edits: [{ oldString: "x", newString: "y" }], }) expect(parsed.edits.length).toBe(1) })