diff --git a/docs/src/app/api-reference/page.tsx b/docs/src/app/api-reference/page.tsx
index 40f0ee5..15cf511 100644
--- a/docs/src/app/api-reference/page.tsx
+++ b/docs/src/app/api-reference/page.tsx
@@ -111,8 +111,7 @@ const { tools, budget } = await createAgentTools(sandbox, {
Context layer config. Opt-in — wraps tools with execution
- and output policies. See the{" "}
- Context page.
+ and output policies. See the Context page.
diff --git a/docs/src/app/context/page.tsx b/docs/src/app/context/page.tsx
index fe0bb60..dcd96ff 100644
--- a/docs/src/app/context/page.tsx
+++ b/docs/src/app/context/page.tsx
@@ -151,10 +151,9 @@ const policy = createOutputPolicy({
/>
When output exceeds redirectionThreshold, it gets
- truncated to maxOutputLength and a{" "}
- _hint field is added with tool-specific guidance (e.g.,
- "use head/tail to see specific
- parts").
+ truncated to maxOutputLength and a _hint{" "}
+ field is added with tool-specific guidance (e.g., "use{" "}
+ head/tail to see specific parts").
Custom Hints
@@ -198,8 +197,8 @@ const policy = createOutputPolicy({
System Prompt Assembly
- buildSystemContext assembles a static system prompt from
- three sources: discovered project instructions (AGENTS.md /
+ buildSystemContext assembles a static system prompt
+ from three sources: discovered project instructions (AGENTS.md /
CLAUDE.md files), environment info (cwd, platform, git branch), and
tool guidance.
@@ -230,8 +229,8 @@ ctx.environment // environment XML block
ctx.toolGuidance // tool hint list`}
/>
- Call once at init — the output is deterministic and designed to
- stay stable across turns for Anthropic prompt caching.
+ Call once at init — the output is deterministic and designed
+ to stay stable across turns for Anthropic prompt caching.
diff --git a/package.json b/package.json
index cee5bc7..522fc81 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bashkit",
- "version": "0.7.0",
+ "version": "0.7.1",
"description": "Agentic coding tools for the Vercel AI SDK",
"type": "module",
"main": "./dist/index.js",
diff --git a/src/context/tool-guidance.ts b/src/context/tool-guidance.ts
index 1c4756d..5b1ac47 100644
--- a/src/context/tool-guidance.ts
+++ b/src/context/tool-guidance.ts
@@ -12,6 +12,8 @@ const DEFAULT_HINTS: Record = {
Read: "Read files or list directories. Use offset/limit for large files.",
Write: "Create or overwrite files. Read first before overwriting.",
Edit: "Replace exact strings in files. Prefer over Write for modifications.",
+ Patch:
+ "Apply multi-hunk, multi-file edits in apply-patch format. Use for bulk edits, file additions/deletions, and renames.",
Glob: "Find files by pattern. Faster than bash find.",
Grep: "Search file contents with regex. Faster than bash grep.",
WebSearch: "Search the web. Use for current information.",
diff --git a/src/index.ts b/src/index.ts
index 7fd2ebc..edfa14e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -57,6 +57,9 @@ export type {
PlanModeState,
GlobError,
GlobOutput,
+ PatchError,
+ PatchFileResult,
+ PatchOutput,
GrepContentOutput,
GrepCountOutput,
GrepError,
@@ -102,6 +105,7 @@ export {
createExitPlanModeTool,
createGlobTool,
createGrepTool,
+ createPatchTool,
createReadTool,
createSkillTool,
createTaskTool,
diff --git a/src/sandbox/e2b.ts b/src/sandbox/e2b.ts
index 9ae7c6f..029ea09 100644
--- a/src/sandbox/e2b.ts
+++ b/src/sandbox/e2b.ts
@@ -159,6 +159,16 @@ export async function createE2BSandbox(
return result.exitCode === 0;
},
+ async deleteFile(path: string): Promise {
+ const sbx = await sandbox.get();
+ await sbx.files.remove(path);
+ },
+
+ async rename(oldPath: string, newPath: string): Promise {
+ const sbx = await sandbox.get();
+ await sbx.files.rename(oldPath, newPath);
+ },
+
async destroy(): Promise {
try {
const sbx = await sandbox.get();
diff --git a/src/sandbox/interface.ts b/src/sandbox/interface.ts
index 9f5e24c..3808836 100644
--- a/src/sandbox/interface.ts
+++ b/src/sandbox/interface.ts
@@ -19,6 +19,16 @@ export interface Sandbox {
readDir(path: string): Promise;
fileExists(path: string): Promise;
isDirectory(path: string): Promise;
+ /**
+ * Optional. When unimplemented, callers should fall back to `exec("rm -- ...")`.
+ * Built-in sandboxes (LocalSandbox, VercelSandbox, E2BSandbox) implement this.
+ */
+ deleteFile?(path: string): Promise;
+ /**
+ * Optional. When unimplemented, callers should fall back to `exec("mv -- ...")`.
+ * Built-in sandboxes implement this.
+ */
+ rename?(oldPath: string, newPath: string): Promise;
destroy(): Promise;
/**
diff --git a/src/sandbox/local.ts b/src/sandbox/local.ts
index 2c12a52..7de28ab 100644
--- a/src/sandbox/local.ts
+++ b/src/sandbox/local.ts
@@ -118,6 +118,25 @@ export function createLocalSandbox(config: LocalSandboxConfig = {}): Sandbox {
}
},
+ async deleteFile(path: string): Promise {
+ const fullPath = path.startsWith("/")
+ ? path
+ : `${workingDirectory}/${path}`;
+ const fs = await import("fs/promises");
+ await fs.unlink(fullPath);
+ },
+
+ async rename(oldPath: string, newPath: string): Promise {
+ const resolvedOldPath = oldPath.startsWith("/")
+ ? oldPath
+ : `${workingDirectory}/${oldPath}`;
+ const resolvedNewPath = newPath.startsWith("/")
+ ? newPath
+ : `${workingDirectory}/${newPath}`;
+ const fs = await import("fs/promises");
+ await fs.rename(resolvedOldPath, resolvedNewPath);
+ },
+
async destroy(): Promise {
// No cleanup needed for local sandbox
},
diff --git a/src/sandbox/shell-quote.ts b/src/sandbox/shell-quote.ts
new file mode 100644
index 0000000..b41659c
--- /dev/null
+++ b/src/sandbox/shell-quote.ts
@@ -0,0 +1,11 @@
+/**
+ * POSIX shell single-quote escaping for safe path interpolation.
+ *
+ * Wraps the input in single quotes and escapes any embedded single quote as
+ * `'\''`. Safe for use with `sh -c` / `bash -c` argv. Always combine with `--`
+ * after the command (e.g. `rm -- ${shellQuote(path)}`) so paths starting with
+ * `-` aren't parsed as flags.
+ */
+export function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, `'\\''`)}'`;
+}
diff --git a/src/sandbox/vercel.ts b/src/sandbox/vercel.ts
index 87bd529..acd3ca6 100644
--- a/src/sandbox/vercel.ts
+++ b/src/sandbox/vercel.ts
@@ -2,6 +2,7 @@ import type { Sandbox as VercelSandboxType } from "@vercel/sandbox";
import type { ExecOptions, ExecResult, Sandbox } from "./interface";
import { createLazySingleton } from "./lazy-singleton";
import { ensureSandboxTools } from "./ensure-tools";
+import { shellQuote } from "./shell-quote";
export interface VercelSandboxConfig {
runtime?: "node22" | "python3.13";
@@ -177,7 +178,7 @@ export async function createVercelSandbox(
},
async readDir(path: string): Promise {
- const result = await exec(`ls -1 ${path}`);
+ const result = await exec(`ls -1 -- ${shellQuote(path)}`);
if (result.exitCode !== 0) {
throw new Error(`Failed to read directory: ${result.stderr}`);
}
@@ -185,15 +186,31 @@ export async function createVercelSandbox(
},
async fileExists(path: string): Promise {
- const result = await exec(`test -e ${path}`);
+ const result = await exec(`test -e ${shellQuote(path)}`);
return result.exitCode === 0;
},
async isDirectory(path: string): Promise {
- const result = await exec(`test -d ${path}`);
+ const result = await exec(`test -d ${shellQuote(path)}`);
return result.exitCode === 0;
},
+ async deleteFile(path: string): Promise {
+ const result = await exec(`rm -- ${shellQuote(path)}`);
+ if (result.exitCode !== 0) {
+ throw new Error(`Failed to delete: ${result.stderr}`);
+ }
+ },
+
+ async rename(oldPath: string, newPath: string): Promise {
+ const result = await exec(
+ `mv -- ${shellQuote(oldPath)} ${shellQuote(newPath)}`,
+ );
+ if (result.exitCode !== 0) {
+ throw new Error(`Failed to rename: ${result.stderr}`);
+ }
+ },
+
async destroy(): Promise {
try {
const sbx = await sandbox.get();
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index 0a0132c..b2dbb8f 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -1,15 +1,18 @@
# Tools Module
-The tools module implements all 15 AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.
+The tools module implements all 16 AI agent tools in BashKit. These tools bridge AI models with sandbox execution environments, enabling agents to perform file operations, run commands, search code, fetch web content, manage workflows, and interact with users. Each tool follows the Vercel AI SDK tool() pattern with Zod schemas for input validation and structured error handling.
+
+Most tools are a single file. **Tools with non-trivial internals live in their own folder with their own `AGENTS.md`** (e.g. `patch/`). When a single-file tool grows past ~3 files of supporting modules, promote it to a folder.
## Files
-| File | Purpose |
-|------|---------|
+| File / Folder | Purpose |
+|---------------|---------|
| `bash.ts` | Execute shell commands with timeout and output limits |
| `read.ts` | Read files and list directories with pagination, per-line truncation (2000 chars), and total output truncation (60K chars) |
| `write.ts` | Write files with size limits and path restrictions |
| `edit.ts` | String-based find/replace editing with uniqueness validation |
+| `patch/` | Multi-hunk / multi-file patches in OpenAI Codex `apply-patch` format. See `patch/AGENTS.md`. |
| `glob.ts` | Pattern-based file discovery using find command |
| `grep.ts` | Ripgrep-powered content search with context and filtering |
| `ask-user.ts` | Deferred structured user Q&A rendered by the client |
@@ -30,6 +33,7 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge
- `createReadTool(sandbox, config?)` -- File/directory reading
- `createWriteTool(sandbox, config?)` -- File writing
- `createEditTool(sandbox, config?)` -- String replacement editing
+- `createPatchTool(sandbox, config?)` -- Multi-hunk apply-patch editing (see `patch/AGENTS.md`)
- `createGlobTool(sandbox, config?)` -- File pattern matching
- `createGrepTool(sandbox, config?)` -- Content search with ripgrep
- `createAskUserTool(config?)` -- Deferred user interaction tool
@@ -65,6 +69,9 @@ Each tool exports `Output` for success and `Error` for errors:
**Core Sandbox Tools** (always enabled):
- Bash, Read, Write, Edit, Glob, Grep -- Direct sandbox operations via Sandbox interface
+**Editing Tools** (opt-in via config):
+- Patch -- Multi-hunk / multi-file apply-patch edits (overlaps with Edit; opt in via `patch: true`)
+
**Interactive Tools** (opt-in via config):
- AskUser -- Deferred structured user Q&A rendered by the client
- EnterPlanMode, ExitPlanMode -- Plan-then-execute workflow
@@ -194,6 +201,8 @@ All tool factories and types exported via `src/index.ts`:
6. Export types and factory from `index.ts`
7. Re-export from `src/index.ts`
+If the tool grows beyond a single file (parser + applier + helpers), promote it to its own folder under `src/tools/your-tool/` with a barrel `index.ts` and a folder-scoped `AGENTS.md` (see `patch/` for the canonical example). Don't forget to run `bun run link-agents` after adding the AGENTS.md so CI's `check:agents` passes.
+
### Adding a New Web-Based Tool
Similar to sandbox tools but:
1. Import web API dynamically like web-fetch.ts (module cache pattern)
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 694f5e5..058184e 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -21,6 +21,7 @@ import { createEnterPlanModeTool, type PlanModeState } from "./enter-plan-mode";
import { createExitPlanModeTool } from "./exit-plan-mode";
import { createGlobTool } from "./glob";
import { createGrepTool } from "./grep";
+import { createPatchTool } from "./patch";
import { createReadTool } from "./read";
import { createSkillTool } from "./skill";
import { createWebFetchTool } from "./web-fetch";
@@ -143,6 +144,7 @@ export interface AgentToolsResult {
*
* **Optional tools (via config):**
* - `askUser` — AskUser tool for clarifying questions
+ * - `patch` — Patch tool for multi-hunk / multi-file apply-patch edits
* - `planMode` — EnterPlanMode, ExitPlanMode for interactive planning
* - `skill` — Skill tool for skill execution
* - `webSearch` — WebSearch tool
@@ -197,6 +199,14 @@ export async function createAgentTools(
);
}
+ // Add Patch tool if configured
+ if (config?.patch) {
+ tools.Patch = createPatchTool(
+ sandbox,
+ config.patch === true ? undefined : config.patch,
+ );
+ }
+
// Add plan mode tools if configured
if (config?.planMode) {
planModeState = { isActive: false };
@@ -364,6 +374,8 @@ export type { ExitPlanModeError, ExitPlanModeOutput } from "./exit-plan-mode";
export { createExitPlanModeTool } from "./exit-plan-mode";
export type { GlobError, GlobOutput } from "./glob";
export { createGlobTool } from "./glob";
+export type { PatchError, PatchFileResult, PatchOutput } from "./patch";
+export { createPatchTool } from "./patch";
export type {
GrepContentOutput,
diff --git a/src/tools/patch/AGENTS.md b/src/tools/patch/AGENTS.md
new file mode 100644
index 0000000..0734d92
--- /dev/null
+++ b/src/tools/patch/AGENTS.md
@@ -0,0 +1,78 @@
+# Patch Tool
+
+The Patch tool applies file edits in OpenAI Codex's [`apply-patch`](https://github.com/openai/codex/tree/main/codex-rs/apply-patch) format. It complements `Edit` (single-string find/replace) with multi-hunk, multi-file, and rename/delete operations using fuzzy context-line matching.
+
+This folder is a faithful port of `codex-rs/apply-patch`. Keep parser/applier behavior in lockstep with the upstream Rust implementation when fixing bugs — the test names in `tests/tools/patch.test.ts` reference Codex test IDs to make this easier.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `tool.ts` | AI SDK `tool()` definition: parses, pre-flights, applies. Entry point. |
+| `parser.ts` | Parses the apply-patch text format into `ParsedPatch` (Add/Delete/Update hunks). |
+| `apply.ts` | Computes and applies replacements for Update hunks (`deriveNewContents`). |
+| `seek-sequence.ts` | Four-tier fuzzy line matcher (exact → trimEnd → trim → unicode). |
+| `types.ts` | Hunk/chunk/output/error types and `PatchParseError` / `PatchApplicationError`. |
+| `index.ts` | Re-exports `createPatchTool` and the public types. |
+
+## Key Exports
+
+- `createPatchTool(sandbox, config?)` — factory producing the AI SDK tool.
+- `parsePatch(input: string): ParsedPatch` — pure parser (used by tests and other consumers).
+- `parseUpdateFileChunk(...)` — exposed for unit tests of the chunk-level grammar.
+- `deriveNewContents(original, chunks, path): string` — pure applier, no I/O.
+- `seekSequence(lines, pattern, start, eof)` / `normalizeUnicode(s)` — matching primitives.
+- Types: `PatchOutput`, `PatchError`, `PatchFileResult`, `Hunk`, `UpdateFileChunk`, `ParsedPatch`.
+
+## Architecture
+
+```
+patch text
+ │
+ ▼
+parsePatch ──────► ParsedPatch (Hunk[])
+ │
+ ▼
+ ┌── pre-flight (per hunk) ──┐
+ │ • allowedPaths check │
+ │ • Update: read + derive │
+ │ • Delete: fileExists │
+ │ • Add: maxFileSize │
+ │ • Move: target collision │
+ └────────────┬──────────────┘
+ │ all pass?
+ ▼
+ apply prepared ops
+ (writeFile / deleteFile)
+```
+
+Pre-flight runs **before** any sandbox writes. If a single hunk fails — bad context, missing file, size limit, move-target collision — the tool returns `{ error }` and the filesystem is unchanged. This isn't transactional atomicity (we can't roll back I/O after writes start), but it eliminates the common failure mode of "hunk 3/5 fails, hunks 1–2 already on disk."
+
+The applier (`deriveNewContents`) is pure — it takes original content + chunks and returns new content as a string. All I/O lives in `tool.ts`.
+
+### Sandbox method fallbacks
+
+`Sandbox.deleteFile` is optional on the interface. When a custom sandbox doesn't implement it, the tool falls back to `sandbox.exec("rm -- ...")`, with paths quoted via `src/sandbox/shell-quote.ts`. Move operations are implemented as write-new-content plus delete-old-file (not `rename` / `mv`) because update chunks may modify the file content during the move. Built-in sandboxes (Local, Vercel, E2B) implement `deleteFile`, so the fallback is only relevant for third-party `Sandbox` implementers.
+
+## Common Modifications
+
+### Adding a new hunk type
+
+1. Extend the `Hunk` union in `types.ts`.
+2. Teach `parser.ts:parseOneHunk` to recognize the header.
+3. Add a `case` to `tool.ts:prepareHunk` (validation + derive content) and to the apply switch in `tool.ts:execute`.
+4. Add tests mirroring an existing hunk type in `tests/tools/patch.test.ts`.
+
+### Tuning the fuzzy matcher
+
+`seek-sequence.ts` runs four passes globally (exact → trimEnd → trim → unicode). Adding a tier means appending another `for (let i = searchStart; i <= maxIdx; i++)` pass. Keep the order: cheaper/stricter first. Update `normalizeUnicode` if you add new typographic mappings.
+
+### Changing the patch format
+
+The patch format is part of the public LLM contract — the tool description and the `patch` field's `.describe(...)` string both feed into the prompt. Format changes are breaking for any agent that learned the old format from a cached system prompt. Coordinate with a major version bump.
+
+## Testing
+
+`tests/tools/patch.test.ts` (63 tests) covers parser, chunk grammar, seek-sequence, unicode normalization, applier, and the tool-level integration path. Test names that reference `Codex: test_xxx` mirror the upstream Rust tests — keep them in sync.
+
+When adding a new pre-flight check, add a test that asserts **no files were modified** when the check fires (use `sandbox.getFiles()` to snapshot pre/post).
diff --git a/src/tools/patch/CLAUDE.md b/src/tools/patch/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/tools/patch/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/tools/patch/apply.ts b/src/tools/patch/apply.ts
new file mode 100644
index 0000000..bceb66c
--- /dev/null
+++ b/src/tools/patch/apply.ts
@@ -0,0 +1,176 @@
+/**
+ * Application logic for parsed patches.
+ * Faithful port of Codex codex-rs/apply-patch/src/lib.rs
+ */
+
+import { seekSequence } from "./seek-sequence";
+import { PatchApplicationError, type UpdateFileChunk } from "./types";
+
+/** A replacement to apply: [startIndex, oldLineCount, newLines] */
+type Replacement = [number, number, string[]];
+
+/**
+ * Compute replacements for an update operation.
+ * Faithful port of Codex compute_replacements.
+ *
+ * For each chunk:
+ * 1. If changeContext exists, seek for it (single line) to narrow position
+ * 2. If old_lines is empty → pure addition at end of file
+ * 3. Otherwise, seek old_lines and schedule replacement
+ * 4. If seek fails and old_lines ends with empty string, retry without it
+ *
+ * @param originalLines - The file split into lines (trailing empty dropped)
+ * @param filePath - File path for error messages
+ * @param chunks - The update chunks to apply
+ * @returns Array of [startIdx, oldLen, newLines] tuples, sorted by startIdx
+ */
+export function computeReplacements(
+ originalLines: string[],
+ filePath: string,
+ chunks: UpdateFileChunk[],
+): Replacement[] {
+ const replacements: Replacement[] = [];
+ let lineIndex = 0;
+
+ for (const chunk of chunks) {
+ // Step 1: If changeContext exists, seek for it to narrow position
+ if (chunk.changeContext !== null) {
+ const idx = seekSequence(
+ originalLines,
+ [chunk.changeContext],
+ lineIndex,
+ false,
+ );
+ if (idx === null) {
+ throw new PatchApplicationError(
+ `Failed to find context '${chunk.changeContext}' in ${filePath} searched from line ${lineIndex + 1}. Read the file to verify current content before retrying.`,
+ filePath,
+ );
+ }
+ lineIndex = idx + 1;
+ }
+
+ // Step 2: Handle pure addition (no old lines)
+ if (chunk.oldLines.length === 0) {
+ // Insert at end of file, or just before trailing empty line if one exists
+ const insertionIdx =
+ originalLines.length > 0 &&
+ originalLines[originalLines.length - 1] === ""
+ ? originalLines.length - 1
+ : originalLines.length;
+ replacements.push([insertionIdx, 0, [...chunk.newLines]]);
+ continue;
+ }
+
+ // Step 3: Seek old_lines in the file
+ let pattern: string[] = chunk.oldLines;
+ let found = seekSequence(
+ originalLines,
+ pattern,
+ lineIndex,
+ chunk.isEndOfFile,
+ );
+ let newSlice: string[] = chunk.newLines;
+
+ // Step 4: Retry without trailing empty line
+ // Many real-world diffs have a trailing empty string representing the final
+ // newline. This sentinel isn't in originalLines (we stripped it), so retry
+ // without it.
+ if (
+ found === null &&
+ pattern.length > 0 &&
+ pattern[pattern.length - 1] === ""
+ ) {
+ pattern = pattern.slice(0, -1);
+ if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
+ newSlice = newSlice.slice(0, -1);
+ }
+ found = seekSequence(
+ originalLines,
+ pattern,
+ lineIndex,
+ chunk.isEndOfFile,
+ );
+ }
+
+ if (found !== null) {
+ replacements.push([found, pattern.length, [...newSlice]]);
+ lineIndex = found + pattern.length;
+ } else {
+ throw new PatchApplicationError(
+ `Failed to find expected lines in ${filePath} searched from line ${lineIndex + 1}:\n${chunk.oldLines.join("\n")}\nRead the file to verify current content before retrying.`,
+ filePath,
+ );
+ }
+ }
+
+ // Sort by start index (ascending)
+ replacements.sort((a, b) => a[0] - b[0]);
+
+ return replacements;
+}
+
+/**
+ * Apply replacements to lines without spreading large arrays into a function call.
+ * Replacements are sorted ascending by computeReplacements.
+ */
+export function applyReplacements(
+ lines: string[],
+ replacements: Replacement[],
+): string[] {
+ if (replacements.length === 0) {
+ return [...lines];
+ }
+
+ const segments: string[][] = [];
+ let cursor = 0;
+
+ for (const [startIdx, oldLen, newLines] of replacements) {
+ if (startIdx > cursor) {
+ segments.push(lines.slice(cursor, startIdx));
+ }
+ segments.push(newLines);
+ cursor = startIdx + oldLen;
+ }
+
+ if (cursor < lines.length) {
+ segments.push(lines.slice(cursor));
+ }
+
+ return segments.flat();
+}
+
+/**
+ * Derive new file contents from original content and update chunks.
+ * Faithful port of Codex derive_new_contents_from_chunks.
+ *
+ * @param originalContent - The original file content as a string
+ * @param chunks - The update chunks to apply
+ * @param filePath - File path for error messages
+ * @returns The new file content as a string
+ */
+export function deriveNewContents(
+ originalContent: string,
+ chunks: UpdateFileChunk[],
+ filePath: string,
+): string {
+ // Split into lines. Drop trailing empty element from final newline
+ // (matches Codex behavior: standard diff line counting)
+ let originalLines = originalContent.split("\n");
+ if (
+ originalLines.length > 0 &&
+ originalLines[originalLines.length - 1] === ""
+ ) {
+ originalLines = originalLines.slice(0, -1);
+ }
+
+ const replacements = computeReplacements(originalLines, filePath, chunks);
+ const newLines = applyReplacements(originalLines, replacements);
+
+ // Ensure trailing newline (Codex: push empty string if last isn't empty, then join with \n)
+ const result = [...newLines];
+ if (result.length === 0 || result[result.length - 1] !== "") {
+ result.push("");
+ }
+ return result.join("\n");
+}
diff --git a/src/tools/patch/index.ts b/src/tools/patch/index.ts
new file mode 100644
index 0000000..da3734f
--- /dev/null
+++ b/src/tools/patch/index.ts
@@ -0,0 +1,2 @@
+export { createPatchTool } from "./tool";
+export type { PatchError, PatchFileResult, PatchOutput } from "./types";
diff --git a/src/tools/patch/parser.ts b/src/tools/patch/parser.ts
new file mode 100644
index 0000000..0ae9ec7
--- /dev/null
+++ b/src/tools/patch/parser.ts
@@ -0,0 +1,350 @@
+/**
+ * Parser for the Codex apply-patch format.
+ * Faithful port of Codex codex-rs/apply-patch/src/parser.rs
+ */
+
+import {
+ PatchParseError,
+ type AddFileHunk,
+ type DeleteFileHunk,
+ type Hunk,
+ type ParsedPatch,
+ type UpdateFileChunk,
+ type UpdateFileHunk,
+} from "./types";
+
+const BEGIN_MARKER = "*** Begin Patch";
+const END_MARKER = "*** End Patch";
+const ADD_PREFIX = "*** Add File: ";
+const DELETE_PREFIX = "*** Delete File: ";
+const UPDATE_PREFIX = "*** Update File: ";
+const MOVE_TO_PREFIX = "*** Move to: ";
+const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
+const CHANGE_CONTEXT_PREFIX = "@@ ";
+const EOF_MARKER = "*** End of File";
+
+/**
+ * Check that lines[0] is Begin marker and lines[last] is End marker (trimmed).
+ * Returns true if valid.
+ */
+function checkPatchBoundariesStrict(lines: string[]): boolean {
+ if (lines.length < 2) return false;
+ return (
+ lines[0].trim() === BEGIN_MARKER &&
+ lines[lines.length - 1].trim() === END_MARKER
+ );
+}
+
+/**
+ * Attempt lenient parsing: strip heredoc wrapper (< 0 && rawLines[0].trim() !== BEGIN_MARKER) {
+ throw new PatchParseError(
+ "The first line of the patch must be '*** Begin Patch'",
+ );
+ }
+ throw new PatchParseError(
+ "The last line of the patch must be '*** End Patch'",
+ );
+ }
+ }
+
+ // lines[0] is Begin marker, lines[last] is End marker
+ const lastLineIndex = lines.length - 1;
+ let remaining = lines.slice(1, lastLineIndex);
+ let lineNumber = 2; // 1-indexed, line 1 is Begin marker
+ const hunks: Hunk[] = [];
+
+ while (remaining.length > 0) {
+ const { hunk, linesConsumed } = parseOneHunk(remaining, lineNumber);
+ hunks.push(hunk);
+ lineNumber += linesConsumed;
+ remaining = remaining.slice(linesConsumed);
+ }
+
+ return { hunks };
+}
+
+/**
+ * Parse a single hunk starting at the beginning of `lines`.
+ * Returns the parsed hunk and number of lines consumed.
+ */
+function parseOneHunk(
+ lines: string[],
+ lineNumber: number,
+): { hunk: Hunk; linesConsumed: number } {
+ const firstLine = lines[0].trim();
+
+ if (firstLine.startsWith(ADD_PREFIX.trim())) {
+ return parseAddFileHunk(lines, lineNumber);
+ }
+ if (firstLine.startsWith(DELETE_PREFIX.trim())) {
+ return parseDeleteFileHunk(lines, lineNumber);
+ }
+ if (firstLine.startsWith(UPDATE_PREFIX.trim())) {
+ return parseUpdateFileHunk(lines, lineNumber);
+ }
+
+ throw new PatchParseError(
+ `'${firstLine}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
+ lineNumber,
+ );
+}
+
+/**
+ * Parse an Add File hunk. Codex: only `+` prefixed lines are content.
+ */
+function parseAddFileHunk(
+ lines: string[],
+ lineNumber: number,
+): { hunk: AddFileHunk; linesConsumed: number } {
+ const path = lines[0].trim().slice(ADD_PREFIX.trim().length).trim();
+ if (!path) {
+ throw new PatchParseError("Add File hunk has empty path", lineNumber);
+ }
+
+ let contents = "";
+ let parsedLines = 1;
+
+ for (const addLine of lines.slice(1)) {
+ if (addLine.startsWith("+")) {
+ contents += `${addLine.slice(1)}\n`;
+ parsedLines++;
+ } else {
+ break;
+ }
+ }
+
+ return {
+ hunk: { type: "add", path, content: contents },
+ linesConsumed: parsedLines,
+ };
+}
+
+/**
+ * Parse a Delete File hunk. Just the header line.
+ */
+function parseDeleteFileHunk(
+ lines: string[],
+ lineNumber: number,
+): { hunk: DeleteFileHunk; linesConsumed: number } {
+ const path = lines[0].trim().slice(DELETE_PREFIX.trim().length).trim();
+ if (!path) {
+ throw new PatchParseError("Delete File hunk has empty path", lineNumber);
+ }
+
+ return {
+ hunk: { type: "delete", path },
+ linesConsumed: 1,
+ };
+}
+
+/**
+ * Parse an Update File hunk with one or more chunks.
+ * Faithful port of Codex parse_one_hunk for UpdateFile.
+ */
+function parseUpdateFileHunk(
+ lines: string[],
+ lineNumber: number,
+): { hunk: UpdateFileHunk; linesConsumed: number } {
+ const path = lines[0].trim().slice(UPDATE_PREFIX.trim().length).trim();
+ if (!path) {
+ throw new PatchParseError("Update File hunk has empty path", lineNumber);
+ }
+
+ let remaining = lines.slice(1);
+ let parsedLines = 1;
+
+ // Optional: Move to header
+ let movePath: string | undefined;
+ if (remaining.length > 0 && remaining[0].startsWith(MOVE_TO_PREFIX)) {
+ movePath = remaining[0].slice(MOVE_TO_PREFIX.length).trim();
+ remaining = remaining.slice(1);
+ parsedLines++;
+ }
+
+ const chunks: UpdateFileChunk[] = [];
+
+ while (remaining.length > 0) {
+ // Skip blank lines between chunks (Codex: skip completely blank lines)
+ if (remaining[0].trim() === "") {
+ parsedLines++;
+ remaining = remaining.slice(1);
+ continue;
+ }
+
+ // Stop at next hunk header (*** prefix that isn't End of File or Move to)
+ if (remaining[0].startsWith("***")) {
+ break;
+ }
+
+ const { chunk, linesConsumed } = parseUpdateFileChunk(
+ remaining,
+ lineNumber + parsedLines,
+ chunks.length === 0, // allow_missing_context for first chunk
+ );
+ chunks.push(chunk);
+ parsedLines += linesConsumed;
+ remaining = remaining.slice(linesConsumed);
+ }
+
+ if (chunks.length === 0) {
+ throw new PatchParseError(
+ `Update file hunk for path '${path}' is empty`,
+ lineNumber,
+ );
+ }
+
+ return {
+ hunk: { type: "update", path, movePath, chunks },
+ linesConsumed: parsedLines,
+ };
+}
+
+/**
+ * Parse a single update chunk.
+ * Faithful port of Codex parse_update_file_chunk.
+ *
+ * In Codex:
+ * - `@@` or `@@ text` → change_context (single string or null)
+ * - Space-prefixed lines go into BOTH old_lines and new_lines
+ * - Empty lines go into BOTH old_lines and new_lines
+ * - `+` lines go into new_lines only
+ * - `-` lines go into old_lines only
+ * - Other prefixes (or *** markers) terminate the chunk
+ */
+export function parseUpdateFileChunk(
+ lines: string[],
+ lineNumber: number,
+ allowMissingContext: boolean,
+): { chunk: UpdateFileChunk; linesConsumed: number } {
+ if (lines.length === 0) {
+ throw new PatchParseError(
+ "Update hunk does not contain any lines",
+ lineNumber,
+ );
+ }
+
+ // Parse @@ header
+ let changeContext: string | null = null;
+ let startIndex: number;
+
+ if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) {
+ // Bare `@@` — no context text
+ changeContext = null;
+ startIndex = 1;
+ } else if (lines[0].startsWith(CHANGE_CONTEXT_PREFIX)) {
+ // `@@ some context text`
+ changeContext = lines[0].slice(CHANGE_CONTEXT_PREFIX.length);
+ startIndex = 1;
+ } else {
+ if (!allowMissingContext) {
+ throw new PatchParseError(
+ `Expected update hunk to start with a @@ context marker, got: '${lines[0]}'`,
+ lineNumber,
+ );
+ }
+ startIndex = 0;
+ }
+
+ if (startIndex >= lines.length) {
+ throw new PatchParseError(
+ "Update hunk does not contain any lines",
+ lineNumber + 1,
+ );
+ }
+
+ const oldLines: string[] = [];
+ const newLines: string[] = [];
+ let isEndOfFile = false;
+ let parsedLines = 0;
+
+ for (const line of lines.slice(startIndex)) {
+ if (line === EOF_MARKER) {
+ if (parsedLines === 0) {
+ throw new PatchParseError(
+ "Update hunk does not contain any lines",
+ lineNumber + 1,
+ );
+ }
+ isEndOfFile = true;
+ parsedLines++;
+ break;
+ }
+
+ const firstChar = line.length > 0 ? line[0] : null;
+
+ switch (firstChar) {
+ case null:
+ // Empty line → push empty string to both old and new
+ oldLines.push("");
+ newLines.push("");
+ break;
+ case " ":
+ // Context line → push to both old and new (strip leading space)
+ oldLines.push(line.slice(1));
+ newLines.push(line.slice(1));
+ break;
+ case "+":
+ newLines.push(line.slice(1));
+ break;
+ case "-":
+ oldLines.push(line.slice(1));
+ break;
+ default:
+ // Not a diff line. If we haven't parsed any lines yet, it's an error.
+ if (parsedLines === 0) {
+ throw new PatchParseError(
+ `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
+ lineNumber + 1,
+ );
+ }
+ // Otherwise, assume start of next hunk — stop.
+ return {
+ chunk: { changeContext, oldLines, newLines, isEndOfFile },
+ linesConsumed: parsedLines + startIndex,
+ };
+ }
+ parsedLines++;
+ }
+
+ return {
+ chunk: { changeContext, oldLines, newLines, isEndOfFile },
+ linesConsumed: parsedLines + startIndex,
+ };
+}
diff --git a/src/tools/patch/seek-sequence.ts b/src/tools/patch/seek-sequence.ts
new file mode 100644
index 0000000..1279064
--- /dev/null
+++ b/src/tools/patch/seek-sequence.ts
@@ -0,0 +1,130 @@
+/**
+ * Fuzzy line matching for Patch tool.
+ * Faithful port of Codex codex-rs/apply-patch/src/seek_sequence.rs
+ *
+ * Four-tier matching in 4 separate passes (matching Codex exactly):
+ * 1. Exact match (all positions)
+ * 2. Trailing whitespace trimmed (all positions)
+ * 3. Both sides trimmed (all positions)
+ * 4. Unicode normalization (all positions)
+ *
+ * An exact match at a later position is preferred over a fuzzy match at an
+ * earlier position — this matches Codex's global tier priority.
+ */
+
+/** Map of typographic characters to their ASCII equivalents */
+const UNICODE_REPLACEMENTS: [RegExp, string][] = [
+ // Dashes: \u2010–\u2015, \u2212 → -
+ [/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-"],
+ // Single quotes: \u2018–\u201B → '
+ [/[\u2018\u2019\u201A\u201B]/g, "'"],
+ // Double quotes: \u201C–\u201F → "
+ [/[\u201C\u201D\u201E\u201F]/g, '"'],
+ // Non-breaking and other special spaces → regular space
+ [
+ /[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g,
+ " ",
+ ],
+];
+
+/**
+ * Normalize Unicode typographic characters to ASCII equivalents,
+ * then trim whitespace.
+ */
+export function normalizeUnicode(s: string): string {
+ let result = s.trim();
+ for (const [pattern, replacement] of UNICODE_REPLACEMENTS) {
+ result = result.replace(pattern, replacement);
+ }
+ return result;
+}
+
+/**
+ * Seek a sequence of pattern lines within the file lines.
+ * Faithful port of Codex seek_sequence.rs.
+ *
+ * When `eof` is true, searching starts from the end-of-file position
+ * (so patterns intended to match file endings are applied at the end).
+ *
+ * Returns the index in `lines` where the pattern starts, or null if not found.
+ *
+ * @param lines - The file lines to search within
+ * @param pattern - The pattern lines to find
+ * @param start - Starting index in lines to begin search
+ * @param eof - If true, search starts from end-of-file position
+ */
+export function seekSequence(
+ lines: string[],
+ pattern: string[],
+ start: number,
+ eof: boolean,
+): number | null {
+ if (pattern.length === 0) {
+ return start;
+ }
+
+ // Pattern longer than available input → no possible match
+ if (pattern.length > lines.length) {
+ return null;
+ }
+
+ const searchStart =
+ eof && lines.length >= pattern.length
+ ? lines.length - pattern.length
+ : start;
+
+ const maxIdx = lines.length - pattern.length;
+
+ // Pass 1: Exact match
+ for (let i = searchStart; i <= maxIdx; i++) {
+ let ok = true;
+ for (let j = 0; j < pattern.length; j++) {
+ if (lines[i + j] !== pattern[j]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return i;
+ }
+
+ // Pass 2: Trailing whitespace trimmed (trimEnd)
+ const trimEndPattern = pattern.map((line) => line.trimEnd());
+ for (let i = searchStart; i <= maxIdx; i++) {
+ let ok = true;
+ for (let j = 0; j < pattern.length; j++) {
+ if (lines[i + j].trimEnd() !== trimEndPattern[j]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return i;
+ }
+
+ // Pass 3: Both sides trimmed (trim)
+ const trimPattern = pattern.map((line) => line.trim());
+ for (let i = searchStart; i <= maxIdx; i++) {
+ let ok = true;
+ for (let j = 0; j < pattern.length; j++) {
+ if (lines[i + j].trim() !== trimPattern[j]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return i;
+ }
+
+ // Pass 4: Unicode normalization
+ const normalizedPattern = pattern.map(normalizeUnicode);
+ for (let i = searchStart; i <= maxIdx; i++) {
+ let ok = true;
+ for (let j = 0; j < pattern.length; j++) {
+ if (normalizeUnicode(lines[i + j]) !== normalizedPattern[j]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return i;
+ }
+
+ return null;
+}
diff --git a/src/tools/patch/tool.ts b/src/tools/patch/tool.ts
new file mode 100644
index 0000000..e50b65a
--- /dev/null
+++ b/src/tools/patch/tool.ts
@@ -0,0 +1,280 @@
+/**
+ * Patch tool — applies patches in Codex apply-patch format.
+ *
+ * Supports Add, Delete, and Update operations with fuzzy line matching.
+ * Complements the Edit tool for multi-hunk and multi-file edits.
+ */
+
+import { tool, zodSchema } from "ai";
+import { z } from "zod";
+import type { Sandbox } from "../../sandbox/interface";
+import { shellQuote } from "../../sandbox/shell-quote";
+import type { ToolConfig } from "../../types";
+import {
+ debugEnd,
+ debugError,
+ debugStart,
+ isDebugEnabled,
+} from "../../utils/debug";
+import { deriveNewContents } from "./apply";
+import { parsePatch } from "./parser";
+import type { Hunk, PatchError, PatchFileResult, PatchOutput } from "./types";
+
+const patchInputSchema = z.object({
+ patch: z.string().describe(
+ `The patch to apply in Codex apply-patch format.
+
+Format:
+\`\`\`
+*** Begin Patch
+*** Add File: path/to/new-file.ts
++line 1
++line 2
+*** Update File: path/to/existing.ts
+ context line
+-old line
++new line
+*** Delete File: path/to/remove.ts
+*** End Patch
+\`\`\`
+
+Rules:
+- Wrap patch in \`*** Begin Patch\` / \`*** End Patch\`
+- Add files: \`*** Add File: \` then \`+\`-prefixed lines
+- Delete files: \`*** Delete File: \`
+- Update files: \`*** Update File: \` then diff chunks
+ - Context lines: space prefix (used for seeking position)
+ - Empty context lines may be blank lines with no prefix
+ - Remove lines: \`-\` prefix
+ - Add lines: \`+\` prefix
+ - The first chunk may omit \`@@\`; subsequent chunks must start with \`@@\`
+ - Use \`@@ context text\` to narrow the search before applying that chunk
+ - Use \`*** End of File\` to anchor a chunk at file end
+- Move/rename: \`*** Move to: \` after Update header
+- Multiple files in one patch supported`,
+ ),
+});
+
+const PATCH_DESCRIPTION = `Apply a patch to one or more files using the Codex apply-patch format.
+
+Supports adding new files, deleting files, and updating existing files with context-based diff matching. Uses fuzzy whitespace matching for resilience.
+
+**When to use Patch vs Edit:**
+- Use **Edit** for simple single-string replacements in one file
+- Use **Patch** for multi-hunk edits, multi-file changes, file additions/deletions, or when you need context-based matching`;
+
+/**
+ * A pre-flight-validated operation, ready to apply.
+ * Updates carry their derived content so we don't read+derive twice.
+ */
+type PreparedOp =
+ | { kind: "add"; path: string; content: string }
+ | { kind: "delete"; path: string }
+ | { kind: "modify"; path: string; content: string }
+ | { kind: "move"; fromPath: string; toPath: string; content: string };
+
+async function deleteFileWithFallback(
+ sandbox: Sandbox,
+ path: string,
+): Promise {
+ if (sandbox.deleteFile) {
+ await sandbox.deleteFile(path);
+ return;
+ }
+ const result = await sandbox.exec(`rm -- ${shellQuote(path)}`);
+ if (result.exitCode !== 0) {
+ throw new Error(`Failed to delete ${path}: ${result.stderr}`);
+ }
+}
+
+export function createPatchTool(sandbox: Sandbox, config?: ToolConfig) {
+ return tool({
+ description: PATCH_DESCRIPTION,
+ inputSchema: zodSchema(patchInputSchema),
+ strict: config?.strict,
+ needsApproval: config?.needsApproval,
+ providerOptions: config?.providerOptions,
+ execute: async ({
+ patch,
+ }: z.infer): Promise => {
+ const startTime = performance.now();
+ const debugId = isDebugEnabled()
+ ? debugStart("patch", {
+ patchLength: patch.length,
+ })
+ : "";
+
+ try {
+ // Step 1: Parse
+ const parsed = parsePatch(patch);
+
+ if (parsed.hunks.length === 0) {
+ const error = "Patch contains no operations";
+ if (debugId) debugError(debugId, "patch", error);
+ return { error };
+ }
+
+ // Step 2: Validate paths against allowedPaths
+ if (config?.allowedPaths) {
+ const pathError = validateAllowedPaths(
+ parsed.hunks,
+ config.allowedPaths,
+ );
+ if (pathError) {
+ if (debugId) debugError(debugId, "patch", pathError);
+ return { error: pathError };
+ }
+ }
+
+ // Step 3: Pre-flight — validate every hunk and derive new contents
+ // before any sandbox writes. This avoids partial application when a
+ // mid-patch hunk fails (bad context, missing file, size limit, etc).
+ const prepared: PreparedOp[] = [];
+ for (const hunk of parsed.hunks) {
+ const op = await prepareHunk(sandbox, hunk, config);
+ if ("error" in op) {
+ if (debugId) debugError(debugId, "patch", op.error);
+ return op;
+ }
+ prepared.push(op.op);
+ }
+
+ // Step 4: Apply prepared ops sequentially. By this point parsing,
+ // context-matching, and size limits have all passed; only raw I/O
+ // errors (disk full, permission denied) can still fail here.
+ const files: PatchFileResult[] = [];
+ for (const op of prepared) {
+ switch (op.kind) {
+ case "add":
+ await sandbox.writeFile(op.path, op.content);
+ files.push({ status: "added", path: op.path });
+ break;
+ case "delete":
+ await deleteFileWithFallback(sandbox, op.path);
+ files.push({ status: "deleted", path: op.path });
+ break;
+ case "modify":
+ await sandbox.writeFile(op.path, op.content);
+ files.push({ status: "modified", path: op.path });
+ break;
+ case "move":
+ await sandbox.writeFile(op.toPath, op.content);
+ await deleteFileWithFallback(sandbox, op.fromPath);
+ files.push({ status: "modified", path: op.toPath });
+ break;
+ }
+ }
+
+ const durationMs = Math.round(performance.now() - startTime);
+ if (debugId) {
+ debugEnd(debugId, "patch", {
+ summary: {
+ files: files.length,
+ operations: files.map((f) => `${f.status}: ${f.path}`),
+ },
+ duration_ms: durationMs,
+ });
+ }
+
+ const message =
+ files.length === 1
+ ? `Successfully patched ${files[0].path}`
+ : `Successfully patched ${files.length} files`;
+
+ return { message, files };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ if (debugId) debugError(debugId, "patch", errorMessage);
+ return { error: errorMessage };
+ }
+ },
+ });
+}
+
+/**
+ * Run pre-flight validation for a single hunk and produce a PreparedOp.
+ * On any validation failure, returns `{ error }` and the caller aborts
+ * before any writes have happened.
+ */
+async function prepareHunk(
+ sandbox: Sandbox,
+ hunk: Hunk,
+ config: ToolConfig | undefined,
+): Promise<{ op: PreparedOp } | { error: string }> {
+ switch (hunk.type) {
+ case "add": {
+ if (config?.maxFileSize && hunk.content.length > config.maxFileSize) {
+ return {
+ error: `File too large: ${hunk.path} (${hunk.content.length} bytes, max ${config.maxFileSize})`,
+ };
+ }
+ return { op: { kind: "add", path: hunk.path, content: hunk.content } };
+ }
+
+ case "delete": {
+ if (!(await sandbox.fileExists(hunk.path))) {
+ return { error: `File not found for deletion: ${hunk.path}` };
+ }
+ return { op: { kind: "delete", path: hunk.path } };
+ }
+
+ case "update": {
+ if (!(await sandbox.fileExists(hunk.path))) {
+ return { error: `File not found: ${hunk.path}` };
+ }
+ const original = await sandbox.readFile(hunk.path);
+ let newContent: string;
+ try {
+ newContent = deriveNewContents(original, hunk.chunks, hunk.path);
+ } catch (e) {
+ return {
+ error:
+ e instanceof Error
+ ? e.message
+ : `Failed to apply update to ${hunk.path}`,
+ };
+ }
+ if (config?.maxFileSize && newContent.length > config.maxFileSize) {
+ return {
+ error: `File too large after patch: ${hunk.path} (${newContent.length} bytes, max ${config.maxFileSize})`,
+ };
+ }
+ if (hunk.movePath && hunk.movePath !== hunk.path) {
+ if (await sandbox.fileExists(hunk.movePath)) {
+ return {
+ error: `Move target already exists: ${hunk.movePath}`,
+ };
+ }
+ return {
+ op: {
+ kind: "move",
+ fromPath: hunk.path,
+ toPath: hunk.movePath,
+ content: newContent,
+ },
+ };
+ }
+ return { op: { kind: "modify", path: hunk.path, content: newContent } };
+ }
+ }
+}
+
+function validateAllowedPaths(
+ hunks: Hunk[],
+ allowedPaths: string[],
+): string | null {
+ for (const hunk of hunks) {
+ const paths = [hunk.path];
+ if (hunk.type === "update" && hunk.movePath) {
+ paths.push(hunk.movePath);
+ }
+ for (const p of paths) {
+ const isAllowed = allowedPaths.some((allowed) => p.startsWith(allowed));
+ if (!isAllowed) {
+ return `Path not allowed: ${p}`;
+ }
+ }
+ }
+ return null;
+}
diff --git a/src/tools/patch/types.ts b/src/tools/patch/types.ts
new file mode 100644
index 0000000..882079f
--- /dev/null
+++ b/src/tools/patch/types.ts
@@ -0,0 +1,87 @@
+/**
+ * Types for the Patch tool (Codex apply-patch format).
+ */
+
+// --- Hunk types ---
+
+export interface AddFileHunk {
+ type: "add";
+ path: string;
+ content: string;
+}
+
+export interface DeleteFileHunk {
+ type: "delete";
+ path: string;
+}
+
+export interface UpdateFileChunk {
+ /**
+ * A single line of context used to narrow down the position of the chunk.
+ * This is the text from the `@@ context` header (e.g., a class/method/function definition).
+ * null when the `@@` header has no text or is omitted entirely.
+ */
+ changeContext: string | null;
+ /** Lines to match for replacement (includes space-prefixed context lines pushed to both old and new) */
+ oldLines: string[];
+ /** Replacement lines (includes space-prefixed context lines pushed to both old and new) */
+ newLines: string[];
+ /** Whether this chunk is at the end of the file */
+ isEndOfFile: boolean;
+}
+
+export interface UpdateFileHunk {
+ type: "update";
+ path: string;
+ /** Optional move/rename destination */
+ movePath?: string;
+ chunks: UpdateFileChunk[];
+}
+
+export type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk;
+
+// --- Parsed patch ---
+
+export interface ParsedPatch {
+ hunks: Hunk[];
+}
+
+// --- Error classes ---
+
+export class PatchParseError extends Error {
+ constructor(
+ message: string,
+ public readonly lineNumber?: number,
+ ) {
+ super(
+ lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message,
+ );
+ this.name = "PatchParseError";
+ }
+}
+
+export class PatchApplicationError extends Error {
+ constructor(
+ message: string,
+ public readonly filePath?: string,
+ ) {
+ super(filePath ? `${filePath}: ${message}` : message);
+ this.name = "PatchApplicationError";
+ }
+}
+
+// --- Tool output types ---
+
+export interface PatchFileResult {
+ status: "added" | "modified" | "deleted";
+ path: string;
+}
+
+export interface PatchOutput {
+ message: string;
+ files: PatchFileResult[];
+}
+
+export interface PatchError {
+ error: string;
+}
diff --git a/src/types.ts b/src/types.ts
index e6b8282..27347f5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -195,6 +195,8 @@ export type AgentConfig = {
};
/** Include AskUser tool for user clarification */
askUser?: true | AskUserConfig;
+ /** Include Patch tool for multi-hunk / multi-file apply-patch edits */
+ patch?: true | ToolConfig;
/** Include EnterPlanMode and ExitPlanMode tools for interactive planning */
planMode?: boolean;
/** Include Skill tool with this config */
diff --git a/tests/context/build-context.test.ts b/tests/context/build-context.test.ts
index 2f7c846..2b72d1e 100644
--- a/tests/context/build-context.test.ts
+++ b/tests/context/build-context.test.ts
@@ -338,6 +338,13 @@ describe("buildToolGuidance", () => {
expect(text).not.toContain("Prefer Read/Grep/Glob");
});
+ it("includes default Patch guidance when Patch is registered", () => {
+ const text = buildToolGuidance({ tools: ["Patch"] });
+ expect(text).toContain("**Patch**");
+ expect(text).toContain("multi-hunk, multi-file edits");
+ expect(text).toContain("file additions/deletions, and renames");
+ });
+
it("includes custom guidelines", () => {
const text = buildToolGuidance({
tools: ["Bash"],
diff --git a/tests/context/output-policy.test.ts b/tests/context/output-policy.test.ts
index 44c5448..942c11f 100644
--- a/tests/context/output-policy.test.ts
+++ b/tests/context/output-policy.test.ts
@@ -566,7 +566,7 @@ describe("createOutputPolicy", () => {
expect(typeof transformed.stdout).toBe("string");
expect(transformed._hint).toBeDefined();
// Hint should not reference a stash file
- expect((transformed._hint as string)).not.toContain("Full output saved to");
+ expect(transformed._hint as string).not.toContain("Full output saved to");
// Warning was logged
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[bashkit] stashOutput failed"),
@@ -594,7 +594,7 @@ describe("createOutputPolicy", () => {
expect(typeof transformed.stdout).toBe("string");
expect(transformed._hint).toBeDefined();
- expect((transformed._hint as string)).not.toContain("Full output saved to");
+ expect(transformed._hint as string).not.toContain("Full output saved to");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[bashkit] stashOutput failed"),
);
diff --git a/tests/context/prepare-step.test.ts b/tests/context/prepare-step.test.ts
index bcf78ce..c201aaa 100644
--- a/tests/context/prepare-step.test.ts
+++ b/tests/context/prepare-step.test.ts
@@ -51,7 +51,8 @@ describe("createPrepareStep", () => {
});
expect(result?.messages).toBeDefined();
- const lastMsg = result!.messages![result!.messages!.length - 1];
+ if (!result?.messages) throw new Error("expected messages");
+ const lastMsg = result.messages[result.messages.length - 1];
expect(lastMsg.role).toBe("user");
expect(lastMsg.content).toContain("PLAN MODE ACTIVE");
});
@@ -143,7 +144,8 @@ describe("createPrepareStep", () => {
const result = await prepareStep({ ...defaultArgs, messages });
expect(result?.messages).toBeDefined();
- const injected = result!.messages!.slice(messages.length);
+ if (!result?.messages) throw new Error("expected messages");
+ const injected = result.messages.slice(messages.length);
const contents = injected.map((m) =>
typeof m.content === "string" ? m.content : "",
);
@@ -180,7 +182,8 @@ describe("createPrepareStep", () => {
state.isActive = true;
const r2 = await prepareStep({ ...defaultArgs, messages });
expect(r2?.messages).toBeDefined();
- const hasHint = r2!.messages!.some(
+ if (!r2?.messages) throw new Error("expected messages");
+ const hasHint = r2.messages.some(
(m) =>
m.role === "user" &&
typeof m.content === "string" &&
diff --git a/tests/helpers/mock-sandbox.ts b/tests/helpers/mock-sandbox.ts
index a945733..cf28826 100644
--- a/tests/helpers/mock-sandbox.ts
+++ b/tests/helpers/mock-sandbox.ts
@@ -156,10 +156,20 @@ export function createMockSandbox(
files[path] = content;
},
- deleteFile(path: string): void {
+ async deleteFile(path: string): Promise {
delete files[path];
},
+ async rename(oldPath: string, newPath: string): Promise {
+ if (!(oldPath in files)) {
+ throw new Error(
+ `ENOENT: no such file or directory, rename '${oldPath}'`,
+ );
+ }
+ files[newPath] = files[oldPath];
+ delete files[oldPath];
+ },
+
getExecHistory(): ExecHistoryEntry[] {
return [...execHistory];
},
@@ -189,7 +199,9 @@ export interface MockSandbox extends Sandbox {
/** Set a file or directory */
setFile(path: string, content: MockFileEntry): void;
/** Delete a file or directory */
- deleteFile(path: string): void;
+ deleteFile(path: string): Promise;
+ /** Rename/move a file */
+ rename(oldPath: string, newPath: string): Promise;
/** Get command execution history */
getExecHistory(): ExecHistoryEntry[];
/** Clear command execution history */
diff --git a/tests/sandbox/shell-quote.test.ts b/tests/sandbox/shell-quote.test.ts
new file mode 100644
index 0000000..b503109
--- /dev/null
+++ b/tests/sandbox/shell-quote.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { shellQuote } from "@/sandbox/shell-quote";
+
+describe("shellQuote", () => {
+ it("wraps a plain string in single quotes", () => {
+ expect(shellQuote("hello")).toBe("'hello'");
+ });
+
+ it("wraps an empty string in single quotes", () => {
+ expect(shellQuote("")).toBe("''");
+ });
+
+ it("escapes embedded single quotes as '\\''", () => {
+ expect(shellQuote("it's")).toBe(String.raw`'it'\''s'`);
+ });
+
+ it("escapes multiple embedded single quotes", () => {
+ expect(shellQuote("a'b'c")).toBe(String.raw`'a'\''b'\''c'`);
+ });
+
+ it("preserves dollar signs and backticks (single quotes disable expansion)", () => {
+ expect(shellQuote("$HOME")).toBe("'$HOME'");
+ expect(shellQuote("`whoami`")).toBe("'`whoami`'");
+ });
+
+ it("preserves double quotes (no escaping needed inside single quotes)", () => {
+ expect(shellQuote('"quoted"')).toBe(`'"quoted"'`);
+ });
+
+ it("preserves whitespace and special characters", () => {
+ expect(shellQuote("foo bar/baz qux")).toBe("'foo bar/baz qux'");
+ expect(shellQuote("path with newline\nhere")).toBe(
+ "'path with newline\nhere'",
+ );
+ });
+
+ it("handles paths with shell metacharacters safely", () => {
+ expect(shellQuote("; rm -rf /")).toBe("'; rm -rf /'");
+ expect(shellQuote("&& echo pwned")).toBe("'&& echo pwned'");
+ });
+});
diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts
index 7a06e49..f94e36c 100644
--- a/tests/tools/index.test.ts
+++ b/tests/tools/index.test.ts
@@ -38,6 +38,7 @@ describe("createAgentTools", () => {
expect(tools.EnterPlanMode).toBeUndefined();
expect(tools.ExitPlanMode).toBeUndefined();
expect(tools.Skill).toBeUndefined();
+ expect(tools.Patch).toBeUndefined();
});
it("should not return planModeState by default", async () => {
@@ -94,6 +95,30 @@ describe("createAgentTools", () => {
});
});
+ describe("Patch tool", () => {
+ it("should include Patch when enabled with patch: true", async () => {
+ const { tools } = await createAgentTools(sandbox, {
+ patch: true,
+ });
+
+ expect(tools.Patch).toBeDefined();
+ });
+
+ it("should include Patch when given a ToolConfig", async () => {
+ const { tools } = await createAgentTools(sandbox, {
+ patch: { allowedPaths: ["/tmp"] },
+ });
+
+ expect(tools.Patch).toBeDefined();
+ });
+
+ it("should not include Patch without config", async () => {
+ const { tools } = await createAgentTools(sandbox);
+
+ expect(tools.Patch).toBeUndefined();
+ });
+ });
+
describe("plan mode tools", () => {
it("should include plan mode tools when enabled", async () => {
const { tools, planModeState } = await createAgentTools(sandbox, {
diff --git a/tests/tools/patch.test.ts b/tests/tools/patch.test.ts
new file mode 100644
index 0000000..cf42c19
--- /dev/null
+++ b/tests/tools/patch.test.ts
@@ -0,0 +1,1021 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import type { Sandbox } from "@/sandbox/interface";
+import { createPatchTool, type PatchOutput } from "@/tools/patch";
+import { parsePatch, parseUpdateFileChunk } from "@/tools/patch/parser";
+import { seekSequence, normalizeUnicode } from "@/tools/patch/seek-sequence";
+import { deriveNewContents } from "@/tools/patch/apply";
+import {
+ createMockSandbox,
+ executeTool,
+ assertSuccess,
+ assertError,
+ type MockSandbox,
+} from "@test/helpers";
+
+// ============================================================
+// Parser Tests — matching Codex parser.rs test cases
+// ============================================================
+
+describe("parsePatch", () => {
+ it("should reject bad input", () => {
+ expect(() => parsePatch("bad")).toThrow(
+ "The first line of the patch must be '*** Begin Patch'",
+ );
+ });
+
+ it("should reject missing end marker", () => {
+ expect(() => parsePatch("*** Begin Patch\nbad")).toThrow(
+ "The last line of the patch must be '*** End Patch'",
+ );
+ });
+
+ it("should parse Add/Delete/Update in one patch", () => {
+ const result = parsePatch(
+ "*** Begin Patch\n" +
+ "*** Add File: path/add.py\n" +
+ "+abc\n" +
+ "+def\n" +
+ "*** Delete File: path/delete.py\n" +
+ "*** Update File: path/update.py\n" +
+ "*** Move to: path/update2.py\n" +
+ "@@ def f():\n" +
+ "- pass\n" +
+ "+ return 123\n" +
+ "*** End Patch",
+ );
+
+ expect(result.hunks).toHaveLength(3);
+
+ // Add hunk
+ const add = result.hunks[0];
+ expect(add.type).toBe("add");
+ if (add.type === "add") {
+ expect(add.path).toBe("path/add.py");
+ expect(add.content).toBe("abc\ndef\n");
+ }
+
+ // Delete hunk
+ const del = result.hunks[1];
+ expect(del.type).toBe("delete");
+ if (del.type === "delete") {
+ expect(del.path).toBe("path/delete.py");
+ }
+
+ // Update hunk with move and change_context
+ const upd = result.hunks[2];
+ expect(upd.type).toBe("update");
+ if (upd.type === "update") {
+ expect(upd.path).toBe("path/update.py");
+ expect(upd.movePath).toBe("path/update2.py");
+ expect(upd.chunks).toHaveLength(1);
+ expect(upd.chunks[0].changeContext).toBe("def f():");
+ expect(upd.chunks[0].oldLines).toEqual([" pass"]);
+ expect(upd.chunks[0].newLines).toEqual([" return 123"]);
+ expect(upd.chunks[0].isEndOfFile).toBe(false);
+ }
+ });
+
+ it("should parse empty patch (no hunks)", () => {
+ const result = parsePatch("*** Begin Patch\n*** End Patch");
+ expect(result.hunks).toHaveLength(0);
+ });
+
+ it("should parse Update hunk followed by Add hunk", () => {
+ const result = parsePatch(
+ "*** Begin Patch\n" +
+ "*** Update File: file.py\n" +
+ "@@\n" +
+ "+line\n" +
+ "*** Add File: other.py\n" +
+ "+content\n" +
+ "*** End Patch",
+ );
+ expect(result.hunks).toHaveLength(2);
+ const upd = result.hunks[0];
+ if (upd.type === "update") {
+ expect(upd.chunks[0].changeContext).toBeNull();
+ expect(upd.chunks[0].oldLines).toEqual([]);
+ expect(upd.chunks[0].newLines).toEqual(["line"]);
+ }
+ const add = result.hunks[1];
+ if (add.type === "add") {
+ expect(add.content).toBe("content\n");
+ }
+ });
+
+ it("should parse first chunk without @@ header (space-prefixed context goes to old+new)", () => {
+ // Codex test: " import foo\n+bar" without @@ → old=["import foo"], new=["import foo","bar"]
+ const result = parsePatch(
+ "*** Begin Patch\n" +
+ "*** Update File: file2.py\n" +
+ " import foo\n" +
+ "+bar\n" +
+ "*** End Patch",
+ );
+
+ expect(result.hunks).toHaveLength(1);
+ const upd = result.hunks[0];
+ if (upd.type === "update") {
+ expect(upd.chunks[0].changeContext).toBeNull();
+ expect(upd.chunks[0].oldLines).toEqual(["import foo"]);
+ expect(upd.chunks[0].newLines).toEqual(["import foo", "bar"]);
+ }
+ });
+
+ it("should reject empty Update file hunk", () => {
+ expect(() =>
+ parsePatch("*** Begin Patch\n*** Update File: test.py\n*** End Patch"),
+ ).toThrow("Update file hunk for path 'test.py' is empty");
+ });
+
+ it("should handle heredoc wrapping (lenient mode)", () => {
+ const result = parsePatch(
+ "<<'EOF'\n" +
+ "*** Begin Patch\n" +
+ "*** Delete File: src/old.ts\n" +
+ "*** End Patch\n" +
+ "EOF\n",
+ );
+ expect(result.hunks).toHaveLength(1);
+ expect(result.hunks[0].type).toBe("delete");
+ });
+
+ it("should handle trimmed Begin/End markers", () => {
+ const result = parsePatch(
+ " *** Begin Patch \n" +
+ "*** Delete File: test.ts\n" +
+ " *** End Patch ",
+ );
+ expect(result.hunks).toHaveLength(1);
+ });
+
+ it("should parse End of File marker", () => {
+ const result = parsePatch(
+ "*** Begin Patch\n" +
+ "*** Update File: test.ts\n" +
+ "@@\n" +
+ "+line\n" +
+ "*** End of File\n" +
+ "*** End Patch",
+ );
+ const upd = result.hunks[0];
+ if (upd.type === "update") {
+ expect(upd.chunks[0].isEndOfFile).toBe(true);
+ }
+ });
+
+ it("should parse multi-chunk updates with @@ headers", () => {
+ const result = parsePatch(
+ "*** Begin Patch\n" +
+ "*** Update File: src/main.ts\n" +
+ "@@ func a():\n" +
+ "-old_a\n" +
+ "+new_a\n" +
+ "@@ func b():\n" +
+ "-old_b\n" +
+ "+new_b\n" +
+ "*** End Patch",
+ );
+ const upd = result.hunks[0];
+ if (upd.type === "update") {
+ expect(upd.chunks).toHaveLength(2);
+ expect(upd.chunks[0].changeContext).toBe("func a():");
+ expect(upd.chunks[1].changeContext).toBe("func b():");
+ }
+ });
+});
+
+// ============================================================
+// parseUpdateFileChunk Tests — matching Codex test_update_file_chunk
+// ============================================================
+
+describe("parseUpdateFileChunk", () => {
+ it("should reject non-@@ start when context required", () => {
+ expect(() => parseUpdateFileChunk(["bad"], 123, false)).toThrow(
+ "Expected update hunk to start with a @@ context marker, got: 'bad'",
+ );
+ });
+
+ it("should reject bare @@ with no diff lines", () => {
+ expect(() => parseUpdateFileChunk(["@@"], 123, false)).toThrow(
+ "Update hunk does not contain any lines",
+ );
+ });
+
+ it("should reject @@ followed by non-diff line", () => {
+ expect(() => parseUpdateFileChunk(["@@", "bad"], 123, false)).toThrow(
+ "Unexpected line found in update hunk: 'bad'",
+ );
+ });
+
+ it("should reject @@ followed by End of File with no diff lines", () => {
+ expect(() =>
+ parseUpdateFileChunk(["@@", "*** End of File"], 123, false),
+ ).toThrow("Update hunk does not contain any lines");
+ });
+
+ it("should parse chunk with context, empty line, context, removal, addition, and trailing context", () => {
+ // Codex test: @@ change_context / (empty) / " context" / -remove / +add / " context2" / *** End Patch
+ const result = parseUpdateFileChunk(
+ [
+ "@@ change_context",
+ "",
+ " context",
+ "-remove",
+ "+add",
+ " context2",
+ "*** End Patch",
+ ],
+ 123,
+ false,
+ );
+ expect(result.chunk).toEqual({
+ changeContext: "change_context",
+ oldLines: ["", "context", "remove", "context2"],
+ newLines: ["", "context", "add", "context2"],
+ isEndOfFile: false,
+ });
+ expect(result.linesConsumed).toBe(6);
+ });
+
+ it("should parse chunk with End of File marker", () => {
+ const result = parseUpdateFileChunk(
+ ["@@", "+line", "*** End of File"],
+ 123,
+ false,
+ );
+ expect(result.chunk).toEqual({
+ changeContext: null,
+ oldLines: [],
+ newLines: ["line"],
+ isEndOfFile: true,
+ });
+ expect(result.linesConsumed).toBe(3);
+ });
+});
+
+// ============================================================
+// seekSequence Tests — matching Codex seek_sequence.rs tests
+// ============================================================
+
+describe("seekSequence", () => {
+ it("should find exact match", () => {
+ const lines = ["foo", "bar", "baz"];
+ expect(seekSequence(lines, ["bar", "baz"], 0, false)).toBe(1);
+ });
+
+ it("should find match with trailing whitespace (trimEnd)", () => {
+ const lines = ["foo ", "bar\t\t"];
+ expect(seekSequence(lines, ["foo", "bar"], 0, false)).toBe(0);
+ });
+
+ it("should find match with leading and trailing whitespace (trim)", () => {
+ const lines = [" foo ", " bar\t"];
+ expect(seekSequence(lines, ["foo", "bar"], 0, false)).toBe(0);
+ });
+
+ it("should return null when pattern longer than input", () => {
+ const lines = ["just one line"];
+ expect(seekSequence(lines, ["too", "many", "lines"], 0, false)).toBeNull();
+ });
+
+ it("should return start for empty pattern", () => {
+ expect(seekSequence(["a", "b"], [], 3, false)).toBe(3);
+ });
+
+ it("should return null when pattern not found", () => {
+ const lines = ["line 1", "line 2", "line 3"];
+ expect(seekSequence(lines, ["not found"], 0, false)).toBeNull();
+ });
+
+ it("should handle eof flag — search starts from end", () => {
+ const lines = ["a", "b", "c", "d"];
+ expect(seekSequence(lines, ["c", "d"], 0, true)).toBe(2);
+ });
+
+ it("should respect start parameter", () => {
+ const lines = ["target", "other", "target", "other"];
+ expect(seekSequence(lines, ["target"], 1, false)).toBe(2);
+ });
+
+ it("should prefer exact match over fuzzy at earlier position (global tier priority)", () => {
+ // Codex does 4 separate passes: exact match wins globally.
+ // Line 0 has trailing whitespace (trimEnd match only),
+ // Line 2 is exact match → should return 2, not 0.
+ const lines = ["hello ", "world", "hello"];
+ expect(seekSequence(lines, ["hello"], 0, false)).toBe(2);
+ });
+
+ it("should find Unicode-normalized match", () => {
+ const lines = ["\u201CHello\u201D", "world"];
+ expect(seekSequence(lines, ['"Hello"', "world"], 0, false)).toBe(0);
+ });
+});
+
+describe("normalizeUnicode", () => {
+ it("should normalize typographic dashes", () => {
+ expect(normalizeUnicode("a\u2014b")).toBe("a-b"); // em dash
+ expect(normalizeUnicode("a\u2013b")).toBe("a-b"); // en dash
+ expect(normalizeUnicode("a\u2212b")).toBe("a-b"); // minus sign
+ });
+
+ it("should normalize typographic quotes", () => {
+ expect(normalizeUnicode("\u2018hello\u2019")).toBe("'hello'");
+ expect(normalizeUnicode("\u201Chello\u201D")).toBe('"hello"');
+ });
+
+ it("should normalize special spaces", () => {
+ expect(normalizeUnicode("a\u00A0b")).toBe("a b");
+ expect(normalizeUnicode("a\u2003b")).toBe("a b");
+ });
+
+ it("should trim whitespace", () => {
+ expect(normalizeUnicode(" hello ")).toBe("hello");
+ });
+});
+
+// ============================================================
+// Apply Tests — matching Codex lib.rs test cases
+// ============================================================
+
+describe("deriveNewContents", () => {
+ it("should apply basic update (Codex: test_update_file_hunk_modifies_content)", () => {
+ // Original: "foo\nbar\n", patch: @@ / " foo" / "-bar" / "+baz"
+ const original = "foo\nbar\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: ["foo", "bar"],
+ newLines: ["foo", "baz"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(deriveNewContents(original, chunks, "test.txt")).toBe("foo\nbaz\n");
+ });
+
+ it("should apply move with replacement (Codex: test_update_file_hunk_can_move_file)", () => {
+ const original = "line\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: ["line"],
+ newLines: ["line2"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(deriveNewContents(original, chunks, "src.txt")).toBe("line2\n");
+ });
+
+ it("should apply multiple chunks to single file (Codex: test_multiple_update_chunks)", () => {
+ // foo\nbar\nbaz\nqux\n → foo\nBAR\nbaz\nQUX\n
+ const original = "foo\nbar\nbaz\nqux\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: ["foo", "bar"],
+ newLines: ["foo", "BAR"],
+ isEndOfFile: false,
+ },
+ {
+ changeContext: null,
+ oldLines: ["baz", "qux"],
+ newLines: ["baz", "QUX"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(deriveNewContents(original, chunks, "multi.txt")).toBe(
+ "foo\nBAR\nbaz\nQUX\n",
+ );
+ });
+
+ it("should handle interleaved changes with EOF append (Codex: test_update_file_hunk_interleaved_changes)", () => {
+ // a\nb\nc\nd\ne\nf\n → a\nB\nc\nd\nE\nf\ng\n
+ const original = "a\nb\nc\nd\ne\nf\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: ["a", "b"],
+ newLines: ["a", "B"],
+ isEndOfFile: false,
+ },
+ {
+ changeContext: null,
+ oldLines: ["c", "d", "e"],
+ newLines: ["c", "d", "E"],
+ isEndOfFile: false,
+ },
+ {
+ changeContext: null,
+ oldLines: ["f"],
+ newLines: ["f", "g"],
+ isEndOfFile: true,
+ },
+ ];
+ expect(deriveNewContents(original, chunks, "interleaved.txt")).toBe(
+ "a\nB\nc\nd\nE\nf\ng\n",
+ );
+ });
+
+ it("should handle pure addition followed by removal (Codex: test_pure_addition_chunk_followed_by_removal)", () => {
+ // line1\nline2\nline3\n
+ // Chunk 1: pure addition (old=[], new=["after-context","second-line"])
+ // Chunk 2: old=["line1","line2","line3"], new=["line1","line2-replacement"]
+ const original = "line1\nline2\nline3\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: [],
+ newLines: ["after-context", "second-line"],
+ isEndOfFile: false,
+ },
+ {
+ changeContext: null,
+ oldLines: ["line1", "line2", "line3"],
+ newLines: ["line1", "line2-replacement"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(deriveNewContents(original, chunks, "panic.txt")).toBe(
+ "line1\nline2-replacement\nafter-context\nsecond-line\n",
+ );
+ });
+
+ it("should handle update hunks that insert more than V8 argument limits", () => {
+ const insertedLines = Array.from(
+ { length: 70_000 },
+ (_, i) => `inserted-${i}`,
+ );
+ const result = deriveNewContents(
+ "before\nafter\n",
+ [
+ {
+ changeContext: null,
+ oldLines: ["before", "after"],
+ newLines: ["before", ...insertedLines, "after"],
+ isEndOfFile: false,
+ },
+ ],
+ "large.ts",
+ );
+
+ expect(result.startsWith("before\ninserted-0\n")).toBe(true);
+ expect(result).toContain("\ninserted-69999\nafter\n");
+ });
+
+ it("should throw when context not found", () => {
+ const original = "line1\nline2\n";
+ const chunks = [
+ {
+ changeContext: "nonexistent",
+ oldLines: ["line1"],
+ newLines: ["replaced"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(() => deriveNewContents(original, chunks, "test.ts")).toThrow(
+ "Failed to find context 'nonexistent' in test.ts searched from line 1. Read the file to verify current content before retrying.",
+ );
+ });
+
+ it("should throw when old lines not found", () => {
+ const original = "line1\nline2\n";
+ const chunks = [
+ {
+ changeContext: null,
+ oldLines: ["nonexistent"],
+ newLines: ["replaced"],
+ isEndOfFile: false,
+ },
+ ];
+ expect(() => deriveNewContents(original, chunks, "test.ts")).toThrow(
+ "Failed to find expected lines in test.ts searched from line 1:\nnonexistent\nRead the file to verify current content before retrying.",
+ );
+ });
+});
+
+// ============================================================
+// Tool Integration Tests
+// ============================================================
+
+describe("Patch Tool", () => {
+ let sandbox: MockSandbox;
+
+ beforeEach(() => {
+ sandbox = createMockSandbox({
+ files: {
+ "/workspace/src/main.ts": `export function hello() {\n return "world";\n}\n`,
+ "/workspace/src/utils.ts": `export function add(a: number, b: number) {\n return a + b;\n}\n\nexport function sub(a: number, b: number) {\n return a - b;\n}\n`,
+ "/workspace/src/old.ts": `// This file is deprecated\n`,
+ },
+ });
+ });
+
+ describe("Add File", () => {
+ it("should create a new file", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Add File: /workspace/src/new.ts
++export const x = 1;
++export const y = 2;
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].status).toBe("added");
+ expect(result.files[0].path).toBe("/workspace/src/new.ts");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/new.ts"]).toContain("export const x = 1;");
+ });
+ });
+
+ describe("Delete File", () => {
+ it("should delete an existing file", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /workspace/src/old.ts
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].status).toBe("deleted");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/old.ts"]).toBeUndefined();
+ });
+
+ it("should error when deleting non-existent file", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /workspace/src/missing.ts
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("File not found for deletion");
+ });
+ });
+
+ describe("Update File", () => {
+ it("should update an existing file with context line in diff body", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].status).toBe("modified");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toContain('"universe"');
+ expect(files["/workspace/src/main.ts"]).not.toContain('"world"');
+ });
+
+ it("should error when updating non-existent file", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/missing.ts
+@@
+-old
++new
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("File not found");
+ });
+
+ it("should handle update with change_context for narrowing", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toContain('"universe"');
+ });
+ });
+
+ describe("Multi-file patches", () => {
+ it("should apply changes to multiple files", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** Delete File: /workspace/src/old.ts
+*** Add File: /workspace/src/config.ts
++export const VERSION = "1.0.0";
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.files).toHaveLength(3);
+ expect(result.message).toContain("3 files");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toContain('"universe"');
+ expect(files["/workspace/src/old.ts"]).toBeUndefined();
+ expect(files["/workspace/src/config.ts"]).toContain("VERSION");
+ });
+ });
+
+ describe("Move/Rename", () => {
+ it("should rename a file during update", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+*** Move to: /workspace/src/renamed.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.files[0].path).toBe("/workspace/src/renamed.ts");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toBeUndefined();
+ expect(files["/workspace/src/renamed.ts"]).toContain('"universe"');
+ });
+ });
+
+ describe("allowedPaths", () => {
+ it("should block paths outside allowed paths", async () => {
+ const tool = createPatchTool(sandbox, {
+ allowedPaths: ["/workspace"],
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Add File: /etc/passwd
++bad content
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("Path not allowed");
+ });
+
+ it("should block delete of file outside allowed paths", async () => {
+ sandbox.setFile("/etc/config", "secret");
+ const tool = createPatchTool(sandbox, {
+ allowedPaths: ["/workspace"],
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /etc/config
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("Path not allowed");
+ });
+
+ it("should block Move destination outside allowed paths", async () => {
+ const tool = createPatchTool(sandbox, {
+ allowedPaths: ["/workspace"],
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+*** Move to: /etc/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("Path not allowed");
+ });
+
+ it("should allow paths within allowed paths", async () => {
+ const tool = createPatchTool(sandbox, {
+ allowedPaths: ["/workspace"],
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ });
+ });
+
+ describe("maxFileSize", () => {
+ it("should enforce maxFileSize on Add", async () => {
+ const tool = createPatchTool(sandbox, {
+ maxFileSize: 10,
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Add File: /workspace/src/big.ts
++this is a very long line that exceeds the limit
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("File too large");
+ });
+
+ it("should enforce maxFileSize on Update", async () => {
+ const tool = createPatchTool(sandbox, {
+ maxFileSize: 10,
+ });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "this is a very long replacement that exceeds the file size limit when combined with everything else";
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("File too large");
+ });
+ });
+
+ describe("Fuzzy whitespace matching", () => {
+ it("should match with trailing whitespace differences", async () => {
+ sandbox.setFile(
+ "/workspace/src/spaces.ts",
+ "function test() { \n return 42; \n}\n",
+ );
+
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/spaces.ts
+@@
+ function test() {
+- return 42;
++ return 99;
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/spaces.ts"]).toContain("return 99;");
+ });
+ });
+
+ describe("Unicode matching (Codex: test_update_line_with_unicode_dash)", () => {
+ it("should match ASCII patch against Unicode file content", async () => {
+ // Original has EN DASH and NON-BREAKING HYPHEN
+ sandbox.setFile(
+ "/workspace/src/unicode.py",
+ "import asyncio # local import \u2013 avoids top\u2011level dep\n",
+ );
+
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/unicode.py
+@@
+-import asyncio # local import - avoids top-level dep
++import asyncio # HELLO
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/unicode.py"]).toBe(
+ "import asyncio # HELLO\n",
+ );
+ });
+ });
+
+ describe("Error handling", () => {
+ it("should return error for invalid patch format", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: "this is not a valid patch",
+ });
+
+ assertError(result);
+ });
+
+ it("should return error for empty patch", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("no operations");
+ });
+
+ it("should handle single file success message", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /workspace/src/old.ts
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(result.message).toContain("/workspace/src/old.ts");
+ expect(result.message).not.toContain("files");
+ });
+ });
+
+ describe("Multi-chunk updates with @@ context narrowing", () => {
+ it("should apply multiple chunks using change_context to narrow position", async () => {
+ const tool = createPatchTool(sandbox);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/utils.ts
+@@ export function add(a: number, b: number) {
+- return a + b;
++ return a + b + 0;
+@@ export function sub(a: number, b: number) {
+- return a - b;
++ return a - b - 0;
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/utils.ts"]).toContain("a + b + 0");
+ expect(files["/workspace/src/utils.ts"]).toContain("a - b - 0");
+ });
+ });
+
+ describe("Pre-flight atomicity", () => {
+ it("does not apply earlier hunks when a later update has bad context", async () => {
+ const originalMain = sandbox.getFiles()["/workspace/src/main.ts"];
+ const originalUtils = sandbox.getFiles()["/workspace/src/utils.ts"];
+ const tool = createPatchTool(sandbox);
+
+ // Hunk 1 is valid; hunk 2 has a context line that doesn't exist.
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** Update File: /workspace/src/utils.ts
+@@
+ export function add(a: number, b: number) {
+- return a + b + nope;
++ return 999;
+*** End Patch`,
+ });
+
+ assertError(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toBe(originalMain);
+ expect(files["/workspace/src/utils.ts"]).toBe(originalUtils);
+ });
+
+ it("does not delete a file when a later add violates maxFileSize", async () => {
+ const originalOld = sandbox.getFiles()["/workspace/src/old.ts"];
+ const tool = createPatchTool(sandbox, { maxFileSize: 5 });
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /workspace/src/old.ts
+*** Add File: /workspace/src/big.ts
++this content is way more than five bytes
+*** End Patch`,
+ });
+
+ assertError(result);
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/old.ts"]).toBe(originalOld);
+ expect(files["/workspace/src/big.ts"]).toBeUndefined();
+ });
+ });
+
+ describe("Move target collision", () => {
+ it("errors when the move target already exists", async () => {
+ sandbox.setFile("/workspace/src/already-here.ts", "// existing\n");
+ const tool = createPatchTool(sandbox);
+
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Update File: /workspace/src/main.ts
+*** Move to: /workspace/src/already-here.ts
+@@
+ export function hello() {
+- return "world";
++ return "universe";
+*** End Patch`,
+ });
+
+ assertError(result);
+ expect(result.error).toContain("Move target already exists");
+
+ const files = sandbox.getFiles();
+ expect(files["/workspace/src/main.ts"]).toContain('"world"');
+ expect(files["/workspace/src/already-here.ts"]).toBe("// existing\n");
+ });
+ });
+
+ describe("Sandbox method fallbacks", () => {
+ it("falls back to exec(rm) when sandbox.deleteFile is undefined", async () => {
+ const rmCalls: string[] = [];
+ sandbox.setExecHandler((command) => {
+ if (command.startsWith("rm -- ")) {
+ rmCalls.push(command);
+ }
+ return {
+ stdout: "",
+ stderr: "",
+ exitCode: 0,
+ durationMs: 1,
+ interrupted: false,
+ };
+ });
+
+ // Wrap the mock sandbox with deleteFile/rename omitted so the patch tool
+ // exercises its exec-based fallback path. The wrapper proxies every other
+ // method (including readFile/fileExists/writeFile) to the mock.
+ const { deleteFile: _omitDelete, rename: _omitRename, ...rest } = sandbox;
+ const sandboxWithoutOptionalMethods: Sandbox = rest;
+
+ const tool = createPatchTool(sandboxWithoutOptionalMethods);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: /workspace/src/old.ts
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(rmCalls).toEqual(["rm -- '/workspace/src/old.ts'"]);
+ });
+
+ it("quotes paths with single quotes when falling back to exec", async () => {
+ const tricky = "/workspace/it's a file.ts";
+ sandbox.setFile(tricky, "// content\n");
+
+ const rmCalls: string[] = [];
+ sandbox.setExecHandler((command) => {
+ if (command.startsWith("rm -- ")) rmCalls.push(command);
+ return {
+ stdout: "",
+ stderr: "",
+ exitCode: 0,
+ durationMs: 1,
+ interrupted: false,
+ };
+ });
+
+ const { deleteFile: _omitDelete, rename: _omitRename, ...rest } = sandbox;
+ const sandboxWithoutOptionalMethods: Sandbox = rest;
+
+ const tool = createPatchTool(sandboxWithoutOptionalMethods);
+ const result = await executeTool(tool, {
+ patch: `*** Begin Patch
+*** Delete File: ${tricky}
+*** End Patch`,
+ });
+
+ assertSuccess(result);
+ expect(rmCalls).toEqual([
+ String.raw`rm -- '/workspace/it'\''s a file.ts'`,
+ ]);
+ });
+ });
+});