refactor: rename apply_patch tool

This commit is contained in:
Dax Raad 2026-07-07 16:27:03 -04:00
commit 5db320b02d
22 changed files with 48 additions and 46 deletions

View file

@ -20,7 +20,7 @@ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{ {
name: "multi patch", name: "multi patch",
tool: "apply_patch", tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
}, },
] as const ] as const

View file

@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"), textPart(followingID, "Following incremental patch"),
], ],
{ completed: false }, { completed: false },
@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"running", "running",
{ files: [first.filePath, second.filePath] }, { files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } }, { metadata: { files: [first, second] } },
@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: [first.filePath, second.filePath, third.filePath] }, { files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } }, { metadata: { files: [first, second, third] } },

View file

@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID, messageID: assistantID,
type: "tool", type: "tool",
callID: `call_0000_${suffix}_patch`, callID: `call_0000_${suffix}_patch`,
tool: "apply_patch", tool: "patch",
state: { state: {
status: "completed", status: "completed",
input: { patchText: realisticPatch(index) }, input: { patchText: realisticPatch(index) },

View file

@ -131,7 +131,7 @@ function toolPart(
): MessagePart { ): MessagePart {
const metadata = const metadata =
metadataOverride ?? metadataOverride ??
(tool === "apply_patch" (tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@ -219,7 +219,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]

View file

@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([ assistantMessage([
toolPart( toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts"] }, { files: ["src/a.ts"] },
{ {

View file

@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
assistantMessage([ assistantMessage([
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: files.map((file) => file.filePath) }, { files: files.map((file) => file.filePath) },
{ metadata: { files } }, { metadata: { files } },

View file

@ -246,7 +246,7 @@ function editPart(id: string) {
function patchPart(id: string) { function patchPart(id: string) {
return toolPart( return toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts", "src/b.ts"] }, { files: ["src/a.ts", "src/b.ts"] },
{ {

View file

@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) => const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
) )
@ -90,7 +90,7 @@ function questionInput() {
function errorInput(tool: string) { function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" } if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] } if (tool === "patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" } if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" } if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" }

View file

@ -120,7 +120,7 @@ function toolPart(
outputLength = 160, outputLength = 160,
): MessagePart { ): MessagePart {
const metadata = const metadata =
tool === "apply_patch" tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@ -199,7 +199,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),

View file

@ -1245,7 +1245,7 @@ export function MessageTimeline(props: {
const value = row() const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool)
} }
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined let contentMeasureFrame: number | undefined

View file

@ -628,11 +628,11 @@ function emitEdit(state: State): void {
function emitPatch(state: State): void { function emitPatch(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts") const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "apply_patch", { const ref = make(state, "patch", {
patchText: "*** Begin Patch\n*** End Patch", patchText: "*** Begin Patch\n*** End Patch",
}) })
doneTool(state, ref, { doneTool(state, ref, {
title: "apply_patch", title: "patch",
output: "", output: "",
metadata: { metadata: {
files: [ files: [

View file

@ -119,7 +119,7 @@ type ToolName =
| "bash" | "bash"
| "write" | "write"
| "edit" | "edit"
| "apply_patch" | "patch"
| "batch" | "batch"
| "task" | "task"
| "todowrite" | "todowrite"
@ -1094,7 +1094,7 @@ const TOOL_RULES = {
}, },
permission: permEdit, permission: permEdit,
}, },
apply_patch: { patch: {
view: { view: {
output: false, output: false,
final: true, final: true,

View file

@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no
// TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add snapshots / undo after V2 snapshot design exists. // TODO: Add snapshots / undo after V2 snapshot design exists.
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits. // TODO: Design multi-file transactions / rollback if patch needs atomic edits.
// Until then, edits are sequential and report partial application. // Until then, edits are sequential and report partial application.
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.

View file

@ -41,7 +41,7 @@ Registrations are scoped:
## Permissions ## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.

View file

@ -12,7 +12,7 @@ import { Patch } from "../patch"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
export const name = "apply_patch" export const name = "patch"
export const Input = Schema.Struct({ export const Input = Schema.Struct({
patchText: Schema.String.annotate({ patchText: Schema.String.annotate({
@ -91,11 +91,11 @@ export const Plugin = {
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({ const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText), try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
}) })
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks) for (const hunk of hunks)

View file

@ -191,10 +191,10 @@ const registryLayer = Layer.effect(
const registration = entries.at(-1)?.registration const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration) if (registration) registrations.set(name, registration)
} }
// OpenAI/GPT models use apply_patch; every other model uses edit and write. // OpenAI/GPT models use patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) { for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if ( if (
wrongEditTool || wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) || (registration.deferred && !Flag.CODEMODE_ENABLED) ||

View file

@ -89,16 +89,16 @@ describe("ToolRegistry", () => {
read: make(), read: make(),
edit: make("edit"), edit: make("edit"),
write: make("edit"), write: make("edit"),
apply_patch: make("edit"), patch: make("edit"),
}) })
const names = (model: ToolRegistry.MaterializeInput["model"]) => const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service service
.materialize({ model }) .materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"])
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
}), }),
) )

View file

@ -26,7 +26,7 @@ const applyPatchToolNode = makeLocationNode({
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
}) })
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") const sessionID = SessionV2.ID.make("ses_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined let denyAction: string | undefined
let failRemoveTarget: string | undefined let failRemoveTarget: string | undefined
@ -132,10 +132,10 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({ const call = (patchText: string, id = "call-apply-patch") => ({
sessionID, sessionID,
...toolIdentity, ...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
}) })
// apply_patch is only materialized for OpenAI/GPT models. // patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" } const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) => const exists = (target: string) =>
@ -162,7 +162,7 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"apply_patch", "patch",
]) ])
const settled = yield* settleTool( const settled = yield* settleTool(
registry, registry,
@ -241,7 +241,7 @@ describe("ApplyPatchTool", () => {
), ),
model, model,
), ),
).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) ).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([]) expect(assertions).toEqual([])
}), }),

View file

@ -536,6 +536,7 @@ export function getToolInfo(
title: i18n.t("ui.messagePart.title.write"), title: i18n.t("ui.messagePart.title.write"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined, subtitle: input.filePath ? getFilename(input.filePath) : undefined,
} }
case "patch":
case "apply_patch": case "apply_patch":
return { return {
icon: "code-lines", icon: "code-lines",
@ -728,7 +729,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
function toolDefaultOpen(tool: string, shell = false, edit = false) { function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell if (tool === "bash") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
} }
export function partDefaultOpen(part: PartType, shell = false, edit = false) { export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@ -1449,7 +1450,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
} }
export function getTool(name: string) { export function getTool(name: string) {
return state[name]?.render return state[name === "apply_patch" ? "patch" : name]?.render
} }
export const ToolRegistry = { export const ToolRegistry = {
@ -2272,7 +2273,7 @@ ToolRegistry.register({
}) })
ToolRegistry.register({ ToolRegistry.register({
name: "apply_patch", name: "patch",
render(props) { render(props) {
const i18n = useI18n() const i18n = useI18n()
const fileComponent = useFileComponent() const fileComponent = useFileComponent()

View file

@ -5,7 +5,7 @@ const docs = `### Overview
Tool call failure summary styled like a tool trigger. Tool call failure summary styled like a tool trigger.
### API ### API
- Required: \`tool\` (tool id, e.g. apply_patch, bash) - Required: \`tool\` (tool id, e.g. patch, bash)
- Required: \`error\` (error string) - Required: \`error\` (error string)
### Behavior ### Behavior
@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger.
const samples = [ const samples = [
{ {
tool: "apply_patch", tool: "patch",
error: error:
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", "patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
}, },
{ {
tool: "bash", tool: "bash",
@ -62,13 +62,13 @@ export default {
}, },
}, },
args: { args: {
tool: "apply_patch", tool: "patch",
error: samples[0].error, error: samples[0].error,
}, },
argTypes: { argTypes: {
tool: { tool: {
control: "select", control: "select",
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
}, },
error: { error: {
control: "text", control: "text",

View file

@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch", webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch", websearch: "ui.tool.websearch",
bash: "ui.tool.shell", bash: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch", apply_patch: "ui.tool.patch",
question: "ui.tool.questions", question: "ui.tool.questions",
} }

View file

@ -1908,7 +1908,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "execute"}> <Match when={display() === "execute"}>
<Execute {...toolprops} /> <Execute {...toolprops} />
</Match> </Match>
<Match when={display() === "apply_patch"}> <Match when={display() === "patch"}>
<ApplyPatch {...toolprops} /> <ApplyPatch {...toolprops} />
</Match> </Match>
<Match when={display() === "todowrite"}> <Match when={display() === "todowrite"}>
@ -2737,7 +2737,7 @@ const toolDisplays = new Set([
"edit", "edit",
"subagent", "subagent",
"execute", "execute",
"apply_patch", "patch",
"todowrite", "todowrite",
"question", "question",
"skill", "skill",
@ -2746,7 +2746,7 @@ const toolDisplays = new Set([
export function toolDisplay(tool: string) { export function toolDisplay(tool: string) {
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views. // them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
return toolDisplays.has(normalized) ? normalized : "generic" return toolDisplays.has(normalized) ? normalized : "generic"
} }