feat: apply_patch tool for openai models (#9127)
This commit is contained in:
@@ -70,8 +70,8 @@ export const AgentCommand = cmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function getAvailableTools(agent: Agent.Info) {
|
async function getAvailableTools(agent: Agent.Info) {
|
||||||
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
|
const model = agent.model ?? (await Provider.defaultModel())
|
||||||
return ToolRegistry.tools(providerID, agent)
|
return ToolRegistry.tools(model, agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
|
|||||||
import type { GrepTool } from "@/tool/grep"
|
import type { GrepTool } from "@/tool/grep"
|
||||||
import type { ListTool } from "@/tool/ls"
|
import type { ListTool } from "@/tool/ls"
|
||||||
import type { EditTool } from "@/tool/edit"
|
import type { EditTool } from "@/tool/edit"
|
||||||
import type { PatchTool } from "@/tool/patch"
|
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
||||||
import type { WebFetchTool } from "@/tool/webfetch"
|
import type { WebFetchTool } from "@/tool/webfetch"
|
||||||
import type { TaskTool } from "@/tool/task"
|
import type { TaskTool } from "@/tool/task"
|
||||||
import type { QuestionTool } from "@/tool/question"
|
import type { QuestionTool } from "@/tool/question"
|
||||||
@@ -1445,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
|||||||
<Match when={props.part.tool === "task"}>
|
<Match when={props.part.tool === "task"}>
|
||||||
<Task {...toolprops} />
|
<Task {...toolprops} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.part.tool === "patch"}>
|
<Match when={props.part.tool === "apply_patch"}>
|
||||||
<Patch {...toolprops} />
|
<ApplyPatch {...toolprops} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.part.tool === "todowrite"}>
|
<Match when={props.part.tool === "todowrite"}>
|
||||||
<TodoWrite {...toolprops} />
|
<TodoWrite {...toolprops} />
|
||||||
@@ -1895,20 +1895,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Patch(props: ToolProps<typeof PatchTool>) {
|
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
const { theme } = useTheme()
|
const ctx = use()
|
||||||
|
const { theme, syntax } = useTheme()
|
||||||
|
|
||||||
|
const files = createMemo(() => props.metadata.files ?? [])
|
||||||
|
|
||||||
|
const view = createMemo(() => {
|
||||||
|
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||||
|
if (diffStyle === "stacked") return "unified"
|
||||||
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
|
})
|
||||||
|
|
||||||
|
function Diff(p: { diff: string; filePath: string }) {
|
||||||
|
return (
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<diff
|
||||||
|
diff={p.diff}
|
||||||
|
view={view()}
|
||||||
|
filetype={filetype(p.filePath)}
|
||||||
|
syntaxStyle={syntax()}
|
||||||
|
showLineNumbers={true}
|
||||||
|
width="100%"
|
||||||
|
wrapMode={ctx.diffWrapMode()}
|
||||||
|
fg={theme.text}
|
||||||
|
addedBg={theme.diffAddedBg}
|
||||||
|
removedBg={theme.diffRemovedBg}
|
||||||
|
contextBg={theme.diffContextBg}
|
||||||
|
addedSignColor={theme.diffHighlightAdded}
|
||||||
|
removedSignColor={theme.diffHighlightRemoved}
|
||||||
|
lineNumberFg={theme.diffLineNumber}
|
||||||
|
lineNumberBg={theme.diffContextBg}
|
||||||
|
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||||
|
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||||
|
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||||
|
if (file.type === "add") return "# Created " + file.relativePath
|
||||||
|
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||||
|
return "← Patched " + file.relativePath
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.output !== undefined}>
|
<Match when={files().length > 0}>
|
||||||
<BlockTool title="# Patch" part={props.part}>
|
<For each={files()}>
|
||||||
<box>
|
{(file) => (
|
||||||
<text fg={theme.text}>{props.output?.trim()}</text>
|
<BlockTool title={title(file)} part={props.part}>
|
||||||
</box>
|
<Show
|
||||||
</BlockTool>
|
when={file.type !== "delete"}
|
||||||
|
fallback={
|
||||||
|
<text fg={theme.diffRemoved}>
|
||||||
|
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Diff diff={file.diff} filePath={file.filePath} />
|
||||||
|
</Show>
|
||||||
|
</BlockTool>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
|
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
|
||||||
Patch
|
apply_patch
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -177,8 +177,18 @@ export namespace Patch {
|
|||||||
return { content, nextIdx: i }
|
return { content, nextIdx: i }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripHeredoc(input: string): string {
|
||||||
|
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
|
||||||
|
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
|
||||||
|
if (heredocMatch) {
|
||||||
|
return heredocMatch[2]
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePatch(patchText: string): { hunks: Hunk[] } {
|
export function parsePatch(patchText: string): { hunks: Hunk[] } {
|
||||||
const lines = patchText.split("\n")
|
const cleaned = stripHeredoc(patchText.trim())
|
||||||
|
const lines = cleaned.split("\n")
|
||||||
const hunks: Hunk[] = []
|
const hunks: Hunk[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
@@ -363,7 +373,7 @@ export namespace Patch {
|
|||||||
// Try to match old lines in the file
|
// Try to match old lines in the file
|
||||||
let pattern = chunk.old_lines
|
let pattern = chunk.old_lines
|
||||||
let newSlice = chunk.new_lines
|
let newSlice = chunk.new_lines
|
||||||
let found = seekSequence(originalLines, pattern, lineIndex)
|
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||||
|
|
||||||
// Retry without trailing empty line if not found
|
// Retry without trailing empty line if not found
|
||||||
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
||||||
@@ -371,7 +381,7 @@ export namespace Patch {
|
|||||||
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
||||||
newSlice = newSlice.slice(0, -1)
|
newSlice = newSlice.slice(0, -1)
|
||||||
}
|
}
|
||||||
found = seekSequence(originalLines, pattern, lineIndex)
|
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found !== -1) {
|
if (found !== -1) {
|
||||||
@@ -407,28 +417,75 @@ export namespace Patch {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
|
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
|
||||||
if (pattern.length === 0) return -1
|
function normalizeUnicode(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
|
||||||
|
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
|
||||||
|
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
|
||||||
|
.replace(/\u2026/g, "...") // ellipsis
|
||||||
|
.replace(/\u00A0/g, " ") // non-breaking space
|
||||||
|
}
|
||||||
|
|
||||||
// Simple substring search implementation
|
type Comparator = (a: string, b: string) => boolean
|
||||||
|
|
||||||
|
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
|
||||||
|
// If EOF anchor, try matching from end of file first
|
||||||
|
if (eof) {
|
||||||
|
const fromEnd = lines.length - pattern.length
|
||||||
|
if (fromEnd >= startIndex) {
|
||||||
|
let matches = true
|
||||||
|
for (let j = 0; j < pattern.length; j++) {
|
||||||
|
if (!compare(lines[fromEnd + j], pattern[j])) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) return fromEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward search from startIndex
|
||||||
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
|
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
|
||||||
let matches = true
|
let matches = true
|
||||||
|
|
||||||
for (let j = 0; j < pattern.length; j++) {
|
for (let j = 0; j < pattern.length; j++) {
|
||||||
if (lines[i + j] !== pattern[j]) {
|
if (!compare(lines[i + j], pattern[j])) {
|
||||||
matches = false
|
matches = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (matches) return i
|
||||||
if (matches) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
|
||||||
|
if (pattern.length === 0) return -1
|
||||||
|
|
||||||
|
// Pass 1: exact match
|
||||||
|
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
|
||||||
|
if (exact !== -1) return exact
|
||||||
|
|
||||||
|
// Pass 2: rstrip (trim trailing whitespace)
|
||||||
|
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
|
||||||
|
if (rstrip !== -1) return rstrip
|
||||||
|
|
||||||
|
// Pass 3: trim (both ends)
|
||||||
|
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
|
||||||
|
if (trim !== -1) return trim
|
||||||
|
|
||||||
|
// Pass 4: normalized (Unicode punctuation to ASCII)
|
||||||
|
const normalized = tryMatch(
|
||||||
|
lines,
|
||||||
|
pattern,
|
||||||
|
startIndex,
|
||||||
|
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
|
||||||
|
eof,
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
||||||
const oldLines = oldContent.split("\n")
|
const oldLines = oldContent.split("\n")
|
||||||
const newLines = newContent.split("\n")
|
const newLines = newContent.split("\n")
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { provider } = c.req.valid("query")
|
const { provider, model } = c.req.valid("query")
|
||||||
const tools = await ToolRegistry.tools(provider)
|
const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
|
||||||
return c.json(
|
return c.json(
|
||||||
tools.map((t) => ({
|
tools.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|||||||
@@ -685,7 +685,10 @@ export namespace SessionPrompt {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
|
for (const item of await ToolRegistry.tools(
|
||||||
|
{ modelID: input.model.api.id, providerID: input.model.providerID },
|
||||||
|
input.agent,
|
||||||
|
)) {
|
||||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||||
tools[item.id] = tool({
|
tools[item.id] = tool({
|
||||||
id: item.id as any,
|
id: item.id as any,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
|||||||
## Editing constraints
|
## Editing constraints
|
||||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||||
|
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||||
|
|
||||||
## Tool usage
|
## Tool usage
|
||||||
- Prefer specialized tools over shell for file operations:
|
- Prefer specialized tools over shell for file operations:
|
||||||
|
|||||||
277
packages/opencode/src/tool/apply_patch.ts
Normal file
277
packages/opencode/src/tool/apply_patch.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import z from "zod"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as fs from "fs/promises"
|
||||||
|
import { Tool } from "./tool"
|
||||||
|
import { FileTime } from "../file/time"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { FileWatcher } from "../file/watcher"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Patch } from "../patch"
|
||||||
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
import { trimDiff } from "./edit"
|
||||||
|
import { LSP } from "../lsp"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
|
const PatchParams = z.object({
|
||||||
|
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||||
|
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
||||||
|
parameters: PatchParams,
|
||||||
|
async execute(params, ctx) {
|
||||||
|
if (!params.patchText) {
|
||||||
|
throw new Error("patchText is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the patch to get hunks
|
||||||
|
let hunks: Patch.Hunk[]
|
||||||
|
try {
|
||||||
|
const parseResult = Patch.parsePatch(params.patchText)
|
||||||
|
hunks = parseResult.hunks
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`apply_patch verification failed: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hunks.length === 0) {
|
||||||
|
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||||
|
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||||
|
throw new Error("patch rejected: empty patch")
|
||||||
|
}
|
||||||
|
throw new Error("apply_patch verification failed: no hunks found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file paths and check permissions
|
||||||
|
const fileChanges: Array<{
|
||||||
|
filePath: string
|
||||||
|
oldContent: string
|
||||||
|
newContent: string
|
||||||
|
type: "add" | "update" | "delete" | "move"
|
||||||
|
movePath?: string
|
||||||
|
diff: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
let totalDiff = ""
|
||||||
|
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||||
|
await assertExternalDirectory(ctx, filePath)
|
||||||
|
|
||||||
|
switch (hunk.type) {
|
||||||
|
case "add": {
|
||||||
|
const oldContent = ""
|
||||||
|
const newContent =
|
||||||
|
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||||
|
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||||
|
|
||||||
|
let additions = 0
|
||||||
|
let deletions = 0
|
||||||
|
for (const change of diffLines(oldContent, newContent)) {
|
||||||
|
if (change.added) additions += change.count || 0
|
||||||
|
if (change.removed) deletions += change.count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fileChanges.push({
|
||||||
|
filePath,
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
type: "add",
|
||||||
|
diff,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
})
|
||||||
|
|
||||||
|
totalDiff += diff + "\n"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "update": {
|
||||||
|
// Check if file exists for update
|
||||||
|
const stats = await fs.stat(filePath).catch(() => null)
|
||||||
|
if (!stats || stats.isDirectory()) {
|
||||||
|
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file and update time tracking (like edit tool does)
|
||||||
|
await FileTime.assert(ctx.sessionID, filePath)
|
||||||
|
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
let newContent = oldContent
|
||||||
|
|
||||||
|
// Apply the update chunks to get new content
|
||||||
|
try {
|
||||||
|
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||||
|
newContent = fileUpdate.content
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`apply_patch verification failed: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||||
|
|
||||||
|
let additions = 0
|
||||||
|
let deletions = 0
|
||||||
|
for (const change of diffLines(oldContent, newContent)) {
|
||||||
|
if (change.added) additions += change.count || 0
|
||||||
|
if (change.removed) deletions += change.count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||||
|
await assertExternalDirectory(ctx, movePath)
|
||||||
|
|
||||||
|
fileChanges.push({
|
||||||
|
filePath,
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
type: hunk.move_path ? "move" : "update",
|
||||||
|
movePath,
|
||||||
|
diff,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
})
|
||||||
|
|
||||||
|
totalDiff += diff + "\n"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete": {
|
||||||
|
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
|
||||||
|
throw new Error(`apply_patch verification failed: ${error}`)
|
||||||
|
})
|
||||||
|
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||||
|
|
||||||
|
const deletions = contentToDelete.split("\n").length
|
||||||
|
|
||||||
|
fileChanges.push({
|
||||||
|
filePath,
|
||||||
|
oldContent: contentToDelete,
|
||||||
|
newContent: "",
|
||||||
|
type: "delete",
|
||||||
|
diff: deleteDiff,
|
||||||
|
additions: 0,
|
||||||
|
deletions,
|
||||||
|
})
|
||||||
|
|
||||||
|
totalDiff += deleteDiff + "\n"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions if needed
|
||||||
|
await ctx.ask({
|
||||||
|
permission: "edit",
|
||||||
|
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
|
||||||
|
always: ["*"],
|
||||||
|
metadata: {
|
||||||
|
diff: totalDiff,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply the changes
|
||||||
|
const changedFiles: string[] = []
|
||||||
|
|
||||||
|
for (const change of fileChanges) {
|
||||||
|
switch (change.type) {
|
||||||
|
case "add":
|
||||||
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||||
|
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
||||||
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||||
|
changedFiles.push(change.filePath)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "update":
|
||||||
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||||
|
changedFiles.push(change.filePath)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "move":
|
||||||
|
if (change.movePath) {
|
||||||
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||||
|
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
||||||
|
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||||
|
await fs.unlink(change.filePath)
|
||||||
|
changedFiles.push(change.movePath)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
await fs.unlink(change.filePath)
|
||||||
|
changedFiles.push(change.filePath)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file time tracking
|
||||||
|
FileTime.read(ctx.sessionID, change.filePath)
|
||||||
|
if (change.movePath) {
|
||||||
|
FileTime.read(ctx.sessionID, change.movePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish file change events
|
||||||
|
for (const filePath of changedFiles) {
|
||||||
|
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify LSP of file changes and collect diagnostics
|
||||||
|
for (const change of fileChanges) {
|
||||||
|
if (change.type === "delete") continue
|
||||||
|
const target = change.movePath ?? change.filePath
|
||||||
|
await LSP.touchFile(target, true)
|
||||||
|
}
|
||||||
|
const diagnostics = await LSP.diagnostics()
|
||||||
|
|
||||||
|
// Generate output summary
|
||||||
|
const summaryLines = fileChanges.map((change) => {
|
||||||
|
if (change.type === "add") {
|
||||||
|
return `A ${path.relative(Instance.worktree, change.filePath)}`
|
||||||
|
}
|
||||||
|
if (change.type === "delete") {
|
||||||
|
return `D ${path.relative(Instance.worktree, change.filePath)}`
|
||||||
|
}
|
||||||
|
const target = change.movePath ?? change.filePath
|
||||||
|
return `M ${path.relative(Instance.worktree, target)}`
|
||||||
|
})
|
||||||
|
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||||
|
|
||||||
|
// Report LSP errors for changed files
|
||||||
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||||
|
for (const change of fileChanges) {
|
||||||
|
if (change.type === "delete") continue
|
||||||
|
const target = change.movePath ?? change.filePath
|
||||||
|
const normalized = Filesystem.normalizePath(target)
|
||||||
|
const issues = diagnostics[normalized] ?? []
|
||||||
|
const errors = issues.filter((item) => item.severity === 1)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||||
|
const suffix =
|
||||||
|
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||||
|
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-file metadata for UI rendering
|
||||||
|
const files = fileChanges.map((change) => ({
|
||||||
|
filePath: change.filePath,
|
||||||
|
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
|
||||||
|
type: change.type,
|
||||||
|
diff: change.diff,
|
||||||
|
before: change.oldContent,
|
||||||
|
after: change.newContent,
|
||||||
|
additions: change.additions,
|
||||||
|
deletions: change.deletions,
|
||||||
|
movePath: change.movePath,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: output,
|
||||||
|
metadata: {
|
||||||
|
diff: totalDiff,
|
||||||
|
files,
|
||||||
|
diagnostics,
|
||||||
|
},
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
1
packages/opencode/src/tool/apply_patch.txt
Normal file
1
packages/opencode/src/tool/apply_patch.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.
|
||||||
@@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
|||||||
const discardedCalls = params.tool_calls.slice(10)
|
const discardedCalls = params.tool_calls.slice(10)
|
||||||
|
|
||||||
const { ToolRegistry } = await import("./registry")
|
const { ToolRegistry } = await import("./registry")
|
||||||
const availableTools = await ToolRegistry.tools("")
|
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
|
||||||
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
|
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
|
||||||
|
|
||||||
const executeCall = async (call: (typeof toolCalls)[0]) => {
|
const executeCall = async (call: (typeof toolCalls)[0]) => {
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import * as path from "path"
|
|
||||||
import * as fs from "fs/promises"
|
|
||||||
import { Tool } from "./tool"
|
|
||||||
import { FileTime } from "../file/time"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Patch } from "../patch"
|
|
||||||
import { createTwoFilesPatch } from "diff"
|
|
||||||
import { assertExternalDirectory } from "./external-directory"
|
|
||||||
|
|
||||||
const PatchParams = z.object({
|
|
||||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const PatchTool = Tool.define("patch", {
|
|
||||||
description:
|
|
||||||
"Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
|
|
||||||
parameters: PatchParams,
|
|
||||||
async execute(params, ctx) {
|
|
||||||
if (!params.patchText) {
|
|
||||||
throw new Error("patchText is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the patch to get hunks
|
|
||||||
let hunks: Patch.Hunk[]
|
|
||||||
try {
|
|
||||||
const parseResult = Patch.parsePatch(params.patchText)
|
|
||||||
hunks = parseResult.hunks
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse patch: ${error}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hunks.length === 0) {
|
|
||||||
throw new Error("No file changes found in patch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file paths and check permissions
|
|
||||||
const fileChanges: Array<{
|
|
||||||
filePath: string
|
|
||||||
oldContent: string
|
|
||||||
newContent: string
|
|
||||||
type: "add" | "update" | "delete" | "move"
|
|
||||||
movePath?: string
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
let totalDiff = ""
|
|
||||||
|
|
||||||
for (const hunk of hunks) {
|
|
||||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
|
||||||
await assertExternalDirectory(ctx, filePath)
|
|
||||||
|
|
||||||
switch (hunk.type) {
|
|
||||||
case "add":
|
|
||||||
if (hunk.type === "add") {
|
|
||||||
const oldContent = ""
|
|
||||||
const newContent = hunk.contents
|
|
||||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
|
||||||
|
|
||||||
fileChanges.push({
|
|
||||||
filePath,
|
|
||||||
oldContent,
|
|
||||||
newContent,
|
|
||||||
type: "add",
|
|
||||||
})
|
|
||||||
|
|
||||||
totalDiff += diff + "\n"
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case "update":
|
|
||||||
// Check if file exists for update
|
|
||||||
const stats = await fs.stat(filePath).catch(() => null)
|
|
||||||
if (!stats || stats.isDirectory()) {
|
|
||||||
throw new Error(`File not found or is directory: ${filePath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read file and update time tracking (like edit tool does)
|
|
||||||
await FileTime.assert(ctx.sessionID, filePath)
|
|
||||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
|
||||||
let newContent = oldContent
|
|
||||||
|
|
||||||
// Apply the update chunks to get new content
|
|
||||||
try {
|
|
||||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
|
||||||
newContent = fileUpdate.content
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
|
||||||
|
|
||||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
|
||||||
await assertExternalDirectory(ctx, movePath)
|
|
||||||
|
|
||||||
fileChanges.push({
|
|
||||||
filePath,
|
|
||||||
oldContent,
|
|
||||||
newContent,
|
|
||||||
type: hunk.move_path ? "move" : "update",
|
|
||||||
movePath,
|
|
||||||
})
|
|
||||||
|
|
||||||
totalDiff += diff + "\n"
|
|
||||||
break
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
// Check if file exists for deletion
|
|
||||||
await FileTime.assert(ctx.sessionID, filePath)
|
|
||||||
const contentToDelete = await fs.readFile(filePath, "utf-8")
|
|
||||||
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
|
|
||||||
|
|
||||||
fileChanges.push({
|
|
||||||
filePath,
|
|
||||||
oldContent: contentToDelete,
|
|
||||||
newContent: "",
|
|
||||||
type: "delete",
|
|
||||||
})
|
|
||||||
|
|
||||||
totalDiff += deleteDiff + "\n"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permissions if needed
|
|
||||||
await ctx.ask({
|
|
||||||
permission: "edit",
|
|
||||||
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
|
|
||||||
always: ["*"],
|
|
||||||
metadata: {
|
|
||||||
diff: totalDiff,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apply the changes
|
|
||||||
const changedFiles: string[] = []
|
|
||||||
|
|
||||||
for (const change of fileChanges) {
|
|
||||||
switch (change.type) {
|
|
||||||
case "add":
|
|
||||||
// Create parent directories
|
|
||||||
const addDir = path.dirname(change.filePath)
|
|
||||||
if (addDir !== "." && addDir !== "/") {
|
|
||||||
await fs.mkdir(addDir, { recursive: true })
|
|
||||||
}
|
|
||||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
||||||
changedFiles.push(change.filePath)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "update":
|
|
||||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
||||||
changedFiles.push(change.filePath)
|
|
||||||
break
|
|
||||||
|
|
||||||
case "move":
|
|
||||||
if (change.movePath) {
|
|
||||||
// Create parent directories for destination
|
|
||||||
const moveDir = path.dirname(change.movePath)
|
|
||||||
if (moveDir !== "." && moveDir !== "/") {
|
|
||||||
await fs.mkdir(moveDir, { recursive: true })
|
|
||||||
}
|
|
||||||
// Write to new location
|
|
||||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
|
||||||
// Remove original
|
|
||||||
await fs.unlink(change.filePath)
|
|
||||||
changedFiles.push(change.movePath)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
await fs.unlink(change.filePath)
|
|
||||||
changedFiles.push(change.filePath)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update file time tracking
|
|
||||||
FileTime.read(ctx.sessionID, change.filePath)
|
|
||||||
if (change.movePath) {
|
|
||||||
FileTime.read(ctx.sessionID, change.movePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish file change events
|
|
||||||
for (const filePath of changedFiles) {
|
|
||||||
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate output summary
|
|
||||||
const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
|
|
||||||
const summary = `${fileChanges.length} files changed`
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: summary,
|
|
||||||
metadata: {
|
|
||||||
diff: totalDiff,
|
|
||||||
},
|
|
||||||
output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
do not use
|
|
||||||
@@ -26,6 +26,7 @@ import { Log } from "@/util/log"
|
|||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import { Truncate } from "./truncation"
|
import { Truncate } from "./truncation"
|
||||||
import { PlanExitTool, PlanEnterTool } from "./plan"
|
import { PlanExitTool, PlanEnterTool } from "./plan"
|
||||||
|
import { ApplyPatchTool } from "./apply_patch"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
@@ -108,6 +109,7 @@ export namespace ToolRegistry {
|
|||||||
WebSearchTool,
|
WebSearchTool,
|
||||||
CodeSearchTool,
|
CodeSearchTool,
|
||||||
SkillTool,
|
SkillTool,
|
||||||
|
ApplyPatchTool,
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||||
@@ -119,15 +121,28 @@ export namespace ToolRegistry {
|
|||||||
return all().then((x) => x.map((t) => t.id))
|
return all().then((x) => x.map((t) => t.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tools(providerID: string, agent?: Agent.Info) {
|
export async function tools(
|
||||||
|
model: {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
},
|
||||||
|
agent?: Agent.Info,
|
||||||
|
) {
|
||||||
const tools = await all()
|
const tools = await all()
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
tools
|
tools
|
||||||
.filter((t) => {
|
.filter((t) => {
|
||||||
// Enable websearch/codesearch for zen users OR via enable flag
|
// Enable websearch/codesearch for zen users OR via enable flag
|
||||||
if (t.id === "codesearch" || t.id === "websearch") {
|
if (t.id === "codesearch" || t.id === "websearch") {
|
||||||
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use apply tool in same format as codex
|
||||||
|
const usePatch =
|
||||||
|
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||||
|
if (t.id === "apply_patch") return usePatch
|
||||||
|
if (t.id === "edit" || t.id === "write") return !usePatch
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map(async (t) => {
|
.map(async (t) => {
|
||||||
|
|||||||
515
packages/opencode/test/tool/apply_patch.test.ts
Normal file
515
packages/opencode/test/tool/apply_patch.test.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import * as fs from "fs/promises"
|
||||||
|
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { FileTime } from "../../src/file/time"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const baseCtx = {
|
||||||
|
sessionID: "test",
|
||||||
|
messageID: "",
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type AskInput = {
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
always: string[]
|
||||||
|
metadata: { diff: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCtx = typeof baseCtx & {
|
||||||
|
ask: (input: AskInput) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||||
|
const tool = await ApplyPatchTool.init()
|
||||||
|
return tool.execute(params, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeCtx = () => {
|
||||||
|
const calls: AskInput[] = []
|
||||||
|
const ctx: ToolCtx = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (input) => {
|
||||||
|
calls.push(input)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ctx, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.apply_patch freeform", () => {
|
||||||
|
test("requires patchText", async () => {
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid patch format", async () => {
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects empty patch", async () => {
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
const emptyPatch = "*** Begin Patch\n*** End Patch"
|
||||||
|
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies add/update/delete in one patch", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx, calls } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const modifyPath = path.join(fixture.path, "modify.txt")
|
||||||
|
const deletePath = path.join(fixture.path, "delete.txt")
|
||||||
|
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||||
|
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, modifyPath)
|
||||||
|
FileTime.read(ctx.sessionID, deletePath)
|
||||||
|
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
const result = await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
expect(result.title).toContain("Success. Updated the following files")
|
||||||
|
expect(result.output).toContain("Success. Updated the following files")
|
||||||
|
expect(result.metadata.diff).toContain("Index:")
|
||||||
|
expect(calls.length).toBe(1)
|
||||||
|
|
||||||
|
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
|
||||||
|
expect(added).toBe("created\n")
|
||||||
|
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
|
||||||
|
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies multiple hunks to one file", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "multi.txt")
|
||||||
|
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("inserts lines with insert-only hunk", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "insert_only.txt")
|
||||||
|
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("appends trailing newline on update", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "no_newline.txt")
|
||||||
|
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
const contents = await fs.readFile(target, "utf-8")
|
||||||
|
expect(contents.endsWith("\n")).toBe(true)
|
||||||
|
expect(contents).toBe("first line\nsecond line\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves file to a new directory", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const original = path.join(fixture.path, "old", "name.txt")
|
||||||
|
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||||
|
await fs.writeFile(original, "old content\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, original)
|
||||||
|
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||||
|
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||||
|
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves file overwriting existing destination", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const original = path.join(fixture.path, "old", "name.txt")
|
||||||
|
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||||
|
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||||
|
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
await fs.writeFile(original, "from\n", "utf-8")
|
||||||
|
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, original)
|
||||||
|
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
|
||||||
|
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||||
|
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds file overwriting existing file", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "duplicate.txt")
|
||||||
|
await fs.writeFile(target, "old content\n", "utf-8")
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects update when target file is missing", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow(
|
||||||
|
"apply_patch verification failed: Failed to read file to update",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects delete when file is missing", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects delete when target is a directory", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const dirPath = path.join(fixture.path, "dir")
|
||||||
|
await fs.mkdir(dirPath)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid hunk header", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects update with missing context", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "modify.txt")
|
||||||
|
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("verification failure leaves no side effects", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||||
|
|
||||||
|
const createdPath = path.join(fixture.path, "created.txt")
|
||||||
|
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports end of file anchor", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "tail.txt")
|
||||||
|
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects missing second chunk context", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "two_chunks.txt")
|
||||||
|
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||||
|
|
||||||
|
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disambiguates change context with @@ header", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||||
|
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("EOF anchor matches from end of file first", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||||
|
// File has duplicate "marker" lines - one in middle, one at end
|
||||||
|
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
// First marker unchanged, second marker changed
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses heredoc-wrapped patch", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = `cat <<'EOF'
|
||||||
|
*** Begin Patch
|
||||||
|
*** Add File: heredoc_test.txt
|
||||||
|
+heredoc content
|
||||||
|
*** End Patch
|
||||||
|
EOF`
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
|
||||||
|
expect(content).toBe("heredoc content\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses heredoc-wrapped patch without cat", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = `<<EOF
|
||||||
|
*** Begin Patch
|
||||||
|
*** Add File: heredoc_no_cat.txt
|
||||||
|
+no cat prefix
|
||||||
|
*** End Patch
|
||||||
|
EOF`
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
|
||||||
|
expect(content).toBe("no cat prefix\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with trailing whitespace differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||||
|
// File has trailing spaces on some lines
|
||||||
|
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with leading whitespace differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "leading_ws.txt")
|
||||||
|
// File has leading spaces
|
||||||
|
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch without leading spaces - should match via trim pass
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with Unicode punctuation differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "unicode.txt")
|
||||||
|
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
|
||||||
|
const leftQuote = "\u201C"
|
||||||
|
const rightQuote = "\u201D"
|
||||||
|
const emDash = "\u2014"
|
||||||
|
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch uses ASCII equivalents - should match via normalized pass
|
||||||
|
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||||
|
const patchText =
|
||||||
|
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
// Result has ASCII quotes because that's what the patch specifies
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { PatchTool } from "../../src/tool/patch"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
import { PermissionNext } from "../../src/permission/next"
|
|
||||||
import * as fs from "fs/promises"
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
sessionID: "test",
|
|
||||||
messageID: "",
|
|
||||||
callID: "",
|
|
||||||
agent: "build",
|
|
||||||
abort: AbortSignal.any([]),
|
|
||||||
metadata: () => {},
|
|
||||||
ask: async () => {},
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchTool = await PatchTool.init()
|
|
||||||
|
|
||||||
describe("tool.patch", () => {
|
|
||||||
test("should validate required parameters", async () => {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: "/tmp",
|
|
||||||
fn: async () => {
|
|
||||||
expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should validate patch format", async () => {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: "/tmp",
|
|
||||||
fn: async () => {
|
|
||||||
expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle empty patch", async () => {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: "/tmp",
|
|
||||||
fn: async () => {
|
|
||||||
const emptyPatch = `*** Begin Patch
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.skip("should ask permission for files outside working directory", async () => {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: "/tmp",
|
|
||||||
fn: async () => {
|
|
||||||
const maliciousPatch = `*** Begin Patch
|
|
||||||
*** Add File: /etc/passwd
|
|
||||||
+malicious content
|
|
||||||
*** End Patch`
|
|
||||||
patchTool.execute({ patchText: maliciousPatch }, ctx)
|
|
||||||
// TODO: this sucks
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
const pending = await PermissionNext.list()
|
|
||||||
expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle simple add file operation", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
const patchText = `*** Begin Patch
|
|
||||||
*** Add File: test-file.txt
|
|
||||||
+Hello World
|
|
||||||
+This is a test file
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText }, ctx)
|
|
||||||
|
|
||||||
expect(result.title).toContain("files changed")
|
|
||||||
expect(result.metadata.diff).toBeDefined()
|
|
||||||
expect(result.output).toContain("Patch applied successfully")
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
const filePath = path.join(fixture.path, "test-file.txt")
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
|
||||||
expect(content).toBe("Hello World\nThis is a test file")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle file with context update", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
const patchText = `*** Begin Patch
|
|
||||||
*** Add File: config.js
|
|
||||||
+const API_KEY = "test-key"
|
|
||||||
+const DEBUG = false
|
|
||||||
+const VERSION = "1.0"
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText }, ctx)
|
|
||||||
|
|
||||||
expect(result.title).toContain("files changed")
|
|
||||||
expect(result.metadata.diff).toBeDefined()
|
|
||||||
expect(result.output).toContain("Patch applied successfully")
|
|
||||||
|
|
||||||
// Verify file was created with correct content
|
|
||||||
const filePath = path.join(fixture.path, "config.js")
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
|
||||||
expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle multiple file operations", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
const patchText = `*** Begin Patch
|
|
||||||
*** Add File: file1.txt
|
|
||||||
+Content of file 1
|
|
||||||
*** Add File: file2.txt
|
|
||||||
+Content of file 2
|
|
||||||
*** Add File: file3.txt
|
|
||||||
+Content of file 3
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText }, ctx)
|
|
||||||
|
|
||||||
expect(result.title).toContain("3 files changed")
|
|
||||||
expect(result.metadata.diff).toBeDefined()
|
|
||||||
expect(result.output).toContain("Patch applied successfully")
|
|
||||||
|
|
||||||
// Verify all files were created
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
const filePath = path.join(fixture.path, `file${i}.txt`)
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
|
||||||
expect(content).toBe(`Content of file ${i}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should create parent directories when adding nested files", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
const patchText = `*** Begin Patch
|
|
||||||
*** Add File: deep/nested/file.txt
|
|
||||||
+Deep nested content
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText }, ctx)
|
|
||||||
|
|
||||||
expect(result.title).toContain("files changed")
|
|
||||||
expect(result.output).toContain("Patch applied successfully")
|
|
||||||
|
|
||||||
// Verify nested file was created
|
|
||||||
const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt")
|
|
||||||
const exists = await fs
|
|
||||||
.access(nestedPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
expect(exists).toBe(true)
|
|
||||||
|
|
||||||
const content = await fs.readFile(nestedPath, "utf-8")
|
|
||||||
expect(content).toBe("Deep nested content")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should generate proper unified diff in metadata", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
// First create a file with simple content
|
|
||||||
const patchText1 = `*** Begin Patch
|
|
||||||
*** Add File: test.txt
|
|
||||||
+line 1
|
|
||||||
+line 2
|
|
||||||
+line 3
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
await patchTool.execute({ patchText: patchText1 }, ctx)
|
|
||||||
|
|
||||||
// Now create an update patch
|
|
||||||
const patchText2 = `*** Begin Patch
|
|
||||||
*** Update File: test.txt
|
|
||||||
@@
|
|
||||||
line 1
|
|
||||||
-line 2
|
|
||||||
+line 2 updated
|
|
||||||
line 3
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText: patchText2 }, ctx)
|
|
||||||
|
|
||||||
expect(result.metadata.diff).toBeDefined()
|
|
||||||
expect(result.metadata.diff).toContain("@@")
|
|
||||||
expect(result.metadata.diff).toContain("-line 2")
|
|
||||||
expect(result.metadata.diff).toContain("+line 2 updated")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should handle complex patch with multiple operations", async () => {
|
|
||||||
await using fixture = await tmpdir()
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: fixture.path,
|
|
||||||
fn: async () => {
|
|
||||||
const patchText = `*** Begin Patch
|
|
||||||
*** Add File: new.txt
|
|
||||||
+This is a new file
|
|
||||||
+with multiple lines
|
|
||||||
*** Add File: existing.txt
|
|
||||||
+old content
|
|
||||||
+new line
|
|
||||||
+more content
|
|
||||||
*** Add File: config.json
|
|
||||||
+{
|
|
||||||
+ "version": "1.0",
|
|
||||||
+ "debug": true
|
|
||||||
+}
|
|
||||||
*** End Patch`
|
|
||||||
|
|
||||||
const result = await patchTool.execute({ patchText }, ctx)
|
|
||||||
|
|
||||||
expect(result.title).toContain("3 files changed")
|
|
||||||
expect(result.metadata.diff).toBeDefined()
|
|
||||||
expect(result.output).toContain("Patch applied successfully")
|
|
||||||
|
|
||||||
// Verify all files were created
|
|
||||||
const newPath = path.join(fixture.path, "new.txt")
|
|
||||||
const newContent = await fs.readFile(newPath, "utf-8")
|
|
||||||
expect(newContent).toBe("This is a new file\nwith multiple lines")
|
|
||||||
|
|
||||||
const existingPath = path.join(fixture.path, "existing.txt")
|
|
||||||
const existingContent = await fs.readFile(existingPath, "utf-8")
|
|
||||||
expect(existingContent).toBe("old content\nnew line\nmore content")
|
|
||||||
|
|
||||||
const configPath = path.join(fixture.path, "config.json")
|
|
||||||
const configContent = await fs.readFile(configPath, "utf-8")
|
|
||||||
expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -689,3 +689,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="apply-patch-files"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="apply-patch-file"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--border-weaker-base);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: 1px solid var(--border-weaker-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="apply-patch-file-header"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--surface-inset-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="apply-patch-file-action"] {
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: var(--line-height-large);
|
||||||
|
color: var(--text-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&[data-type="delete"] {
|
||||||
|
color: var(--text-critical-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-type="add"] {
|
||||||
|
color: var(--text-success-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-type="move"] {
|
||||||
|
color: var(--text-warning-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="apply-patch-file-path"] {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--text-weak);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="apply-patch-deletion-count"] {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--text-critical-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="apply-patch-file-diff"] {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
|||||||
title: "Write",
|
title: "Write",
|
||||||
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
||||||
}
|
}
|
||||||
|
case "apply_patch":
|
||||||
|
return {
|
||||||
|
icon: "code-lines",
|
||||||
|
title: "Patch",
|
||||||
|
subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined,
|
||||||
|
}
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
return {
|
return {
|
||||||
icon: "checklist",
|
icon: "checklist",
|
||||||
@@ -1027,6 +1033,94 @@ ToolRegistry.register({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface ApplyPatchFile {
|
||||||
|
filePath: string
|
||||||
|
relativePath: string
|
||||||
|
type: "add" | "update" | "delete" | "move"
|
||||||
|
diff: string
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
movePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolRegistry.register({
|
||||||
|
name: "apply_patch",
|
||||||
|
render(props) {
|
||||||
|
const diffComponent = useDiffComponent()
|
||||||
|
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
|
||||||
|
|
||||||
|
const subtitle = createMemo(() => {
|
||||||
|
const count = files().length
|
||||||
|
if (count === 0) return ""
|
||||||
|
return `${count} file${count > 1 ? "s" : ""}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicTool
|
||||||
|
{...props}
|
||||||
|
icon="code-lines"
|
||||||
|
trigger={{
|
||||||
|
title: "Patch",
|
||||||
|
subtitle: subtitle(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={files().length > 0}>
|
||||||
|
<div data-component="apply-patch-files">
|
||||||
|
<For each={files()}>
|
||||||
|
{(file) => (
|
||||||
|
<div data-component="apply-patch-file">
|
||||||
|
<div data-slot="apply-patch-file-header">
|
||||||
|
<Switch>
|
||||||
|
<Match when={file.type === "delete"}>
|
||||||
|
<span data-slot="apply-patch-file-action" data-type="delete">
|
||||||
|
Deleted
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.type === "add"}>
|
||||||
|
<span data-slot="apply-patch-file-action" data-type="add">
|
||||||
|
Created
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.type === "move"}>
|
||||||
|
<span data-slot="apply-patch-file-action" data-type="move">
|
||||||
|
Moved
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.type === "update"}>
|
||||||
|
<span data-slot="apply-patch-file-action" data-type="update">
|
||||||
|
Patched
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<span data-slot="apply-patch-file-path">{file.relativePath}</span>
|
||||||
|
<Show when={file.type !== "delete"}>
|
||||||
|
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
|
||||||
|
</Show>
|
||||||
|
<Show when={file.type === "delete"}>
|
||||||
|
<span data-slot="apply-patch-deletion-count">-{file.deletions}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={file.type !== "delete"}>
|
||||||
|
<div data-component="apply-patch-file-diff">
|
||||||
|
<Dynamic
|
||||||
|
component={diffComponent}
|
||||||
|
before={{ name: file.filePath, contents: file.before }}
|
||||||
|
after={{ name: file.filePath, contents: file.after }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</BasicTool>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ToolRegistry.register({
|
ToolRegistry.register({
|
||||||
name: "todowrite",
|
name: "todowrite",
|
||||||
render(props) {
|
render(props) {
|
||||||
|
|||||||
Reference in New Issue
Block a user