feat(coding-agent/edit): add codex apply_patch as a new edit mode

Slots a new "apply_patch" variant alongside the existing edit modes
(replace, patch, hashline, chunk, vim). The mode accepts a single input
string containing a Codex *** Begin Patch / *** End Patch envelope,
parses it with a new lenient parser (heredoc-tolerant), and fans each
file-op out to the existing executePatchSingle so LSP writethrough,
plan-mode guards, fs-cache invalidation and diagnostics are shared
with the patch mode.

Exposes both tool shapes from the spec: the JSON function-tool variant
(§1.2, {input: string}) and the OpenAI custom-tool / Lark-grammar
"freeform" variant (§1.1, raw patch string). The edit tool advertises
a Lark grammar via customFormat and a wire name via customWireName;
openai-responses emits it as a grammar-constrained custom tool when a
model opts in with applyPatchToolType: "freeform" in models.json.
custom_tool_call / custom_tool_call_output are plumbed end-to-end
through the shared responses code (emission, streaming, history
replay), and the agent-loop dispatcher matches tool calls by either
name or customWireName so returned calls route correctly.

Also threads preview/diff rendering for apply_patch through the TUI
(tool-execution + edit renderer) so streaming patches show per-file
diffs like the other edit modes.

Default edit mode is unchanged (hashline); opt in via edit.mode or
PI_EDIT_VARIANT=apply_patch.
This commit is contained in:
Hans Josephsen
2026-04-18 22:17:53 +02:00
committed by can1357
parent ee05fb6c2d
commit 2a367bf043
23 changed files with 2468 additions and 221 deletions
+1
View File
@@ -8,6 +8,7 @@ saveTextLockfile = true
[loader]
".md" = "text"
".py" = "text"
".lark" = "text"
[run]
bun = true
+846
View File
@@ -0,0 +1,846 @@
# `apply_patch`: Codex Patch Format Specification
This document is a full, reimplementation-quality specification of the `apply_patch`
tool used by the Codex coding harness. It covers:
1. How the tool is exposed to the model (schema + freeform grammar variants).
2. The exact prompt / instruction text shown to the model.
3. The patch format grammar.
4. The parser (lexical rules, lenient mode, streaming mode, errors).
5. The application algorithm (Add / Delete / Update / Move), including the
`seek_sequence` fuzzy matcher.
6. Invocation forms the harness accepts (freeform args, JSON args, shell
heredoc wrappers, stdin).
7. Result presentation and error reporting.
8. Test-derived edge cases.
All normative behavior below is drawn from the `codex-rs/apply-patch` crate
(the parser in `src/parser.rs`, the applier in `src/lib.rs`, the matcher in
`src/seek_sequence.rs`) and the tool registration in `codex-rs/tools`.
---
## 1. Tool registration
`apply_patch` is registered in the tool registry when the current model's
`apply_patch_tool_type` metadata is set (see
`codex-rs/models-manager/models.json` — GPT-5.x variants use `"freeform"`;
older / non-reasoning models use `"function"`). It is registered as
`supports_parallel_tool_calls = false`, meaning the harness will not issue
two concurrent `apply_patch` calls. (Registration in
`codex-rs/tools/src/tool_registry_plan.rs`.)
There are two wire formats for the same underlying command.
### 1.1 Freeform (GPT-5 and later)
The freeform variant uses OpenAI's custom-tool mechanism: the model emits a
single opaque string whose shape is constrained by a Lark grammar.
```jsonc
{
"type": "custom",
"name": "apply_patch",
"description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "<see §3.2>"
}
}
```
(See `codex-rs/tools/src/apply_patch_tool.rs::create_apply_patch_freeform_tool`.)
The freeform call payload is the patch text itself — no JSON envelope, no
wrapping quotes. Example (the model types this verbatim as the tool's
"input"):
```
*** Begin Patch
*** Add File: hello.txt
+Hello, world!
*** End Patch
```
### 1.2 JSON function (legacy / gpt-oss)
For providers that only support function-style tool calls, a JSON variant is
registered:
```jsonc
{
"type": "function",
"name": "apply_patch",
"description": "<APPLY_PATCH_JSON_TOOL_DESCRIPTION, see §2>",
"strict": false,
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["input"],
"properties": {
"input": {
"type": "string",
"description": "The entire contents of the apply_patch command"
}
}
}
}
```
(See `codex-rs/tools/src/apply_patch_tool.rs::create_apply_patch_json_tool`.)
### 1.3 Handler dispatch
Both variants land in the same handler, which normalizes the arguments into
`ApplyPatchToolArgs { input: String }` and passes `input` to
`apply_patch::apply_patch(...)` (see
`codex-rs/core/src/tools/handlers/apply_patch.rs`). The `input` is the full
patch text, including the `*** Begin Patch` / `*** End Patch` envelope.
---
## 2. Agent prompt (verbatim)
The model is taught the format via two equivalent pieces of text: the
Markdown file `codex-rs/apply-patch/apply_patch_tool_instructions.md`
(embedded in system prompts), and the string constant
`APPLY_PATCH_JSON_TOOL_DESCRIPTION` in
`codex-rs/tools/src/apply_patch_tool.rs` which is used as the JSON tool's
`description`. They are identical in content.
A reimplementation SHOULD ship the following text verbatim (note: the
original uses Unicode curly quotes in a few places — they are reproduced
here; the final "smart quote" versus "typewriter quote" choice is not
semantic):
````markdown
## `apply_patch`
Use the `apply_patch` shell command to edit files.
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change's [context_after] lines in the second change's [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
You can invoke apply_patch like:
```
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
```
````
Notes for implementers:
- The "You can invoke apply_patch like ..." footer is only shown when the
patch command is exposed as a `shell` command (the `codex` fallback path).
When the freeform tool is registered, the model invokes the tool directly
and the footer is redundant but harmless.
- The prompt asserts some things the parser is lenient about in practice:
- *"File references can only be relative"* — the parser accepts absolute
paths, but the model is instructed to produce relative ones.
- *"+ line even when creating a new file"* — `+` MUST be the first
character of every content line in an Add File section.
---
## 3. Patch grammar
### 3.1 EBNF (canonical)
```
Patch := Begin { FileOp }+ End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" [NEWLINE]
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" text NEWLINE }+
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE
[ "*** Move to: " path NEWLINE ]
Change?
Change := (ChangeContext | ChangeLine)+ EofLine?
ChangeContext := ("@@" | "@@ " text) NEWLINE
ChangeLine := (" " | "-" | "+") text NEWLINE
EofLine := "*** End of File" NEWLINE
```
The Lark grammar that the OpenAI freeform tool uses to constrain model
output is in `codex-rs/tools/src/tool_apply_patch.lark`:
```lark
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
%import common.LF
```
### 3.2 Reserved tokens
| Token | Meaning |
| ---------------------------- | --------------------------------------------------------------- |
| `*** Begin Patch` | Required first significant line of the envelope. |
| `*** End Patch` | Required last significant line (trailing LF optional). |
| `*** Add File: <path>` | Start of an Add File section. |
| `*** Delete File: <path>` | Standalone Delete File directive. |
| `*** Update File: <path>` | Start of an Update File section. |
| `*** Move to: <path>` | Optional rename target, immediately after `*** Update File:`. |
| `@@` or `@@ <header>` | Starts a chunk inside an Update File. |
| `*** End of File` | Terminates a chunk; asserts the chunk ended at EOF. |
| `+<text>` / `-<text>` / ` <text>` | Added / deleted / context line inside a chunk. |
Action headers match **with a trailing space**: the parser uses literal
`strip_prefix("*** Add File: ")` etc. Everything after the space is the
path; no escaping, no quoting.
### 3.3 Lines and newlines
- Input is split on `\n` (LF only). CRLF is not supported by the parser —
producers MUST use LF. (Tool output goes through Rust string handling which
preserves CRs as literal bytes on the content lines, which then fail to
match.)
- Each content line in a hunk starts with exactly one byte (`+`, `-`, or
space) followed by the line's text and then a newline. An empty ` `-prefixed
line is representable as a single space followed by LF; a bare `+\n` is a
one-character added empty line.
- A completely blank line (no prefix byte at all) inside an Update File
section is **skipped**, and is used only for visual separation between
chunks. This is a deliberate leniency; do not rely on blank lines to carry
data.
---
## 4. Parser
Implementation: `codex-rs/apply-patch/src/parser.rs`.
### 4.1 Public entry points
- `parse_patch(text: &str) -> Result<ApplyPatchArgs, ParseError>` —
production parse. Uses `ParseMode::Lenient` (see §4.3).
- `parse_patch_streaming(text: &str) -> Result<ApplyPatchArgs, ParseError>` —
same format, but tolerates a missing `*** End Patch` (the harness calls
this from a streaming response handler to show progress). Its output
MUST NOT be used to actually apply the patch.
The result is:
```rust
pub struct ApplyPatchArgs {
pub patch: String, // canonicalized patch text (heredoc stripped)
pub hunks: Vec<Hunk>,
pub workdir: Option<String>, // populated only when parsing a shell
// invocation that begins with `cd <path> &&`
}
```
### 4.2 Lexical canonicalization
Before parsing, the input is `trim()`ed, then split into lines by `\n`.
Marker-line matching is done against `line.trim()`, so arbitrary
leading/trailing whitespace around the sentinel lines (`*** Begin Patch`,
etc.) is accepted. Content lines (those starting with `+`, `-`, ` `) are
NOT trimmed — their whitespace is significant.
### 4.3 Parse modes
The parser operates in one of three modes:
1. **Strict** — requires line 0 = `*** Begin Patch` and the last line to be
`*** End Patch`. Not used by the harness today (`PARSE_IN_STRICT_MODE =
false`); retained as a fallback.
2. **Lenient** (default). Tries strict first. If that fails, attempts to
strip a heredoc wrapper: the first line must be exactly one of
`<<EOF`, `<<'EOF'`, or `<<"EOF"`, and the last line must be `EOF`. The
inner region is then parsed strictly. This was introduced to handle
gpt-4.1, which insisted on wrapping the patch in a heredoc body.
Mismatched quotes (e.g. `<<"EOF'`) are rejected.
3. **Streaming** — requires `*** Begin Patch` but does NOT require
`*** End Patch`. Individual hunks are parsed on a best-effort basis; the
last incomplete hunk is dropped. Used for progress UI only.
### 4.4 Hunk parsing state machine
```
loop:
trim line[i]
if line[i] starts with "*** End Patch": break
match line[i].strip_prefix(...):
"*** Add File: " -> parse AddFile
"*** Delete File: " -> parse DeleteFile
"*** Update File: " -> parse UpdateFile
otherwise -> InvalidHunkError at line i
```
**AddFile** consumes subsequent lines while they start with `+`. Each line
becomes one element of `contents`, with the leading `+` stripped. The lines
are joined with `\n`; an additional `\n` is appended after the join so the
resulting file ends with exactly one newline.
**DeleteFile** consumes only the header line; no content follows.
**UpdateFile** consumes the header, optionally a `*** Move to: <path>` line,
then zero or more chunks. The UpdateFile section ends when the next line
starts with `***` (the next hunk header, or `*** End Patch`) or input is
exhausted. An UpdateFile section with zero chunks is an error
(`"Update file hunk for path '<p>' is empty"`).
**Chunk** parsing within an UpdateFile:
```
chunk:
optional context line:
"@@" -> change_context = None (empty marker)
"@@ <header>" -> change_context = Some("<header>")
otherwise -> if this is the FIRST chunk of the hunk, fall through
(context-less first chunk); otherwise error.
then:
loop over lines:
if line starts with "*" -> stop (end of chunk; next hunk or end)
if line == "" -> append empty string to BOTH old_lines
and new_lines (empty context line)
if line starts with ' ' -> append line[1..] to BOTH old_lines and
new_lines
if line starts with '-' -> append line[1..] to old_lines
if line starts with '+' -> append line[1..] to new_lines
if line == "*** End of File" -> set is_end_of_file = true, stop
otherwise -> error: unexpected line in update hunk
```
Invariants enforced by the parser:
- A chunk must contain at least one non-context line (pure context chunks
are rejected).
- `*** End of File` cannot be the first line of a chunk.
- Chunks are stored in order and the applier relies on each chunk's match
position being ≥ the previous chunk's match position.
### 4.5 Error taxonomy
```rust
pub enum ParseError {
InvalidPatchError(String), // envelope errors
InvalidHunkError { message: String, line_number: usize }, // content errors
}
```
Specific messages (implementers SHOULD match these literally so tests and
downstream tools keep working):
| Error | Condition |
| ------------------------------------ | ---------------------------------------------------------------------- |
| `The first line of the patch must be '*** Begin Patch'` | Missing/wrong `Begin` marker. |
| `The last line of the patch must be '*** End Patch'` | Missing/wrong `End` marker (strict/lenient only). |
| `'<line>' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'` | Unknown file-level directive. |
| `Update file hunk for path '<p>' is empty` | UpdateFile section has zero chunks. |
| `Expected update hunk to start with a @@ context marker, got: '<line>'` | Missing `@@` when required (non-first chunk). |
| `Update hunk does not contain any lines` | Chunk with a context but no +/-/space line before EOF/next chunk. |
| `Unexpected line found in update hunk: '<line>'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)` | Invalid diff prefix. |
---
## 5. Intermediate representation
```rust
pub enum Hunk {
AddFile { path: PathBuf, contents: String },
DeleteFile { path: PathBuf },
UpdateFile {
path: PathBuf,
move_path: Option<PathBuf>,
chunks: Vec<UpdateFileChunk>, // MUST be non-empty
},
}
pub struct UpdateFileChunk {
pub change_context: Option<String>, // text after "@@ "; None for bare "@@"
pub old_lines: Vec<String>, // lines to match in the file
pub new_lines: Vec<String>, // replacement lines
pub is_end_of_file: bool, // "*** End of File" present
}
```
Paths are stored exactly as the patch wrote them — no canonicalization.
Resolution to an absolute path happens at apply time via
`AbsolutePathBuf::resolve_path_against_base(path, cwd)`.
---
## 6. Application algorithm
Implementation: `codex-rs/apply-patch/src/lib.rs`.
### 6.1 Top-level flow
```
apply_patch(input, cwd, fs, sandbox):
args := parse_patch(input)?
if args.hunks is empty: error "No files were modified."
affected := { added: [], modified: [], deleted: [] }
for each hunk in args.hunks:
apply_hunk(hunk, cwd, fs, sandbox, affected)?
return affected
```
Hunks are applied **in the order they appear in the patch** and **not
atomically**: if hunk N fails, hunks `0..N-1` have already written to disk
and are not rolled back. Hunks `N+1..` are skipped. (Test scenario
`015_failure_after_partial_success_leaves_changes` pins this behavior.)
A reimplementation MAY add transactional semantics, but MUST document the
deviation — callers today rely on partial application being observable.
### 6.2 Add File
```
path_abs := resolve_path_against_base(hunk.path, cwd)
try: fs.write_file(path_abs, contents)
on NotFound: fs.create_directory(parent(path_abs), { recursive: true })
fs.write_file(path_abs, contents)
append hunk.path to affected.added
```
Characteristics:
- Parent directories are created on demand (recursive). This is done lazily
— only after a first write fails with `NotFound`.
- An existing file at `path_abs` is **silently overwritten** (scenario
`011_add_overwrites_existing_file`). No confirmation, no diff.
- `contents` is the literal joined string from the parser — the parser
already terminates it with `\n`.
### 6.3 Delete File
```
path_abs := resolve_path_against_base(hunk.path, cwd)
meta := fs.get_metadata(path_abs)
if meta.is_directory: error "path is a directory"
fs.remove(path_abs, { recursive: false, force: false })
append hunk.path to affected.deleted
```
- If the file does not exist, the metadata call returns `NotFound` and the
whole `apply_patch` fails with `Failed to delete file <p>: ...`.
- Deleting directories is explicitly rejected.
### 6.4 Update File
```
applied := derive_new_contents_from_chunks(path_abs, hunk.chunks, fs)?
if hunk.move_path.is_some():
dest_abs := resolve_path_against_base(move_path, cwd)
write_with_missing_parent_retry(dest_abs, applied.new_contents)
ensure source isn't a directory; fs.remove(path_abs)
else:
fs.write_file(path_abs, applied.new_contents)
append hunk.path to affected.modified
```
Note: a renamed file is reported as **modified** (`M`) with the original
path, not as `D` + `A`. This is intentional — it mirrors git's rename
detection.
`derive_new_contents_from_chunks`:
```
text := fs.read_file_text(path_abs) // utf-8, error if missing
lines := text.split('\n') // retains trailing '' if text ends '\n'
if lines.last() == "": lines.pop() // normalize off trailing-\n artifact
replacements := compute_replacements(lines, chunks)?
new_lines := apply_replacements(lines, replacements)
if new_lines.last() != "": new_lines.push("") // re-add trailing newline
return new_lines.join('\n')
```
Postcondition: **every update produces a file ending in exactly one `\n`**,
regardless of whether the input had one. Reimplementations MAY choose to
preserve "no trailing newline" when present — doing so is a deliberate
deviation.
### 6.5 Computing replacements
```
line_index := 0
replacements := []
for each chunk:
# 1. If the chunk has a "@@ ctx" marker, locate that line first.
if chunk.change_context.is_some():
idx := seek_sequence([ctx], lines, start=line_index, eof=false)?
line_index := idx + 1 # search for old_lines AFTER ctx
# 2. Pure-addition chunks (old_lines empty):
if chunk.old_lines.is_empty():
insertion_idx := (if lines.last() == "" then lines.len() - 1
else lines.len())
replacements.push((insertion_idx, 0, chunk.new_lines))
continue
# 3. Normal replacement: match old_lines in the file.
pattern := chunk.old_lines
new_slc := chunk.new_lines
found := seek_sequence(lines, pattern, line_index, chunk.is_end_of_file)
if found.is_none() and pattern.last() == "":
pattern := pattern[..pattern.len()-1] # drop trailing empty sentinel
if new_slc.last() == "":
new_slc := new_slc[..new_slc.len()-1]
found := seek_sequence(lines, pattern, line_index, chunk.is_end_of_file)
match found:
Some(start): replacements.push((start, pattern.len(), new_slc))
line_index := start + pattern.len()
None: error "Failed to find expected lines in <path>:\n<old_lines joined>"
replacements.sort_by_start_index()
```
Key invariants:
- Context (`@@ ctx`) is matched by *one line*; it is a locator only and is
never modified. After a successful context match, the old_lines search
begins **immediately after** the context line.
- A chunk's `old_lines` search starts at `line_index` (the cursor after the
previous chunk), so chunks must appear in file order.
- Pure-addition chunks (no `-` or ` ` lines, only `+`) append at the end of
the file. They do NOT honor `line_index`; they always go to the end.
- The "trailing empty sentinel" retry exists because unified-diff-style
tools often emit a blank line at the end of `old_lines` representing the
file's terminal newline. Our line-splitting strips that element from the
file, so a literal match fails; we retry with the sentinel removed.
Replacements are then applied **in reverse order of `start_index`** so
earlier edits do not shift later edits' indices:
```
apply_replacements(lines, replacements):
for (start, old_len, new_seg) in replacements.reversed():
delete lines[start .. start + old_len]
insert new_seg at position start
```
### 6.6 `seek_sequence` — the fuzzy matcher
Implementation: `codex-rs/apply-patch/src/seek_sequence.rs`.
Signature:
```rust
fn seek_sequence(
lines: &[String], pattern: &[String],
start: usize, eof: bool,
) -> Option<usize>
```
Contract:
- Returns the smallest `i ≥ search_start` such that
`lines[i..i + pattern.len()]` matches `pattern` under one of four match
predicates (tried in order). `search_start = lines.len() - pattern.len()`
if `eof` and the pattern fits, else `start`.
- Empty `pattern` → `Some(start)`.
- `pattern.len() > lines.len()` → `None` (MUST NOT panic).
The four match predicates, tried in order (first success wins):
1. **Exact**. `lines[i + k] == pattern[k]` for all k.
2. **Rstrip**. `lines[i + k].trim_end() == pattern[k].trim_end()`.
3. **Full trim**. `lines[i + k].trim() == pattern[k].trim()`.
4. **Unicode-normalized trim**. Trim, then fold common typographic
punctuation to ASCII, then compare.
Normalization table (MUST be implemented identically):
| Folded to | Source code points |
| --------- | ------------------ |
| `-` | U+2010 HYPHEN, U+2011 NON-BREAKING HYPHEN, U+2012 FIGURE DASH, U+2013 EN DASH, U+2014 EM DASH, U+2015 HORIZONTAL BAR, U+2212 MINUS SIGN |
| `'` | U+2018, U+2019, U+201A, U+201B |
| `"` | U+201C, U+201D, U+201E, U+201F |
| ` ` (space) | U+00A0 NBSP, U+2002, U+2003, U+2004, U+2005, U+2006, U+2007, U+2008, U+2009, U+200A, U+202F, U+205F, U+3000 |
All other code points are passed through unchanged. This lets the model
emit ASCII hyphens/quotes/spaces even when the source file contains
typographic variants (e.g. an em-dash pasted from a doc).
**Per-chunk EOF hint.** When `eof == true` (set from `is_end_of_file`), the
matcher first tries to match at the tail of the file
(`i = lines.len() - pattern.len()`) before falling through to the normal
forward search from `start`.
**What's deliberately not supported.**
- No "floating" / best-effort match. If all four passes fail, the chunk
fails; there is no nearest-match heuristic.
- No matching across non-adjacent lines — the pattern must appear as a
contiguous block.
- No multi-match disambiguation: the **first** match wins. Chunks must
carry enough context (or a `@@ header`) that the first hit at or after
`line_index` is the intended one.
---
## 7. Path resolution
- `cwd` is the `AbsolutePathBuf` passed to `apply_patch(...)`. In the CLI /
harness it defaults to the process working directory, possibly further
qualified by a workdir extracted from a `cd X && apply_patch <<EOF` shell
invocation (§8.3).
- `resolve_path_against_base(path, cwd)`:
- If `path` is absolute → `path` (cwd is ignored).
- If `path` is relative → `cwd.join(path)`.
- The patch grammar does not define an escape mechanism. Paths containing
spaces, tabs, or Unicode are supported as-is (the parser takes the full
rest of the header line); paths containing literal newlines are
unrepresentable by construction.
- A `FileSystemSandboxContext` MAY be passed in; when present, every
filesystem call is routed through it. All of `read_file_text`,
`write_file`, `remove`, `get_metadata`, `create_directory` receive the
sandbox. The sandbox is responsible for enforcing path restrictions —
the applier does no check of its own.
---
## 8. Invocation forms
The applier accepts the patch text through several transport layers. A
reimplementation only strictly needs §8.1 (tool arg) — the others exist for
historical compatibility.
### 8.1 Direct tool argument (freeform or JSON)
The preferred form. Either the freeform tool's `input` or the JSON tool's
`input` string is the full patch, e.g.:
```
*** Begin Patch
*** Add File: hello.txt
+Hello, world!
*** End Patch
```
### 8.2 Heredoc-wrapped
When the patch is invoked via `shell` (the legacy path), the model wraps
the patch in a heredoc. The parser's lenient mode strips the outermost
heredoc wrapper:
```
<<EOF
*** Begin Patch
...
*** End Patch
EOF
```
The opener must be one of `<<EOF`, `<<'EOF'`, `<<"EOF"`; the closer must be
`EOF` on its own line. Mismatched quoting (`<<"EOF'`) or a missing closer
is rejected.
### 8.3 Shell script with workdir
The harness also recognizes a `cd <path> && apply_patch <<'EOF' ... EOF`
shell invocation (parsed via Tree-sitter in
`codex-rs/apply-patch/src/invocation.rs`). The `<path>` is extracted into
`ApplyPatchArgs.workdir` and used to qualify `cwd` before applying hunks.
Any other pre- or post-commands cause the parse to fail over to "treat as
a regular shell command" rather than `apply_patch`.
### 8.4 stdin (standalone executable)
`codex-rs/apply-patch/src/standalone_executable.rs` lets the binary be
invoked as `apply_patch` with the patch on argv[1], OR with no args and the
patch piped on stdin.
---
## 9. Result presentation
### 9.1 Success
After a successful apply, the caller renders a git-style summary
(`codex-rs/apply-patch/src/lib.rs::print_summary`):
```
Success. Updated the following files:
A <added path 1>
A <added path 2>
M <modified or renamed path>
D <deleted path>
```
- Sections appear in the order Added / Modified / Deleted.
- Paths are the ones spelled in the patch (not canonicalized).
- Renamed files appear under `M` with the **original** path, not the
destination.
- Exit status 0.
### 9.2 Failure
- Parse errors: written to stderr as
`Invalid patch: <message>` or
`Invalid patch hunk on line <N>: <message>`.
- Apply errors (context miss / old_lines miss / IO): written to stderr
with the Rust `anyhow` chain, e.g.:
- `Failed to find context '<ctx>' in <path>`
- `Failed to find expected lines in <path>:\n<block>`
- `Failed to read file to update <path>: <io err>`
- `Failed to write file <path>: <io err>`
- `Failed to delete file <path>: <io err>`
- `Failed to remove original <path>: <io err>`
- `Failed to create parent directories for <path>: <io err>`
- Exit status 1 (apply/parse failure) or 2 (argv usage error).
### 9.3 Harness-side tool call result
When invoked through the harness, the handler wraps the above in
`ExecToolCallOutput { exit_code, stdout, stderr, aggregated_output, duration,
timed_out }`. The model sees `aggregated_output`.
### 9.4 Progress events
The `PatchApplyUpdatedEvent` is emitted to the TUI as each hunk is applied
(only when the progress feature is on). This is a UX detail and is not part
of the observable patch semantics.
---
## 10. Edge cases (test-derived)
| Case | Behavior |
| --------------------------------------------------- | -------- |
| Patch with zero hunks | Error: `No files were modified.` |
| Add File overwriting an existing file | Silent overwrite. |
| Delete File on a directory | Error: `path is a directory`. |
| Delete File on a nonexistent file | Error propagated from `fs.get_metadata` / `fs.remove`. |
| Move to an existing destination | Destination overwritten; source removed. |
| Update File with 0 chunks | Parse error: `Update file hunk for path '<p>' is empty`. |
| Chunk with only `+` lines (pure addition) | Inserts at end of file (before final empty line if any). |
| Chunk whose `old_lines` end in an empty string | Retry without the trailing empty; lets EOF edits match. |
| Patch with `*** End of File` marker | `is_end_of_file = true`; matcher tries tail-of-file first. |
| Unicode dash/quote/NBSP mismatch between patch and file | Normalized-trim match (4th seek pass) matches. |
| Leading/trailing whitespace on a sentinel line | Ignored (`line.trim()` before marker compare). |
| Blank line inside an Update File between chunks | Ignored (used as visual separator). |
| Heredoc wrapper around the whole patch | Stripped in Lenient mode. |
| Streaming: `*** End Patch` absent yet | OK in `parse_patch_streaming`; last incomplete hunk is dropped. |
| First chunk of an Update File lacks `@@` | Allowed (context-less first chunk). |
| Non-first chunk missing `@@` | Parse error. |
| Absolute path in patch | Accepted by parser; model is told not to emit these. |
| Multiple chunks touching the same file | Applied in reverse start-order; must be in file-order in the patch. |
| One hunk of N fails | Prior hunks remain applied; later hunks skipped. |
| File with no trailing newline as input | Output gains one (post-condition). |
---
## 11. Reimplementation checklist
To reimplement this format end-to-end, a conforming implementation MUST:
- [ ] Accept the exact sentinel tokens in §3.2 with the trailing space
where required; match marker lines after `trim()` only.
- [ ] Parse the grammar in §3.1 including the context-less first chunk
allowance, the `*** End of File` terminator, blank-line separation
between chunks, and the `*** Move to:` renames.
- [ ] Implement Lenient mode (heredoc strip) and Streaming mode as
described in §4.3.
- [ ] Emit the error messages in §4.5 verbatim (test compatibility).
- [ ] For Update File, read the target with UTF-8, split on `\n`, drop the
trailing empty element, apply chunks via the replacement machinery in
§6.5, and re-add a trailing newline before writing.
- [ ] Implement `seek_sequence` with the four-pass strictness hierarchy and
the exact Unicode normalization table in §6.6, including the
pattern-longer-than-input → `None` guard and the `eof` tail-first
search.
- [ ] Apply hunks sequentially and non-atomically; do not rollback on
partial failure.
- [ ] Silently overwrite existing destinations for Add File and Move.
- [ ] Emit the `Success. Updated the following files:` / `A`/`M`/`D`
summary in §9.1 on success, and stderr messages in §9.2 on failure.
- [ ] Register both freeform-grammar and JSON-function tool variants with
`supports_parallel_tool_calls = false`.
- [ ] Ship the agent prompt in §2 verbatim.
Optional / harness features (not required for correctness):
- Heredoc `cd <dir> && apply_patch <<'EOF' ... EOF` shell-form detection
with workdir extraction.
- Streaming progress events to the UI.
- Unified-diff rendering (`unified_diff_from_chunks`) for displaying a
user-visible diff after apply.
+7 -1
View File
@@ -454,7 +454,13 @@ async function executeToolCalls(
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
// Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
// come back under their wire-level name, which may differ from the
// harness-internal `name`. Match on either, preferring `name` for
// determinism if both somehow collide.
tool:
tools?.find(t => t.name === toolCall.name) ??
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
+75 -38
View File
@@ -32460,7 +32460,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-chat-latest": {
"id": "gpt-5-chat-latest",
@@ -32480,7 +32481,8 @@
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 16384
"maxTokens": 16384,
"applyPatchToolType": "freeform"
},
"gpt-5-codex": {
"id": "gpt-5-codex",
@@ -32505,7 +32507,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-mini": {
"id": "gpt-5-mini",
@@ -32530,7 +32533,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-nano": {
"id": "gpt-5-nano",
@@ -32555,7 +32559,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-pro": {
"id": "gpt-5-pro",
@@ -32580,7 +32585,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1": {
"id": "gpt-5.1",
@@ -32605,7 +32611,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-chat-latest": {
"id": "gpt-5.1-chat-latest",
@@ -32630,7 +32637,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex": {
"id": "gpt-5.1-codex",
@@ -32655,7 +32663,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex-max": {
"id": "gpt-5.1-codex-max",
@@ -32680,7 +32689,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex-mini": {
"id": "gpt-5.1-codex-mini",
@@ -32705,7 +32715,8 @@
"mode": "effort",
"minLevel": "medium",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2": {
"id": "gpt-5.2",
@@ -32730,7 +32741,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2-chat-latest": {
"id": "gpt-5.2-chat-latest",
@@ -32755,7 +32767,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2-codex": {
"id": "gpt-5.2-codex",
@@ -32780,7 +32793,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2-pro": {
"id": "gpt-5.2-pro",
@@ -32805,7 +32819,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.3-chat-latest": {
"id": "gpt-5.3-chat-latest",
@@ -32825,7 +32840,8 @@
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 16384
"maxTokens": 16384,
"applyPatchToolType": "freeform"
},
"gpt-5.3-codex": {
"id": "gpt-5.3-codex",
@@ -32850,7 +32866,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.3-codex-spark": {
"id": "gpt-5.3-codex-spark",
@@ -32876,7 +32893,8 @@
"minLevel": "low",
"maxLevel": "xhigh"
},
"contextPromotionTarget": "openai/gpt-5.3-codex"
"contextPromotionTarget": "openai/gpt-5.3-codex",
"applyPatchToolType": "freeform"
},
"gpt-5.4": {
"id": "gpt-5.4",
@@ -32901,7 +32919,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4-mini": {
"id": "gpt-5.4-mini",
@@ -32926,7 +32945,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4-nano": {
"id": "gpt-5.4-nano",
@@ -32951,7 +32971,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4-pro": {
"id": "gpt-5.4-pro",
@@ -32976,7 +32997,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"o1": {
"id": "o1",
@@ -33231,7 +33253,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-codex": {
"id": "gpt-5-codex",
@@ -33258,7 +33281,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5-codex-mini": {
"id": "gpt-5-codex-mini",
@@ -33285,7 +33309,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1": {
"id": "gpt-5.1",
@@ -33312,7 +33337,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex": {
"id": "gpt-5.1-codex",
@@ -33339,7 +33365,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex-max": {
"id": "gpt-5.1-codex-max",
@@ -33366,7 +33393,8 @@
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.1-codex-mini": {
"id": "gpt-5.1-codex-mini",
@@ -33393,7 +33421,8 @@
"mode": "effort",
"minLevel": "medium",
"maxLevel": "high"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2": {
"id": "gpt-5.2",
@@ -33420,7 +33449,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.2-codex": {
"id": "gpt-5.2-codex",
@@ -33447,7 +33477,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.3-codex": {
"id": "gpt-5.3-codex",
@@ -33474,7 +33505,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.3-codex-spark": {
"id": "gpt-5.3-codex-spark",
@@ -33500,7 +33532,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4": {
"id": "gpt-5.4",
@@ -33527,7 +33560,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4-mini": {
"id": "gpt-5.4-mini",
@@ -33554,7 +33588,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.4-nano": {
"id": "gpt-5.4-nano",
@@ -33581,7 +33616,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
},
"gpt-5.5": {
"id": "gpt-5.5",
@@ -33608,7 +33644,8 @@
"mode": "effort",
"minLevel": "low",
"maxLevel": "xhigh"
}
},
"applyPatchToolType": "freeform"
}
},
"opencode": {
@@ -51549,4 +51586,4 @@
}
}
}
}
}
@@ -2,6 +2,7 @@ import * as os from "node:os";
import { $env, $flag, abortableSleep, asRecord, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import type OpenAI from "openai";
import type {
ResponseCustomToolCall,
ResponseFunctionToolCall,
ResponseInput,
ResponseInputContent,
@@ -112,7 +113,7 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
type CodexTransport = "sse" | "websocket";
type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall;
type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
type CodexOutputBlock = ThinkingContent | TextContent | (ToolCall & { partialJson: string });
type CodexWebSocketSessionState = {
@@ -498,13 +499,23 @@ async function buildTransformedCodexRequestBody(
params.service_tier = options.serviceTier;
}
if (context.tools && context.tools.length > 0) {
params.tools = convertTools(context.tools);
params.tools = convertTools(context.tools, model);
if (options?.toolChoice) {
const toolChoice = normalizeCodexToolChoice(options.toolChoice);
if (toolChoice) {
params.tool_choice = toolChoice;
}
}
// When a custom-tool is active, force serial tool-calling. OpenAI's
// `parallel_tool_calls` is request-scoped — disabling it here affects
// every tool in the turn, not just the custom one. That's coarser
// than spec §1's "supports_parallel_tool_calls = false" (which
// strictly targets `apply_patch`), but the platform API offers no
// per-tool flag.
const emittedTools = params.tools as CodexToolPayload[];
if (emittedTools.some(t => t.type === "custom")) {
params.parallel_tool_calls = false;
}
}
const systemPrompt = buildCodexSystemPrompt({ userSystemPrompt: context.systemPrompt });
@@ -802,6 +813,16 @@ function handleCodexStreamEvent(args: {
return firstTokenTime;
}
if (eventType === "response.custom_tool_call_input.delta") {
handleCustomToolCallInputDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
return firstTokenTime;
}
if (eventType === "response.custom_tool_call_input.done") {
handleCustomToolCallInputDone(runtime.currentItem, runtime.currentBlock, rawEvent);
return firstTokenTime;
}
if (eventType === "response.output_item.done") {
handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex);
return firstTokenTime;
@@ -839,6 +860,19 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
partialJson: item.arguments || "",
};
}
if (item.type === "custom_tool_call") {
// Wire name flows through unchanged; the agent-loop dispatcher also
// matches `Tool.customWireName`. Reuse `partialJson` as the
// accumulation buffer for the raw input string.
return {
type: "toolCall",
id: `${item.call_id}|${item.id ?? ""}`,
name: item.name,
arguments: { input: item.input ?? "" },
customWireName: item.name,
partialJson: item.input ?? "",
};
}
return null;
}
@@ -948,6 +982,34 @@ function handleToolCallArgumentsDone(
}
}
function handleCustomToolCallInputDelta(
currentItem: CodexEventItem | null,
currentBlock: CodexOutputBlock | null,
rawEvent: Record<string, unknown>,
stream: AssistantMessageEventStream,
output: AssistantMessage,
blockIndex: () => number,
): void {
if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
const delta = (rawEvent as { delta?: string }).delta || "";
currentBlock.partialJson += delta;
currentBlock.arguments = { input: currentBlock.partialJson };
stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
}
function handleCustomToolCallInputDone(
currentItem: CodexEventItem | null,
currentBlock: CodexOutputBlock | null,
rawEvent: Record<string, unknown>,
): void {
if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
const input = (rawEvent as { input?: string }).input;
if (typeof input === "string") {
currentBlock.partialJson = input;
currentBlock.arguments = { input };
}
}
function handleOutputItemDone(
model: Model<"openai-codex-responses">,
output: AssistantMessage,
@@ -1000,6 +1062,23 @@ function handleOutputItemDone(
return;
}
if (item.type === "custom_tool_call") {
const rawInput =
runtime.currentBlock?.type === "toolCall" && runtime.currentBlock.partialJson
? runtime.currentBlock.partialJson
: (item.input ?? "");
const toolCall: ToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id ?? ""}`,
name: item.name,
arguments: { input: rawInput },
customWireName: item.name,
};
runtime.canSafelyReplayWebsocketOverSse = false;
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
return;
}
void model;
}
@@ -2081,6 +2160,10 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
let msgIndex = 0;
// Track call_ids that originated as custom tool calls so paired tool-result
// messages can be replayed as `custom_tool_call_output` rather than
// `function_call_output` (OpenAI rejects mismatched pairs).
const customCallIds = new Set<string>();
for (const msg of transformedMessages) {
if (msg.role === "user" || msg.role === "developer") {
@@ -2089,6 +2172,12 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
| Array<ResponseInput[number]>
| undefined;
if (historyItems) {
for (const item of historyItems) {
const maybe = item as { type?: string; call_id?: string };
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
customCallIds.add(maybe.call_id);
}
}
messages.push(...historyItems);
msgIndex += 1;
continue;
@@ -2110,10 +2199,17 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
);
const historyItems = providerPayload?.items as Array<ResponseInput[number]> | undefined;
if (historyItems) {
for (const item of historyItems) {
const maybe = item as { type?: string; call_id?: string };
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
customCallIds.add(maybe.call_id);
}
}
if (providerPayload?.dt) {
messages.push(...historyItems);
} else {
messages.splice(0, messages.length, ...historyItems);
// Keep customCallIds from the pre-splice state since historyItems may re-introduce them.
}
msgIndex += 1;
continue;
@@ -2149,6 +2245,18 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
if (block.type === "toolCall") {
const toolCall = block as ToolCall;
const normalized = normalizeResponsesToolCallId(toolCall.id);
if (toolCall.customWireName) {
const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : "";
customCallIds.add(normalized.callId);
outputItems.push({
type: "custom_tool_call",
id: normalized.itemId,
call_id: normalized.callId,
name: toolCall.customWireName,
input: rawInput,
} as ResponseInput[number]);
continue;
}
outputItems.push({
type: "function_call",
id: normalized.itemId,
@@ -2172,11 +2280,20 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
.join("\n");
const hasImages = msg.content.some(content => content.type === "image");
const normalized = normalizeResponsesToolCallId(msg.toolCallId);
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output: (textResult.length > 0 ? textResult : "(see attached image)").toWellFormed(),
});
const output = (textResult.length > 0 ? textResult : "(see attached image)").toWellFormed();
if (customCallIds.has(normalized.callId)) {
messages.push({
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output,
});
}
if (hasImages && model.input.includes("image")) {
const contentParts: ResponseInputContent[] = [
{ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
@@ -2226,14 +2343,48 @@ function normalizeInputMessageContent(
return maybeWithoutImages.filter(item => item.type !== "input_text" || item.text.trim().length > 0);
}
function convertTools(tools: Tool[]): Array<{
type: "function";
name: string;
description: string;
parameters: Record<string, unknown>;
strict?: boolean;
}> {
return tools.map(tool => {
/**
* Whether this Codex-backend model should get the custom-tool grammar
* variant for `apply_patch`. codex-rs uses a single serializer for both
* the public Responses endpoint and `chatgpt.com/backend-api`, so the
* backend already accepts `{type: "custom"}` tools in production — the
* gate is the per-model `applyPatchToolType` flag, nothing endpoint-specific.
*/
function supportsFreeformApplyPatchCodex(model: Model<"openai-codex-responses">): boolean {
return model.applyPatchToolType === "freeform";
}
type CodexToolPayload =
| {
type: "function";
name: string;
description: string;
parameters: Record<string, unknown>;
strict?: boolean;
}
| {
type: "custom";
name: string;
description: string;
format: { type: "grammar"; syntax: "lark" | "regex"; definition: string };
};
/** @internal Exported for tests. */
export function convertTools(tools: Tool[], model: Model<"openai-codex-responses">): CodexToolPayload[] {
const allowFreeform = supportsFreeformApplyPatchCodex(model);
return tools.map((tool): CodexToolPayload => {
if (allowFreeform && tool.customFormat) {
return {
type: "custom",
name: tool.customWireName ?? tool.name,
description: tool.description || "",
format: {
type: "grammar",
syntax: tool.customFormat.syntax,
definition: tool.customFormat.definition,
},
};
}
const strict = !!(!NO_STRICT && tool.strict);
const baseParameters = tool.parameters as unknown as Record<string, unknown>;
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
@@ -1,6 +1,7 @@
import { structuredCloneJSON } from "@oh-my-pi/pi-utils";
import type OpenAI from "openai";
import type {
ResponseCustomToolCall,
ResponseFunctionToolCall,
ResponseInput,
ResponseInputContent,
@@ -82,11 +83,30 @@ export function collectKnownCallIds(messages: ResponseInput): Set<string> {
for (const item of messages) {
if (item.type === "function_call" && typeof item.call_id === "string") {
knownCallIds.add(item.call_id);
} else if (
(item as { type?: string }).type === "custom_tool_call" &&
typeof (item as { call_id?: string }).call_id === "string"
) {
knownCallIds.add((item as { call_id: string }).call_id);
}
}
return knownCallIds;
}
/** Scan replay items for call_ids that were originally custom tool calls. */
export function collectCustomCallIds(messages: ResponseInput): Set<string> {
const customCallIds = new Set<string>();
for (const item of messages) {
if (
(item as { type?: string }).type === "custom_tool_call" &&
typeof (item as { call_id?: string }).call_id === "string"
) {
customCallIds.add((item as { call_id: string }).call_id);
}
}
return customCallIds;
}
export function convertResponsesInputContent(
content: string | Array<TextContent | ImageContent>,
supportsImages: boolean,
@@ -122,6 +142,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
msgIndex: number,
knownCallIds: Set<string>,
includeThinkingSignatures = true,
customCallIds?: Set<string>,
): ResponseInput {
const outputItems: ResponseInput = [];
const isDifferentModel =
@@ -167,6 +188,18 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
itemId = undefined;
}
knownCallIds.add(normalized.callId);
if (block.customWireName) {
const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
customCallIds?.add(normalized.callId);
outputItems.push({
type: "custom_tool_call",
id: itemId,
call_id: normalized.callId,
name: block.customWireName,
input: rawInput,
} as ResponseInput[number]);
continue;
}
outputItems.push({
type: "function_call",
id: itemId,
@@ -185,6 +218,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
model: Model<TApi>,
strictResponsesPairing: boolean,
knownCallIds: ReadonlySet<string>,
customCallIds?: ReadonlySet<string>,
): void {
const textResult = toolResult.content
.filter((block): block is TextContent => block.type === "text")
@@ -196,11 +230,20 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
return;
}
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output: (textResult.length > 0 ? textResult : "(see attached image)").toWellFormed(),
});
const output = (textResult.length > 0 ? textResult : "(see attached image)").toWellFormed();
if (customCallIds?.has(normalized.callId)) {
messages.push({
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output,
});
}
if (!hasImages || !model.input.includes("image")) {
return;
@@ -233,7 +276,12 @@ export async function processResponsesStream<TApi extends Api>(
model: Model<TApi>,
options?: ProcessResponsesStreamOptions,
): Promise<void> {
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
let currentItem:
| ResponseReasoningItem
| ResponseOutputMessage
| ResponseFunctionToolCall
| ResponseCustomToolCall
| null = null;
let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
@@ -269,6 +317,24 @@ export async function processResponsesStream<TApi extends Api>(
};
output.content.push(currentBlock);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
} else if (item.type === "custom_tool_call") {
currentItem = item;
currentBlock = {
type: "toolCall",
id: `${item.call_id}|${item.id ?? ""}`,
// Preserve the raw wire name (e.g. `apply_patch`). The agent-loop
// dispatcher matches it against both `Tool.name` and
// `Tool.customWireName`, so this stays wire-accurate through
// history replay while still routing to the right handler.
name: item.name,
arguments: { input: item.input ?? "" },
customWireName: item.name,
// Custom tools stream a raw string, but we reuse `partialJson` as the
// accumulation buffer so later code that inspects the field still works.
partialJson: item.input ?? "",
};
output.content.push(currentBlock);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
}
} else if (event.type === "response.reasoning_summary_part.added") {
if (currentItem?.type === "reasoning") {
@@ -368,6 +434,22 @@ export async function processResponsesStream<TApi extends Api>(
currentBlock.partialJson = event.arguments;
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
}
} else if (event.type === "response.custom_tool_call_input.delta") {
if (currentItem?.type === "custom_tool_call" && currentBlock?.type === "toolCall") {
currentBlock.partialJson += event.delta;
currentBlock.arguments = { input: currentBlock.partialJson };
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: event.delta,
partial: output,
});
}
} else if (event.type === "response.custom_tool_call_input.done") {
if (currentItem?.type === "custom_tool_call" && currentBlock?.type === "toolCall") {
currentBlock.partialJson = event.input;
currentBlock.arguments = { input: event.input };
}
} else if (event.type === "response.output_item.done") {
const item = structuredCloneJSON(event.item);
options?.onOutputItemDone?.(item);
@@ -418,6 +500,20 @@ export async function processResponsesStream<TApi extends Api>(
};
currentBlock = null;
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
} else if (item.type === "custom_tool_call") {
const rawInput =
currentBlock?.type === "toolCall" && currentBlock.partialJson
? currentBlock.partialJson
: (item.input ?? "");
const toolCall: ToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id ?? ""}`,
name: item.name,
arguments: { input: rawInput },
customWireName: item.name,
};
currentBlock = null;
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
} else if (event.type === "response.completed") {
const response = event.response;
+44 -3
View File
@@ -48,6 +48,7 @@ import {
} from "./github-copilot-headers";
import {
appendResponsesToolResultMessages,
collectCustomCallIds,
collectKnownCallIds,
convertResponsesAssistantMessage,
convertResponsesInputContent,
@@ -388,10 +389,19 @@ function buildParams(
}
if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model));
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
if (options?.toolChoice) {
params.tool_choice = mapToOpenAIResponsesToolChoice(options.toolChoice);
}
// The apply_patch spec §1 marks only `apply_patch` itself as
// `supports_parallel_tool_calls = false`. OpenAI's Responses API
// exposes `parallel_tool_calls` as a request-scoped flag, not a
// per-tool one, so when a custom grammar tool is in the list we
// disable parallelism for the whole turn. Slightly coarser than
// the spec requires — but the platform API offers no finer knob.
if (params.tools.some(t => (t as { type?: string }).type === "custom")) {
params.parallel_tool_calls = false;
}
}
if (model.reasoning) {
@@ -459,6 +469,7 @@ function convertConversationMessages(
): ResponseInput {
const messages: ResponseInput = [];
let knownCallIds = new Set<string>();
const customCallIds = new Set<string>();
const shouldReplayNativeHistory = canReplayOpenAIResponsesNativeHistory(providerSessionState);
const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform);
@@ -478,6 +489,7 @@ function convertConversationMessages(
if (historyItems && shouldReplayPayloadItems) {
messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(historyItems));
knownCallIds = collectKnownCallIds(messages);
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
msgIndex++;
continue;
}
@@ -498,6 +510,7 @@ function convertConversationMessages(
messages.splice(0, messages.length, ...sanitizedHistoryItems);
}
knownCallIds = collectKnownCallIds(messages);
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
msgIndex++;
continue;
}
@@ -508,11 +521,12 @@ function convertConversationMessages(
msgIndex,
knownCallIds,
shouldReplayNativeHistory,
customCallIds,
);
if (outputItems.length === 0) continue;
messages.push(...outputItems);
} else if (msg.role === "toolResult") {
appendResponsesToolResultMessages(messages, msg, model, strictResponsesPairing, knownCallIds);
appendResponsesToolResultMessages(messages, msg, model, strictResponsesPairing, knownCallIds, customCallIds);
}
msgIndex++;
}
@@ -520,8 +534,35 @@ function convertConversationMessages(
return messages;
}
function convertTools(tools: Tool[], strictMode: boolean): OpenAITool[] {
/**
* Whether this model should get the OpenAI custom-tool grammar variant
* for `apply_patch`. Explicit opt-in via `model.applyPatchToolType` in
* `models.json` — no auto-detection. See Phase 2a.1 in the plan.
* @internal Exported for tests.
*/
export function supportsFreeformApplyPatch(model: Model<"openai-responses">): boolean {
return model.applyPatchToolType === "freeform";
}
/** @internal Exported for tests. */
export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[] {
const allowFreeform = supportsFreeformApplyPatch(model);
return tools.map(tool => {
if (allowFreeform && tool.customFormat) {
return {
type: "custom",
// Tool advertises its wire-level name (e.g. `apply_patch`) — the
// agent-loop dispatcher will match incoming calls by either the
// internal `name` or `customWireName`.
name: tool.customWireName ?? tool.name,
description: tool.description || "",
format: {
type: "grammar",
syntax: tool.customFormat.syntax,
definition: tool.customFormat.definition,
},
} as unknown as OpenAITool;
}
const strict = !NO_STRICT && strictMode && tool.strict !== false;
const baseParameters = tool.parameters as unknown as Record<string, unknown>;
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
+32
View File
@@ -283,6 +283,14 @@ export interface ToolCall {
arguments: Record<string, any>;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
intent?: string; // Harness-level intent metadata extracted from traced tool arguments
/**
* Original wire-level name when the tool was invoked via OpenAI's custom-tool
* mechanism (e.g., `apply_patch`). Set by `openai-responses` on receive so
* the history-replay path can re-emit the call as `custom_tool_call` with
* its paired tool-result as `custom_tool_call_output`. Absent for regular
* JSON function tools.
*/
customWireName?: string;
}
export interface Usage {
@@ -409,6 +417,23 @@ export interface Tool<TParameters extends TSchema = TSchema> {
parameters: TParameters;
/** If true, tool is strictly typed and validated against the parameters schema before execution */
strict?: boolean;
/**
* Optional grammar constraint for OpenAI custom-tool emission.
* When set, providers that support grammar-constrained tools (currently only
* `openai-responses` against models with the right capability flag) may emit
* this tool as `{type: "custom", format: {type: "grammar", …}}` instead of a
* JSON function tool. Other providers ignore the field.
*/
customFormat?: { syntax: "lark" | "regex"; definition: string };
/**
* Optional wire-level name used when this tool is emitted as a custom tool
* (e.g. OpenAI's `{type: "custom"}` shape). Models trained on specific tool
* names — like GPT-5 on `apply_patch` — need to see that exact name on the
* wire, but it may differ from the harness-internal `name`. The agent-loop
* dispatcher matches both `name` and `customWireName` so returned tool
* calls route correctly. Absent for regular JSON function tools.
*/
customWireName?: string;
}
export interface Context {
@@ -542,4 +567,11 @@ export interface Model<TApi extends Api = any> {
thinking?: ThinkingConfig;
/** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions" ? OpenAICompat : never;
/**
* Which shape to use when exposing the Codex `apply_patch` tool to this model.
* - `"freeform"`: OpenAI custom-tool with a Lark grammar (spec §1.1). Raw patch
* string, no JSON envelope. Only honored by the `openai-responses` provider.
* - `"function"` or undefined: JSON function-tool with `{input: string}` (spec §1.2).
*/
applyPatchToolType?: "freeform" | "function";
}
@@ -0,0 +1,382 @@
import { describe, expect, test } from "bun:test";
import { convertTools, supportsFreeformApplyPatch } from "@oh-my-pi/pi-ai/providers/openai-responses";
import {
appendResponsesToolResultMessages,
convertResponsesAssistantMessage,
processResponsesStream,
} from "@oh-my-pi/pi-ai/providers/openai-responses-shared";
import type { AssistantMessage, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { Type } from "@sinclair/typebox";
const GRAMMAR = 'start: "*** Begin Patch" LF';
function makeModel(overrides: Partial<Model<"openai-responses">> = {}): Model<"openai-responses"> {
return {
id: "gpt-5",
name: "GPT-5",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
...overrides,
};
}
const editTool: Tool = {
name: "edit",
customWireName: "apply_patch",
description: "edit files",
parameters: Type.Object({ input: Type.String() }),
customFormat: { syntax: "lark", definition: GRAMMAR },
};
const plainTool: Tool = {
name: "read_file",
description: "read a file",
parameters: Type.Object({ path: Type.String() }),
};
describe("supportsFreeformApplyPatch", () => {
test("absent flag returns false", () => {
// No auto-detection — requires explicit opt-in in models.json.
expect(supportsFreeformApplyPatch(makeModel())).toBe(false);
});
test("applyPatchToolType: freeform enables", () => {
expect(supportsFreeformApplyPatch(makeModel({ applyPatchToolType: "freeform" }))).toBe(true);
});
test("applyPatchToolType: function disables", () => {
expect(supportsFreeformApplyPatch(makeModel({ applyPatchToolType: "function" }))).toBe(false);
});
test("flag is the sole signal — id/baseUrl are irrelevant", () => {
expect(
supportsFreeformApplyPatch(
makeModel({ id: "gpt-4", baseUrl: "https://proxy.example/", applyPatchToolType: "freeform" }),
),
).toBe(true);
expect(supportsFreeformApplyPatch(makeModel({ id: "gpt-5", baseUrl: "https://api.openai.com/v1" }))).toBe(false);
});
});
describe("convertTools: freeform emission", () => {
const freeformModel = makeModel({ applyPatchToolType: "freeform" });
test("edit tool with customFormat becomes a custom grammar tool", () => {
const [out] = convertTools([editTool], false, freeformModel) as unknown as Array<Record<string, unknown>>;
expect(out.type).toBe("custom");
expect(out.name).toBe("apply_patch"); // wire name from tool.customWireName
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: GRAMMAR });
});
test("regular tools remain function-type alongside a custom one", () => {
const out = convertTools([editTool, plainTool], false, freeformModel) as unknown as Array<
Record<string, unknown>
>;
expect(out[0].type).toBe("custom");
expect(out[1].type).toBe("function");
expect(out[1].name).toBe("read_file");
});
test("falls back to function tool when flag is absent", () => {
const [out] = convertTools([editTool], false, makeModel()) as unknown as Array<Record<string, unknown>>;
expect(out.type).toBe("function");
expect(out.name).toBe("edit");
});
test("applyPatchToolType=function explicitly disables", () => {
const [out] = convertTools([editTool], false, makeModel({ applyPatchToolType: "function" })) as unknown as Array<
Record<string, unknown>
>;
expect(out.type).toBe("function");
});
});
describe("custom_tool_call stream receive", () => {
async function* makeStream(events: unknown[]): AsyncIterable<any> {
for (const e of events) yield e;
}
test("aggregates delta events into a ToolCall with input arg", async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
timestamp: Date.now(),
provider: "openai",
model: "gpt-5",
api: "openai-responses",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
};
const emitted: unknown[] = [];
const stream = {
push: (e: unknown) => emitted.push(e),
end: () => {},
} as never;
const events = [
{
type: "response.output_item.added",
item: {
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "apply_patch",
input: "",
},
},
{
type: "response.custom_tool_call_input.delta",
delta: "*** Begin Patch\n",
},
{
type: "response.custom_tool_call_input.delta",
delta: "*** End Patch\n",
},
{
type: "response.custom_tool_call_input.done",
input: "*** Begin Patch\n*** End Patch\n",
},
{
type: "response.output_item.done",
item: {
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "apply_patch",
input: "*** Begin Patch\n*** End Patch\n",
},
},
];
await processResponsesStream(makeStream(events), output, stream, makeModel());
const block = output.content[0];
expect(block?.type).toBe("toolCall");
const tool = block as {
type: "toolCall";
name: string;
arguments: Record<string, unknown>;
customWireName?: string;
};
// Wire name passes through unchanged — the agent-loop dispatcher
// matches against both `Tool.name` and `Tool.customWireName`.
expect(tool.name).toBe("apply_patch");
expect(tool.customWireName).toBe("apply_patch");
expect(tool.arguments.input).toBe("*** Begin Patch\n*** End Patch\n");
// toolcall_end event carries the final ToolCall
const endEvent = emitted.find(
(
e,
): e is {
type: string;
toolCall: { name: string; arguments: Record<string, unknown>; customWireName?: string };
} => !!e && typeof e === "object" && (e as { type?: string }).type === "toolcall_end",
);
expect(endEvent?.toolCall.name).toBe("apply_patch");
expect(endEvent?.toolCall.customWireName).toBe("apply_patch");
});
});
describe("codex-backend convertTools (chatgpt.com/backend-api)", () => {
// Dynamic import: loading the codex provider pulls in heavy SDK code we
// don't want mixed into module-resolve for unrelated tests.
async function getCodexConvertTools() {
const mod = (await import("@oh-my-pi/pi-ai/providers/openai-codex-responses")) as {
convertTools: (tools: Tool[], model: Model<"openai-codex-responses">) => Array<Record<string, unknown>>;
};
return mod.convertTools;
}
function makeCodexModel(overrides: Partial<Model<"openai-codex-responses">> = {}): Model<"openai-codex-responses"> {
return {
id: "gpt-5",
name: "GPT-5",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
...overrides,
};
}
test("edit tool with customFormat becomes a custom grammar tool when flag is set", async () => {
const codexConvertTools = await getCodexConvertTools();
const [out] = codexConvertTools([editTool], makeCodexModel({ applyPatchToolType: "freeform" }));
expect(out.type).toBe("custom");
expect(out.name).toBe("apply_patch");
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: GRAMMAR });
});
test("wire shape matches direct-OpenAI convertTools (single serializer contract)", async () => {
const codexConvertTools = await getCodexConvertTools();
const [codexOut] = codexConvertTools([editTool], makeCodexModel({ applyPatchToolType: "freeform" }));
const [openaiOut] = convertTools(
[editTool],
false,
makeModel({ applyPatchToolType: "freeform" }),
) as unknown as Array<Record<string, unknown>>;
expect(codexOut).toEqual(openaiOut);
});
test("falls back to function tool when flag is absent", async () => {
const codexConvertTools = await getCodexConvertTools();
const [out] = codexConvertTools([editTool], makeCodexModel());
expect(out.type).toBe("function");
expect(out.name).toBe("edit");
});
});
describe("dispatcher wire-name matching", () => {
test("ToolCall.name matches a Tool via its customWireName", () => {
// Simulate what agent-loop.ts:455-465 does.
const editLikeTool: Tool & { customWireName?: string } = {
name: "edit",
customWireName: "apply_patch",
description: "edit files",
parameters: Type.Object({ input: Type.String() }),
customFormat: { syntax: "lark", definition: GRAMMAR },
};
const readTool: Tool = {
name: "read_file",
description: "read",
parameters: Type.Object({ path: Type.String() }),
};
const tools = [editLikeTool, readTool];
const toolCall = { name: "apply_patch" };
const matched =
tools.find(t => t.name === toolCall.name) ??
tools.find(
(t): t is typeof t & { customWireName: string } =>
(t as { customWireName?: string }).customWireName !== undefined &&
(t as { customWireName?: string }).customWireName === toolCall.name,
);
expect(matched).toBe(editLikeTool);
});
test("prefers name over customWireName when both would match", () => {
// A pathological tool set: one tool named `foo`, another with
// customWireName `foo`. Internal name wins.
const nameMatch: Tool = {
name: "foo",
description: "",
parameters: Type.Object({}),
};
const wireMatch: Tool & { customWireName: string } = {
name: "bar",
customWireName: "foo",
description: "",
parameters: Type.Object({}),
};
const tools = [wireMatch, nameMatch]; // wireMatch listed first
const toolCall = { name: "foo" };
const matched =
tools.find(t => t.name === toolCall.name) ??
tools.find(
(t): t is typeof t & { customWireName: string } =>
(t as { customWireName?: string }).customWireName !== undefined &&
(t as { customWireName?: string }).customWireName === toolCall.name,
);
expect(matched).toBe(nameMatch);
});
});
describe("history replay: custom_tool_call round-trip", () => {
test("assistant tool-call block with customWireName replays as custom_tool_call", () => {
const assistantMsg: AssistantMessage = {
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1|ctc_1",
name: "edit",
arguments: { input: "*** Begin Patch\n*** End Patch\n" },
customWireName: "apply_patch",
},
],
timestamp: Date.now(),
provider: "openai",
model: "gpt-5",
api: "openai-responses",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
};
const knownCallIds = new Set<string>();
const customCallIds = new Set<string>();
const items = convertResponsesAssistantMessage(assistantMsg, makeModel(), 0, knownCallIds, true, customCallIds);
expect(items).toHaveLength(1);
const item = items[0] as { type: string; name?: string; input?: string };
expect(item.type).toBe("custom_tool_call");
expect(item.name).toBe("apply_patch");
expect(item.input).toBe("*** Begin Patch\n*** End Patch\n");
expect(customCallIds.has("call_1")).toBe(true);
});
test("paired tool result emits custom_tool_call_output when custom id is tracked", () => {
const messages: unknown[] = [];
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_1",
toolName: "edit",
isError: false,
content: [{ type: "text", text: "Success. Updated the following files:\nM foo.txt" }],
timestamp: Date.now(),
};
const knownCallIds = new Set<string>(["call_1"]);
const customCallIds = new Set<string>(["call_1"]);
appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds);
expect(messages).toHaveLength(1);
const item = messages[0] as { type: string; call_id: string; output: string };
expect(item.type).toBe("custom_tool_call_output");
expect(item.call_id).toBe("call_1");
expect(item.output).toContain("Success");
});
test("tool result for a non-custom call still emits function_call_output", () => {
const messages: unknown[] = [];
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_2",
toolName: "read_file",
isError: false,
content: [{ type: "text", text: "ok" }],
timestamp: Date.now(),
};
const knownCallIds = new Set<string>(["call_2"]);
const customCallIds = new Set<string>(); // call_2 not custom
appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds);
const item = messages[0] as { type: string };
expect(item.type).toBe("function_call_output");
});
});
+6
View File
@@ -20,3 +20,9 @@ declare module "*.py" {
const content: string;
export default content;
}
// Lark grammar files imported as text
declare module "*.lark" {
const content: string;
export default content;
}
@@ -953,12 +953,12 @@ export const SETTINGS_SCHEMA = {
// Edit tool
"edit.mode": {
type: "enum",
values: ["replace", "patch", "hashline", "chunk", "vim"] as const,
values: ["replace", "patch", "hashline", "chunk", "vim", "apply_patch"] as const,
default: "hashline",
ui: {
tab: "editing",
label: "Edit Mode",
description: "Select the edit tool variant (replace, patch, hashline, chunk, or vim)",
description: "Select the edit tool variant (replace, patch, hashline, chunk, vim, or apply_patch)",
},
},
@@ -0,0 +1,87 @@
/**
* Multi-file orchestrator for the Codex `apply_patch` envelope.
*
* Decoupled from tool-registration: takes raw patch text + options, parses
* it, and applies each hunk via the existing single-file `applyPatch` in
* `../modes/patch.ts`. A future OpenAI freeform/grammar tool variant can
* call this directly with the raw grammar output.
*
* Per spec §6.1, hunks are applied in order and NOT atomically — if hunk
* N fails, hunks `0..N-1` are already on disk. We surface that by
* returning the per-file results alongside the error when it happens.
*/
import { ApplyPatchError } from "../diff";
import { type ApplyPatchOptions, type ApplyPatchResult, applyPatch, type PatchInput } from "../modes/patch";
import { parseApplyPatch } from "./parser";
export { parseApplyPatch } from "./parser";
export interface ApplyCodexPatchResult {
/** Single-file apply results in the order they were attempted. */
results: ApplyPatchResult[];
/** Affected file paths grouped by operation, for the §9.1 summary. */
affected: {
added: string[];
modified: string[];
deleted: string[];
};
}
/**
* Apply a full Codex `*** Begin Patch` envelope.
*
* Note: renames are reported under `modified` with the original path (spec
* §9.1), not as a delete + add.
*/
export async function applyCodexPatch(patchText: string, options: ApplyPatchOptions): Promise<ApplyCodexPatchResult> {
const hunks = parseApplyPatch(patchText);
if (hunks.length === 0) {
throw new ApplyPatchError("No files were modified.");
}
const results: ApplyPatchResult[] = [];
const affected = {
added: [] as string[],
modified: [] as string[],
deleted: [] as string[],
};
for (const hunk of hunks) {
const result = await applyPatch(hunk, options);
results.push(result);
recordAffected(affected, hunk, result);
}
return { results, affected };
}
function recordAffected(
affected: ApplyCodexPatchResult["affected"],
hunk: PatchInput,
_result: ApplyPatchResult,
): void {
switch (hunk.op) {
case "create":
affected.added.push(hunk.path);
break;
case "delete":
affected.deleted.push(hunk.path);
break;
case "update":
affected.modified.push(hunk.path);
break;
}
}
/**
* Format the A/M/D summary described in spec §9.1.
*/
export function formatApplyCodexPatchSummary(affected: ApplyCodexPatchResult["affected"]): string {
const lines = ["Success. Updated the following files:"];
for (const p of affected.added) lines.push(`A ${p}`);
for (const p of affected.modified) lines.push(`M ${p}`);
for (const p of affected.deleted) lines.push(`D ${p}`);
return lines.join("\n");
}
@@ -0,0 +1,148 @@
/**
* Parser for the Codex `apply_patch` envelope format.
*
* *** Begin Patch
* *** Add File: <path>
* +<line>
* *** Delete File: <path>
* *** Update File: <path>
* *** Move to: <newpath>
* @@ <optional context>
* -old
* +new
* context
* *** End of File
* *** End Patch
*
* Input is the full envelope text (optionally heredoc-wrapped). Output is a
* list of `PatchInput` records, each ready to hand to the existing
* single-file `applyPatch()` in `../modes/patch.ts`.
*
* Per spec §4.3 Lenient mode: a `<<EOF` / `<<'EOF'` / `<<"EOF"` heredoc
* wrapper around the whole envelope is stripped before parsing.
*/
import { ParseError } from "../diff";
import type { PatchInput } from "../modes/patch";
const BEGIN_PATCH_MARKER = "*** Begin Patch";
const END_PATCH_MARKER = "*** End Patch";
const ADD_FILE_MARKER = "*** Add File: ";
const DELETE_FILE_MARKER = "*** Delete File: ";
const UPDATE_FILE_MARKER = "*** Update File: ";
const MOVE_TO_MARKER = "*** Move to: ";
/**
* Parse a Codex `*** Begin Patch` envelope into a list of single-file
* patch inputs.
*/
export function parseApplyPatch(patchText: string): PatchInput[] {
let lines = patchText.trim().split("\n");
// Lenient heredoc strip: <<EOF / <<'EOF' / <<"EOF" ... EOF
if (lines.length >= 2) {
const first = lines[0];
const last = lines[lines.length - 1].trim();
const validOpeners = new Set(["<<EOF", "<<'EOF'", '<<"EOF"']);
if (validOpeners.has(first) && last === "EOF") {
lines = lines.slice(1, lines.length - 1);
}
}
if (lines.length === 0 || lines[0].trim() !== BEGIN_PATCH_MARKER) {
throw new ParseError("The first line of the patch must be '*** Begin Patch'");
}
if (lines[lines.length - 1].trim() !== END_PATCH_MARKER) {
throw new ParseError("The last line of the patch must be '*** End Patch'");
}
const hunks: PatchInput[] = [];
let remaining = lines.slice(1, lines.length - 1);
// Line numbers are 1-based and include the `*** Begin Patch` line (= 1).
let lineNumber = 2;
while (remaining.length > 0) {
// Blank separator lines between hunks are ignored (spec §3.3).
if (remaining[0].trim() === "") {
remaining = remaining.slice(1);
lineNumber++;
continue;
}
const firstLine = remaining[0].trim();
if (firstLine.startsWith(ADD_FILE_MARKER)) {
const path = firstLine.slice(ADD_FILE_MARKER.length);
let contents = "";
let consumed = 1;
for (let i = 1; i < remaining.length; i++) {
const line = remaining[i];
if (line.startsWith("+")) {
contents += `${line.slice(1)}\n`;
consumed++;
} else {
break;
}
}
hunks.push({ path, op: "create", diff: contents });
remaining = remaining.slice(consumed);
lineNumber += consumed;
continue;
}
if (firstLine.startsWith(DELETE_FILE_MARKER)) {
const path = firstLine.slice(DELETE_FILE_MARKER.length);
hunks.push({ path, op: "delete" });
remaining = remaining.slice(1);
lineNumber++;
continue;
}
if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
const path = firstLine.slice(UPDATE_FILE_MARKER.length);
remaining = remaining.slice(1);
lineNumber++;
let movePath: string | undefined;
if (remaining.length > 0 && remaining[0].startsWith(MOVE_TO_MARKER)) {
movePath = remaining[0].slice(MOVE_TO_MARKER.length);
remaining = remaining.slice(1);
lineNumber++;
}
// The body runs until the next file-op marker or end of input.
// `*** End of File` is a chunk-terminator and stays inside the body —
// the downstream unified-diff parser handles it.
const diffLines: string[] = [];
while (remaining.length > 0) {
const line = remaining[0];
if (
line.startsWith("*** Add File:") ||
line.startsWith("*** Delete File:") ||
line.startsWith("*** Update File:")
) {
break;
}
diffLines.push(line);
remaining = remaining.slice(1);
lineNumber++;
}
if (diffLines.length === 0) {
throw new ParseError(`Update file hunk for path '${path}' is empty`, lineNumber);
}
hunks.push({ path, op: "update", rename: movePath, diff: diffLines.join("\n") });
continue;
}
throw new ParseError(
`'${firstLine}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
lineNumber,
);
}
return hunks;
}
+65 -2
View File
@@ -8,6 +8,7 @@ import {
type WritethroughDeferredHandle,
writethroughNoop,
} from "../lsp";
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" };
import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
@@ -16,6 +17,13 @@ import type { ToolSession } from "../tools";
import { VimTool, vimSchema } from "../tools/vim";
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import {
type ApplyPatchParams,
applyPatchSchema,
expandApplyPatchToEntries,
isApplyPatchParams,
} from "./modes/apply-patch";
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import {
type ChunkParams,
type ChunkToolEdit,
@@ -50,8 +58,10 @@ import {
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
export * from "./apply-patch";
export * from "./diff";
export * from "./line-hash";
export * from "./modes/apply-patch";
export * from "./modes/chunk";
export * from "./modes/hashline";
export * from "./modes/patch";
@@ -64,10 +74,11 @@ type TInput =
| typeof patchEditSchema
| typeof hashlineEditParamsSchema
| typeof chunkEditParamsSchema
| typeof vimSchema;
| typeof vimSchema
| typeof applyPatchSchema;
type VimParams = Static<typeof vimSchema>;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams | ApplyPatchParams;
type EditToolResultDetails = EditToolDetails | VimToolDetails;
type EditModeDefinition = {
@@ -265,6 +276,28 @@ export class EditTool implements AgentTool<TInput> {
return this.#getModeDefinition().parameters;
}
/**
* When in `apply_patch` mode, expose the Codex Lark grammar so providers
* that support OpenAI-style custom tools can emit a grammar-constrained
* variant. Providers that don't support custom tools ignore this field
* and fall back to emitting a JSON function tool from `parameters`.
*/
get customFormat(): { syntax: "lark"; definition: string } | undefined {
if (this.mode !== "apply_patch") return undefined;
return { syntax: "lark", definition: applyPatchGrammar };
}
/**
* Wire-level tool name used when the custom-tool variant is active. GPT-5+
* is trained on the literal name `apply_patch`; internally this is just a
* mode of the `edit` tool. The agent-loop dispatcher matches both the
* internal `name` and `customWireName`, so returned calls route correctly.
*/
get customWireName(): string | undefined {
if (this.mode !== "apply_patch") return undefined;
return "apply_patch";
}
async execute(
_toolCallId: string,
params: EditParams,
@@ -346,6 +379,36 @@ export class EditTool implements AgentTool<TInput> {
return executePerFile(entries, batchRequest, onUpdate);
},
},
apply_patch: {
description: () => prompt.render(applyPatchDescription),
parameters: applyPatchSchema,
invalidParamsMessage: "Invalid edit parameters for apply_patch mode.",
validate: isApplyPatchParams,
execute: (
tool: EditTool,
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
const perFile = entries.map((entry: PatchEditEntry) => ({
path: entry.path,
run: (br: LspBatchRequest | undefined) =>
executePatchSingle({
session: tool.session,
params: entry,
signal,
batchRequest: br,
allowFuzzy: tool.#allowFuzzy,
fuzzyThreshold: tool.#fuzzyThreshold,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(perFile, batchRequest, onUpdate);
},
},
hashline: {
description: () => prompt.render(hashlineDescription),
parameters: hashlineEditParamsSchema,
@@ -0,0 +1,19 @@
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
%import common.LF
@@ -0,0 +1,51 @@
/**
* Edit mode wrapper for the Codex `apply_patch` envelope format.
*
* The mode accepts a single `input` string containing a full
* `*** Begin Patch ... *** End Patch` block, parses it, and fans out to
* the existing `executePatchSingle` — so all the machinery (plan mode,
* LSP writethrough, fs-cache invalidation, diagnostics) is shared with
* the `patch` mode.
*/
import { type Static, Type } from "@sinclair/typebox";
import { parseApplyPatch } from "../apply-patch/parser";
import { ApplyPatchError } from "../diff";
import type { PatchEditEntry } from "./patch";
export const applyPatchSchema = Type.Object({
input: Type.String({
description:
"Full Codex apply_patch envelope, including '*** Begin Patch' and '*** End Patch'. Contains any mix of Add/Delete/Update (with optional Move to) file operations.",
}),
});
export type ApplyPatchParams = Static<typeof applyPatchSchema>;
export function isApplyPatchParams(params: unknown): params is ApplyPatchParams {
return (
typeof params === "object" &&
params !== null &&
"input" in params &&
typeof (params as { input: unknown }).input === "string"
);
}
/**
* Parse the envelope and lower each hunk to a `PatchEditEntry` so it can
* be routed through `executePatchSingle`.
*/
export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEntry[] {
const hunks = parseApplyPatch(params.input);
if (hunks.length === 0) {
throw new ApplyPatchError("No files were modified.");
}
return hunks.map(
(h): PatchEditEntry => ({
path: h.path,
op: h.op,
rename: h.rename,
diff: h.diff,
}),
);
}
+41 -8
View File
@@ -26,9 +26,10 @@ import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { expandApplyPatchToEntries } from "./modes/apply-patch";
import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
import type { HashlineToolEdit } from "./modes/hashline";
import type { Operation } from "./modes/patch";
import type { Operation, PatchEditEntry } from "./modes/patch";
// ═══════════════════════════════════════════════════════════════════════════
// LSP Batching
@@ -79,6 +80,7 @@ interface EditRenderArgs {
oldText?: string;
newText?: string;
patch?: string;
input?: string;
all?: boolean;
// Patch mode fields
op?: Operation;
@@ -89,7 +91,19 @@ interface EditRenderArgs {
*/
previewDiff?: string;
// Hashline / chunk mode fields
edits?: Partial<HashlineToolEdit | ChunkToolEdit>[];
edits?: EditRenderEntry[];
}
type EditRenderEntry = {
path?: string;
rename?: string;
move?: string;
op?: Operation;
};
interface ApplyPatchRenderSummary {
entries: PatchEditEntry[];
error?: string;
}
function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
@@ -145,8 +159,8 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
}
/** Count distinct file paths in an edits array. */
function countEditFiles(edits: any[]): number {
return new Set(edits.map((e: any) => filePathFromEditEntry(e?.path)).filter(Boolean)).size;
function countEditFiles(edits: EditRenderEntry[]): number {
return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
}
function countLines(text: string): number {
@@ -352,6 +366,18 @@ function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme):
return "";
}
function getApplyPatchRenderSummary(args: EditRenderArgs): ApplyPatchRenderSummary | undefined {
if (typeof args.input !== "string") {
return undefined;
}
try {
return { entries: expandApplyPatchToEntries({ input: args.input }) };
} catch (err) {
return { entries: [], error: err instanceof Error ? err.message : String(err) };
}
}
function renderDiffSection(
diff: string,
rawPath: string,
@@ -419,21 +445,28 @@ export const editToolRenderer = {
return vimToolRenderer.renderCall(args, options, uiTheme);
}
const applyPatchSummary = getApplyPatchRenderSummary(args);
const firstApplyPatchEntry = applyPatchSummary?.entries[0];
// Extract path from first edit entry when top-level path is absent (new schema)
const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
const rawPath = args.file_path || args.path || filePathFromEditEntry((firstEdit as any)?.path) || "";
const rename = args.rename || (firstEdit as any)?.rename;
const op = args.op || (firstEdit as any)?.op;
const rawPath = args.file_path || args.path || filePathFromEditEntry(firstEdit?.path) || firstApplyPatchEntry?.path || "";
const rename = args.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
const op = args.op || firstEdit?.op || firstApplyPatchEntry?.op;
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
const spinner =
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
// Show file count hint for multi-file edits
const fileCount = Array.isArray(args.edits) ? countEditFiles(args.edits as any[]) : 0;
const fileCount = Array.isArray(args.edits)
? countEditFiles(args.edits)
: (applyPatchSummary?.entries.length ?? 0);
if (fileCount > 1) {
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
}
text += getCallPreview(args, rawPath, uiTheme);
if (applyPatchSummary?.error) {
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
}
return new Text(text, 0, 0);
},
@@ -14,7 +14,14 @@ import {
type TUI,
} from "@oh-my-pi/pi-tui";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../edit";
import {
computeEditDiff,
computeHashlineDiff,
computePatchDiff,
type DiffError,
type DiffResult,
expandApplyPatchToEntries,
} from "../../edit";
import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -54,6 +61,10 @@ function cloneToolArgs<T>(args: T): T {
}
}
function isEditLikeToolName(toolName: string): boolean {
return toolName === "edit" || toolName === "apply_patch";
}
export interface ToolExecutionOptions {
showImages?: boolean; // default: true (only used if terminal supports images)
editFuzzyThreshold?: number;
@@ -166,7 +177,7 @@ export class ToolExecutionComponent extends Container {
/**
* Signal that args are complete (tool is about to execute).
* This triggers diff computation for edit tool.
* This triggers diff computation for edit-like tools.
*/
setArgsComplete(_toolCallId?: string): void {
this.#argsComplete = true;
@@ -179,10 +190,39 @@ export class ToolExecutionComponent extends Container {
* This runs async and updates display when done.
*/
#maybeComputeEditDiff(): void {
if (this.#toolName !== "edit") return;
if (!isEditLikeToolName(this.#toolName)) return;
const edits = this.#args?.edits;
if (!Array.isArray(edits) || edits.length === 0) return;
if (!Array.isArray(edits) || edits.length === 0) {
if (this.#toolName !== "apply_patch" || typeof this.#args?.input !== "string") {
return;
}
const input = this.#args.input;
const argsKey = JSON.stringify({ input });
if (this.#editDiffArgsKey === argsKey) return;
this.#editDiffArgsKey = argsKey;
try {
const first = expandApplyPatchToEntries({ input })[0];
if (!first?.path) return;
computePatchDiff(first, this.#cwd, {
fuzzyThreshold: this.#editFuzzyThreshold,
allowFuzzy: this.#editAllowFuzzy,
}).then(result => {
if (this.#editDiffArgsKey === argsKey) {
this.#editDiffPreview = result;
this.#updateDisplay();
this.#ui.requestRender();
}
});
} catch (err) {
this.#editDiffPreview = { error: err instanceof Error ? err.message : String(err) };
this.#updateDisplay();
this.#ui.requestRender();
}
return;
}
const first = edits[0];
if (!first || typeof first !== "object") return;
@@ -309,7 +349,7 @@ export class ToolExecutionComponent extends Container {
*/
#updateSpinnerAnimation(): void {
// Spinner for: task tool with partial result, or edit/write while args streaming
const isStreamingArgs = !this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write");
const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
const isBackgroundAsyncTask =
this.#toolName === "task" &&
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
@@ -603,7 +643,7 @@ export class ToolExecutionComponent extends Container {
}
#getCallArgsForRender(): any {
if (this.#toolName !== "edit") {
if (!isEditLikeToolName(this.#toolName)) {
return this.#args;
}
if (!this.#editDiffPreview || !("diff" in this.#editDiffPreview) || !this.#editDiffPreview.diff) {
@@ -639,7 +679,7 @@ export class ToolExecutionComponent extends Container {
context.expanded = this.#expanded;
context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
} else if (this.#toolName === "edit") {
} else if (isEditLikeToolName(this.#toolName)) {
// Edit needs diff preview and renderDiff function
context.editDiffPreview = this.#editDiffPreview;
context.renderDiff = renderDiff;
@@ -0,0 +1,69 @@
## `apply_patch`
Use the `apply_patch` shell command to edit files.
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change's [context_after] lines in the second change's [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
@@ -51,6 +51,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
python: pythonToolRenderer as ToolRenderer,
calc: calculatorToolRenderer as ToolRenderer,
edit: editToolRenderer as ToolRenderer,
apply_patch: editToolRenderer as ToolRenderer,
find: findToolRenderer as ToolRenderer,
grep: grepToolRenderer as ToolRenderer,
lsp: lspToolRenderer as ToolRenderer,
+2 -1
View File
@@ -1,10 +1,11 @@
import { $env, $flag } from "@oh-my-pi/pi-utils";
export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim";
export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim" | "apply_patch";
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
const EDIT_MODE_IDS = {
apply_patch: "apply_patch",
chunk: "chunk",
hashline: "hashline",
patch: "patch",
@@ -4,24 +4,20 @@ import * as os from "node:os";
import * as path from "node:path";
import {
ApplyPatchError,
applyCodexPatch,
applyPatch,
ParseError,
type PatchInput,
parseApplyPatch,
parseDiffHunks,
seekSequence,
} from "@oh-my-pi/pi-coding-agent/edit";
// ═══════════════════════════════════════════════════════════════════════════
// Legacy parser for test fixtures (*** Begin Patch format)
// Test-local adapters over the production Codex envelope API.
// (Kept as thin shims so the pre-existing tests keep their original shape;
// the production API returns PatchInput[] directly.)
// ═══════════════════════════════════════════════════════════════════════════
const BEGIN_PATCH_MARKER = "*** Begin Patch";
const END_PATCH_MARKER = "*** End Patch";
const ADD_FILE_MARKER = "*** Add File: ";
const DELETE_FILE_MARKER = "*** Delete File: ";
const UPDATE_FILE_MARKER = "*** Update File: ";
const MOVE_TO_MARKER = "*** Move to: ";
type LegacyHunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
@@ -32,139 +28,16 @@ interface LegacyParseResult {
}
function parseLegacyPatch(patch: string): LegacyParseResult {
let lines = patch.trim().split("\n");
// Try lenient heredoc mode first
if (
lines.length >= 4 &&
(lines[0] === "<<EOF" || lines[0] === "<<'EOF'" || lines[0] === '<<"EOF"') &&
lines[lines.length - 1].endsWith("EOF")
) {
lines = lines.slice(1, lines.length - 1);
}
// Check boundaries
if (lines.length === 0 || lines[0].trim() !== BEGIN_PATCH_MARKER) {
throw new ParseError("The first line of the patch must be '*** Begin Patch'");
}
if (lines[lines.length - 1].trim() !== END_PATCH_MARKER) {
throw new ParseError("The last line of the patch must be '*** End Patch'");
}
const hunks: LegacyHunk[] = [];
let remainingLines = lines.slice(1, lines.length - 1);
let lineNumber = 2;
while (remainingLines.length > 0) {
if (remainingLines[0].trim() === "") {
remainingLines = remainingLines.slice(1);
lineNumber++;
continue;
}
const firstLine = remainingLines[0].trim();
// Add File
if (firstLine.startsWith(ADD_FILE_MARKER)) {
const path = firstLine.slice(ADD_FILE_MARKER.length);
let contents = "";
let linesConsumed = 1;
for (let i = 1; i < remainingLines.length; i++) {
const line = remainingLines[i];
if (line.startsWith("+")) {
contents += `${line.slice(1)}\n`;
linesConsumed++;
} else {
break;
}
}
hunks.push({ type: "add", path, contents });
remainingLines = remainingLines.slice(linesConsumed);
lineNumber += linesConsumed;
continue;
}
// Delete File
if (firstLine.startsWith(DELETE_FILE_MARKER)) {
const path = firstLine.slice(DELETE_FILE_MARKER.length);
hunks.push({ type: "delete", path });
remainingLines = remainingLines.slice(1);
lineNumber++;
continue;
}
// Update File
if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
const path = firstLine.slice(UPDATE_FILE_MARKER.length);
remainingLines = remainingLines.slice(1);
lineNumber++;
let movePath: string | undefined;
if (remainingLines.length > 0 && remainingLines[0].startsWith(MOVE_TO_MARKER)) {
movePath = remainingLines[0].slice(MOVE_TO_MARKER.length);
remainingLines = remainingLines.slice(1);
lineNumber++;
}
// Collect diff body until next file marker or end
const diffLines: string[] = [];
while (remainingLines.length > 0) {
const line = remainingLines[0];
// Stop at file operation markers (but not *** End of File)
if (
line.startsWith("*** Add File:") ||
line.startsWith("*** Delete File:") ||
line.startsWith("*** Update File:")
) {
break;
}
diffLines.push(remainingLines[0]);
remainingLines = remainingLines.slice(1);
lineNumber++;
}
if (diffLines.length === 0) {
throw new ParseError(`Update file hunk for path '${path}' is empty`, lineNumber);
}
hunks.push({ type: "update", path, movePath, diffBody: diffLines.join("\n") });
continue;
}
throw new ParseError(
`'${firstLine}' is not a valid hunk header. Valid: '*** Add File:', '*** Delete File:', '*** Update File:'`,
lineNumber,
);
}
const hunks = parseApplyPatch(patch).map((h): LegacyHunk => {
if (h.op === "create") return { type: "add", path: h.path, contents: h.diff ?? "" };
if (h.op === "delete") return { type: "delete", path: h.path };
return { type: "update", path: h.path, movePath: h.rename, diffBody: h.diff ?? "" };
});
return { hunks };
}
/** Convert legacy hunk to new PatchInput format */
function legacyHunkToInput(hunk: LegacyHunk): PatchInput {
if (hunk.type === "add") {
return { path: hunk.path, op: "create", diff: hunk.contents };
}
if (hunk.type === "delete") {
return { path: hunk.path, op: "delete" };
}
return { path: hunk.path, op: "update", rename: hunk.movePath, diff: hunk.diffBody };
}
/** Apply a legacy format patch (for test fixtures) */
async function applyLegacyPatch(patch: string, options: { cwd: string }) {
const { hunks } = parseLegacyPatch(patch);
if (hunks.length === 0) {
throw new ApplyPatchError("No files were modified.");
}
for (const hunk of hunks) {
const input = legacyHunkToInput(hunk);
await applyPatch(input, options);
}
await applyCodexPatch(patch, options);
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -696,3 +569,151 @@ describe("simple replace mode", () => {
).rejects.toThrow(/2 occurrences/);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Production Codex envelope API — spec §10 edge-case coverage
// ═══════════════════════════════════════════════════════════════════════════
describe("parseApplyPatch (production)", () => {
const wrap = (body: string) => `*** Begin Patch\n${body}\n*** End Patch`;
test("returns PatchInput[] shape directly", () => {
const result = parseApplyPatch(wrap("*** Add File: foo.txt\n+hi"));
expect(result).toEqual([{ path: "foo.txt", op: "create", diff: "hi\n" }]);
});
test("maps update with rename to op=update + rename field", () => {
const result = parseApplyPatch(wrap("*** Update File: a.py\n*** Move to: b.py\n@@\n-old\n+new"));
expect(result[0]).toMatchObject({ path: "a.py", op: "update", rename: "b.py" });
expect(result[0].diff).toContain("-old");
});
test("zero-hunk patch returns empty array", () => {
expect(parseApplyPatch(wrap(""))).toEqual([]);
});
test("heredoc wrapper with double quotes is stripped", () => {
const inner = wrap("*** Add File: x.txt\n+content");
const wrapped = `<<"EOF"\n${inner}\nEOF`;
const result = parseApplyPatch(wrapped);
expect(result).toHaveLength(1);
expect(result[0].op).toBe("create");
});
test("heredoc wrapper with bare EOF is stripped", () => {
const inner = wrap("*** Add File: x.txt\n+content");
const wrapped = `<<EOF\n${inner}\nEOF`;
expect(parseApplyPatch(wrapped)).toHaveLength(1);
});
test("mismatched heredoc quotes are not stripped", () => {
const inner = wrap("*** Add File: x.txt\n+content");
// `<<"EOF'` — opener has mismatched quotes; parser should not strip it,
// so the begin-patch check fails.
const bad = `<<"EOF'\n${inner}\nEOF`;
expect(() => parseApplyPatch(bad)).toThrow(ParseError);
});
test("unknown file directive is rejected with spec message", () => {
expect(() => parseApplyPatch(wrap("*** Rename File: a"))).toThrow(/is not a valid hunk header/);
});
test("preserves *** End of File marker inside update body", () => {
const result = parseApplyPatch(wrap("*** Update File: a.py\n@@\n-x\n+y\n*** End of File"));
expect(result[0].diff).toContain("*** End of File");
});
});
describe("applyCodexPatch (production)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `codex-patch-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
test("zero-hunk patch throws 'No files were modified.'", async () => {
await expect(applyCodexPatch("*** Begin Patch\n*** End Patch", { cwd: tempDir })).rejects.toThrow(
"No files were modified.",
);
});
test("multi-op patch (add + update + delete) applies in order", async () => {
await Bun.write(path.join(tempDir, "old.txt"), "to delete\n");
await Bun.write(path.join(tempDir, "keep.txt"), "hello\n");
const patch = [
"*** Begin Patch",
"*** Add File: new.txt",
"+brand new",
"*** Update File: keep.txt",
"@@",
"-hello",
"+HELLO",
"*** Delete File: old.txt",
"*** End Patch",
].join("\n");
const result = await applyCodexPatch(patch, { cwd: tempDir });
expect(result.affected.added).toEqual(["new.txt"]);
expect(result.affected.modified).toEqual(["keep.txt"]);
expect(result.affected.deleted).toEqual(["old.txt"]);
expect(await Bun.file(path.join(tempDir, "new.txt")).text()).toBe("brand new\n");
expect(await Bun.file(path.join(tempDir, "keep.txt")).text()).toBe("HELLO\n");
expect(fs.existsSync(path.join(tempDir, "old.txt"))).toBe(false);
});
test("rename reports modified under original path (spec §9.1)", async () => {
await Bun.write(path.join(tempDir, "src.txt"), "body\n");
const patch = [
"*** Begin Patch",
"*** Update File: src.txt",
"*** Move to: dst.txt",
"@@",
"-body",
"+body2",
"*** End Patch",
].join("\n");
const result = await applyCodexPatch(patch, { cwd: tempDir });
expect(result.affected.modified).toEqual(["src.txt"]);
expect(result.affected.added).toEqual([]);
expect(result.affected.deleted).toEqual([]);
expect(fs.existsSync(path.join(tempDir, "src.txt"))).toBe(false);
expect(await Bun.file(path.join(tempDir, "dst.txt")).text()).toBe("body2\n");
});
test("partial success: earlier ops stay applied when a later op fails", async () => {
await Bun.write(path.join(tempDir, "first.txt"), "a\n");
const patch = [
"*** Begin Patch",
"*** Update File: first.txt",
"@@",
"-a",
"+A",
"*** Update File: missing.txt",
"@@",
"-x",
"+y",
"*** End Patch",
].join("\n");
await expect(applyCodexPatch(patch, { cwd: tempDir })).rejects.toThrow();
// First op should have landed before the failure.
expect(await Bun.file(path.join(tempDir, "first.txt")).text()).toBe("A\n");
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers";
import type { TUI } from "@oh-my-pi/pi-tui";
async function getUiTheme() {
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
return theme!;
}
describe("apply_patch rendering", () => {
it("registers apply_patch to use the edit renderer", () => {
expect(toolRenderers.apply_patch).toBe(editToolRenderer);
});
it("renders apply_patch results through edit UI instead of generic fallback", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const component = new ToolExecutionComponent(
"apply_patch",
{
input: "*** Begin Patch\n*** Update File: src/demo.ts\n@@\n-old\n+new\n*** End Patch",
},
{},
undefined,
uiStub,
);
component.updateResult(
{
content: [{ type: "text", text: "" }],
details: {
path: "src/demo.ts",
op: "update",
diff: "@@\n-old\n+new",
},
},
false,
);
const rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("src/demo.ts");
expect(rendered).toContain("+new");
expect(rendered).not.toContain("(no output)");
});
it("derives call path, operation, and file-count hints from apply_patch input", async () => {
const uiTheme = await getUiTheme();
const input = [
"*** Begin Patch",
"*** Update File: src/first.ts",
"@@",
"-before",
"+after",
"*** Add File: src/new.ts",
"+hello",
"*** End Patch",
].join("\n");
const component = editToolRenderer.renderCall({ input }, { expanded: false, isPartial: true }, uiTheme);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("src/first.ts");
expect(rendered).toContain("Edit");
expect(rendered).toContain("(+1 more)");
});
it("shows an apply_patch parse error preview for malformed input", async () => {
const uiTheme = await getUiTheme();
const malformedInput = ["*** Begin Patch", "*** Update File: src/bad.ts", "*** End Patch"].join("\n");
const component = editToolRenderer.renderCall({ input: malformedInput }, { expanded: false, isPartial: true }, uiTheme);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("src/bad.ts");
expect(rendered).toContain("is empty");
});
it("shows apply_patch preview diffs after args complete", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "apply-patch-preview-"));
try {
await Bun.write(path.join(tmpDir, "preview.ts"), "const value = 1;\n");
const input = [
"*** Begin Patch",
"*** Update File: preview.ts",
"@@",
"-const value = 1;",
"+const value = 2;",
"*** End Patch",
].join("\n");
const component = new ToolExecutionComponent("apply_patch", { input }, {}, undefined, uiStub, tmpDir);
const before = Bun.stripANSI(component.render(160).join("\n"));
expect(before).not.toContain("(preview)");
component.setArgsComplete();
await Bun.sleep(50);
const after = Bun.stripANSI(component.render(160).join("\n"));
expect(after).toContain("(preview)");
expect(after).toContain("const value = 2;");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});