Skip to content

Commit b7ad6bd

Browse files
authored
feat: apply_patch tool for openai models (anomalyco#9127)
1 parent 10433cb commit b7ad6bd

16 files changed

Lines changed: 1122 additions & 496 deletions

File tree

packages/opencode/src/cli/cmd/debug/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export const AgentCommand = cmd({
7070
})
7171

7272
async function getAvailableTools(agent: Agent.Info) {
73-
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
74-
return ToolRegistry.tools(providerID, agent)
73+
const model = agent.model ?? (await Provider.defaultModel())
74+
return ToolRegistry.tools(model, agent)
7575
}
7676

7777
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
3939
import type { GrepTool } from "@/tool/grep"
4040
import type { ListTool } from "@/tool/ls"
4141
import type { EditTool } from "@/tool/edit"
42-
import type { PatchTool } from "@/tool/patch"
42+
import type { ApplyPatchTool } from "@/tool/apply_patch"
4343
import type { WebFetchTool } from "@/tool/webfetch"
4444
import type { TaskTool } from "@/tool/task"
4545
import type { QuestionTool } from "@/tool/question"
@@ -1445,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
14451445
<Match when={props.part.tool === "task"}>
14461446
<Task {...toolprops} />
14471447
</Match>
1448-
<Match when={props.part.tool === "patch"}>
1449-
<Patch {...toolprops} />
1448+
<Match when={props.part.tool === "apply_patch"}>
1449+
<ApplyPatch {...toolprops} />
14501450
</Match>
14511451
<Match when={props.part.tool === "todowrite"}>
14521452
<TodoWrite {...toolprops} />
@@ -1895,20 +1895,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
18951895
)
18961896
}
18971897

1898-
function Patch(props: ToolProps<typeof PatchTool>) {
1899-
const { theme } = useTheme()
1898+
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
1899+
const ctx = use()
1900+
const { theme, syntax } = useTheme()
1901+
1902+
const files = createMemo(() => props.metadata.files ?? [])
1903+
1904+
const view = createMemo(() => {
1905+
const diffStyle = ctx.sync.data.config.tui?.diff_style
1906+
if (diffStyle === "stacked") return "unified"
1907+
return ctx.width > 120 ? "split" : "unified"
1908+
})
1909+
1910+
function Diff(p: { diff: string; filePath: string }) {
1911+
return (
1912+
<box paddingLeft={1}>
1913+
<diff
1914+
diff={p.diff}
1915+
view={view()}
1916+
filetype={filetype(p.filePath)}
1917+
syntaxStyle={syntax()}
1918+
showLineNumbers={true}
1919+
width="100%"
1920+
wrapMode={ctx.diffWrapMode()}
1921+
fg={theme.text}
1922+
addedBg={theme.diffAddedBg}
1923+
removedBg={theme.diffRemovedBg}
1924+
contextBg={theme.diffContextBg}
1925+
addedSignColor={theme.diffHighlightAdded}
1926+
removedSignColor={theme.diffHighlightRemoved}
1927+
lineNumberFg={theme.diffLineNumber}
1928+
lineNumberBg={theme.diffContextBg}
1929+
addedLineNumberBg={theme.diffAddedLineNumberBg}
1930+
removedLineNumberBg={theme.diffRemovedLineNumberBg}
1931+
/>
1932+
</box>
1933+
)
1934+
}
1935+
1936+
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
1937+
if (file.type === "delete") return "# Deleted " + file.relativePath
1938+
if (file.type === "add") return "# Created " + file.relativePath
1939+
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
1940+
return "← Patched " + file.relativePath
1941+
}
1942+
19001943
return (
19011944
<Switch>
1902-
<Match when={props.output !== undefined}>
1903-
<BlockTool title="# Patch" part={props.part}>
1904-
<box>
1905-
<text fg={theme.text}>{props.output?.trim()}</text>
1906-
</box>
1907-
</BlockTool>
1945+
<Match when={files().length > 0}>
1946+
<For each={files()}>
1947+
{(file) => (
1948+
<BlockTool title={title(file)} part={props.part}>
1949+
<Show
1950+
when={file.type !== "delete"}
1951+
fallback={
1952+
<text fg={theme.diffRemoved}>
1953+
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
1954+
</text>
1955+
}
1956+
>
1957+
<Diff diff={file.diff} filePath={file.filePath} />
1958+
</Show>
1959+
</BlockTool>
1960+
)}
1961+
</For>
19081962
</Match>
19091963
<Match when={true}>
1910-
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
1911-
Patch
1964+
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
1965+
apply_patch
19121966
</InlineTool>
19131967
</Match>
19141968
</Switch>

packages/opencode/src/patch/index.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,18 @@ export namespace Patch {
177177
return { content, nextIdx: i }
178178
}
179179

180+
function stripHeredoc(input: string): string {
181+
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
182+
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
183+
if (heredocMatch) {
184+
return heredocMatch[2]
185+
}
186+
return input
187+
}
188+
180189
export function parsePatch(patchText: string): { hunks: Hunk[] } {
181-
const lines = patchText.split("\n")
190+
const cleaned = stripHeredoc(patchText.trim())
191+
const lines = cleaned.split("\n")
182192
const hunks: Hunk[] = []
183193
let i = 0
184194

@@ -363,15 +373,15 @@ export namespace Patch {
363373
// Try to match old lines in the file
364374
let pattern = chunk.old_lines
365375
let newSlice = chunk.new_lines
366-
let found = seekSequence(originalLines, pattern, lineIndex)
376+
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
367377

368378
// Retry without trailing empty line if not found
369379
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
370380
pattern = pattern.slice(0, -1)
371381
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
372382
newSlice = newSlice.slice(0, -1)
373383
}
374-
found = seekSequence(originalLines, pattern, lineIndex)
384+
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
375385
}
376386

377387
if (found !== -1) {
@@ -407,28 +417,75 @@ export namespace Patch {
407417
return result
408418
}
409419

410-
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
411-
if (pattern.length === 0) return -1
420+
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
421+
function normalizeUnicode(str: string): string {
422+
return str
423+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
424+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
425+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
426+
.replace(/\u2026/g, "...") // ellipsis
427+
.replace(/\u00A0/g, " ") // non-breaking space
428+
}
429+
430+
type Comparator = (a: string, b: string) => boolean
431+
432+
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
433+
// If EOF anchor, try matching from end of file first
434+
if (eof) {
435+
const fromEnd = lines.length - pattern.length
436+
if (fromEnd >= startIndex) {
437+
let matches = true
438+
for (let j = 0; j < pattern.length; j++) {
439+
if (!compare(lines[fromEnd + j], pattern[j])) {
440+
matches = false
441+
break
442+
}
443+
}
444+
if (matches) return fromEnd
445+
}
446+
}
412447

413-
// Simple substring search implementation
448+
// Forward search from startIndex
414449
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
415450
let matches = true
416-
417451
for (let j = 0; j < pattern.length; j++) {
418-
if (lines[i + j] !== pattern[j]) {
452+
if (!compare(lines[i + j], pattern[j])) {
419453
matches = false
420454
break
421455
}
422456
}
423-
424-
if (matches) {
425-
return i
426-
}
457+
if (matches) return i
427458
}
428459

429460
return -1
430461
}
431462

463+
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
464+
if (pattern.length === 0) return -1
465+
466+
// Pass 1: exact match
467+
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
468+
if (exact !== -1) return exact
469+
470+
// Pass 2: rstrip (trim trailing whitespace)
471+
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
472+
if (rstrip !== -1) return rstrip
473+
474+
// Pass 3: trim (both ends)
475+
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
476+
if (trim !== -1) return trim
477+
478+
// Pass 4: normalized (Unicode punctuation to ASCII)
479+
const normalized = tryMatch(
480+
lines,
481+
pattern,
482+
startIndex,
483+
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
484+
eof,
485+
)
486+
return normalized
487+
}
488+
432489
function generateUnifiedDiff(oldContent: string, newContent: string): string {
433490
const oldLines = oldContent.split("\n")
434491
const newLines = newContent.split("\n")

packages/opencode/src/server/routes/experimental.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() =>
7474
}),
7575
),
7676
async (c) => {
77-
const { provider } = c.req.valid("query")
78-
const tools = await ToolRegistry.tools(provider)
77+
const { provider, model } = c.req.valid("query")
78+
const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
7979
return c.json(
8080
tools.map((t) => ({
8181
id: t.id,

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,10 @@ export namespace SessionPrompt {
685685
},
686686
})
687687

688-
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
688+
for (const item of await ToolRegistry.tools(
689+
{ modelID: input.model.api.id, providerID: input.model.providerID },
690+
input.agent,
691+
)) {
689692
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
690693
tools[item.id] = tool({
691694
id: item.id as any,

packages/opencode/src/session/prompt/codex.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
55
## Editing constraints
66
- 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.
77
- Only add comments if they are necessary to make a non-obvious block easier to understand.
8+
- 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).
89

910
## Tool usage
1011
- Prefer specialized tools over shell for file operations:

0 commit comments

Comments
 (0)