Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ Sixteen packages under `src/agent/directors/<id>/` register in `DIRECTOR_REGISTR
3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `task` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. `task(agent=skywalker)` is refused (primary is not a spawned worker). Primary omits the list so plugin profiles stay reachable.
4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus skywalker; plugin agent profiles still load and can override by id.
5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. Product mutation tools (`write_file` / `edit_file` / `delete_file`) live in CORE (and `SKYWALKER_TOOLS`) so they are advertised on the primary without a `tool_search` round-trip. DIY tiny/bounded edits on the parent; spawn build/docs directors for substantial work — a prompt judgment call, not a toolset strip. `PRIMARY_DENIED_PRODUCT_TOOLS` is gone. Shell file-writes stay denied; MCP tools are not re-filtered by a product-write deny list. Optional `writePaths` (when a profile sets it) only gate path-keyed product tools.

**Codex tool proxies.** When the active provider is Codex (`isCodexProviderName`), `createAgentToolset` and `runSubAgent` mount `apply_patch`, `shell`, and `update_plan` stringTools from `createCodexToolProxies`, all forwarding through the same posix `ToolRunner` seam (`runTool`) so permission plugins still apply. `apply_patch` parses the Codex envelope and forwards each op (`write_file` / `delete_file` / `read_file`). `shell` — the native Codex name is `shell`, not `exec_command`, per the pinned base-instructions text quoted in `codex-responses-adapter.ts`'s bridge message — normalizes Codex's `command` (string or `["bash","-lc",script]`-style argv array), `workdir`, and `timeout_ms` onto `run_shell`'s `{command, cwd?, timeout?}` and is gated by `allowShellFromCapabilities` (mirrors `allowDeleteFromCapabilities` against `run_shell`). `update_plan` maps Codex's `plan: [{step, status}]` onto `manage_tasks(action: "create")`; `pending`/`in_progress`/`completed` map to `todo`/`doing`/`done` — `manage_tasks`'s `cancelled` status has no Codex equivalent and is never produced by this proxy. Primary strips `apply_patch` after mount (Corbits DIY stays on `write_file` / `edit_file` / `delete_file`); `shell` and `update_plan` stay on primary (same classification as `run_shell` / `manage_tasks`). Build and docs leaf allowlists (`BUILD_TOOLS` / `DOCS_TOOLS`) include `apply_patch` so Codex workers keep the proxy after the capability filter. `CORE_TOOL_NAMES` does not list it.
6. Shipped directors omit `writePaths`. The optional field is still enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`) when a plugin/custom profile sets it.
7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/worker binary > parent inheritance. Optional skills are listed in the identity header for awareness; workers do not mount `use_skill` (guidance is baked into package system prompts). Primary mounts `use_skill` for its own skill list.

Expand Down
306 changes: 306 additions & 0 deletions src/agent/codex-apply-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { describe, expect, test } from "bun:test";
import {
CodexApplyPatchError,
applyUpdateHunks,
extractAffectedPaths,
parseCodexApplyPatch,
} from "./codex-apply-patch.js";

describe("parseCodexApplyPatch", () => {
test("parses Add File with Codex trailing newlines", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Add File: hello.txt
+Hello world
+second line
*** End Patch
`);
expect(patch.ops).toEqual([
{
type: "add",
path: "hello.txt",
content: "Hello world\nsecond line\n",
},
]);
});

test("Add File with no + lines yields empty content", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Add File: empty.txt
*** End Patch
`);
expect(patch.ops).toEqual([{ type: "add", path: "empty.txt", content: "" }]);
});

test("parses Delete File", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Delete File: obsolete.txt
*** End Patch
`);
expect(patch.ops).toEqual([{ type: "delete", path: "obsolete.txt" }]);
});

test("parses Update File with Move to", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** End Patch
`);
expect(patch.ops).toHaveLength(1);
const op = patch.ops[0]!;
expect(op.type).toBe("update");
if (op.type !== "update") throw new Error("unreachable");
expect(op.path).toBe("src/app.py");
expect(op.moveTo).toBe("src/main.py");
expect(op.hunks).toHaveLength(1);
expect(op.hunks[0]!.header).toBe("def greet():");
expect(op.hunks[0]!.lines).toEqual([
{ kind: "-", text: 'print("Hi")' },
{ kind: "+", text: 'print("Hello, world!")' },
]);
});

test("parses stacked multi-@@ context anchors as header-only then change hunk", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
@@ class BaseClass
@@ def method():
-old_line
+new_line
*** End Patch
`);
const op = patch.ops[0]!;
expect(op.type).toBe("update");
if (op.type !== "update") throw new Error("unreachable");
expect(op.hunks).toHaveLength(2);
expect(op.hunks[0]).toEqual({ header: "class BaseClass", lines: [] });
expect(op.hunks[1]!.header).toBe(" def method():");
expect(op.hunks[1]!.lines).toEqual([
{ kind: "-", text: "old_line" },
{ kind: "+", text: "new_line" },
]);
});

test("rejects bare empty @@ without lines", () => {
expect(() =>
parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
@@
*** End Patch
`),
).toThrow(/empty hunk/);
});

test("rejects malformed envelope (missing Begin)", () => {
expect(() =>
parseCodexApplyPatch(`*** Add File: a.txt
+hi
*** End Patch
`),
).toThrow(CodexApplyPatchError);
expect(() =>
parseCodexApplyPatch(`*** Add File: a.txt
+hi
*** End Patch
`),
).toThrow(/Begin Patch/);
});

test("rejects malformed envelope (missing End)", () => {
expect(() =>
parseCodexApplyPatch(`*** Begin Patch
*** Add File: a.txt
+hi
`),
).toThrow(/End Patch/);
});

test("rejects absolute paths", () => {
expect(() =>
parseCodexApplyPatch(`*** Begin Patch
*** Add File: /etc/passwd
+x
*** End Patch
`),
).toThrow(/relative/);

expect(() =>
parseCodexApplyPatch(`*** Begin Patch
*** Delete File: /tmp/x
*** End Patch
`),
).toThrow(/absolute/);

expect(() =>
parseCodexApplyPatch(`*** Begin Patch
*** Update File: C:\\Windows\\system32\\x
@@
-a
+b
*** End Patch
`),
).toThrow(/absolute/);
});
});

describe("extractAffectedPaths", () => {
test("multi-file path extraction", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Add File: hello.txt
+Hello
*** Update File: src/app.py
@@
-old
+new
*** Delete File: obsolete.txt
*** End Patch
`);
expect(extractAffectedPaths(patch)).toEqual([
"hello.txt",
"src/app.py",
"obsolete.txt",
]);
});

test("move path extraction includes source and destination", () => {
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
*** Move to: src/main.py
@@
-a
+b
*** End Patch
`);
expect(extractAffectedPaths(patch)).toEqual(["src/app.py", "src/main.py"]);
});
});

describe("applyUpdateHunks", () => {
test("applies a simple replacement hunk", () => {
const original = `def greet():
print("Hi")
print("bye")
`;
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** End Patch
`);
const op = patch.ops[0]!;
expect(op.type).toBe("update");
if (op.type !== "update") throw new Error("unreachable");
const updated = applyUpdateHunks(original, op.hunks);
expect(updated).toBe(`def greet():
print("Hello, world!")
print("bye")
`);
});

test("applies stacked multi-@@ anchors then replacement", () => {
const original = `class BaseClass
def method():
old_line
keep
`;
const patch = parseCodexApplyPatch(`*** Begin Patch
*** Update File: src/app.py
@@ class BaseClass
@@ def method():
- old_line
+ new_line
*** End Patch
`);
const op = patch.ops[0]!;
expect(op.type).toBe("update");
if (op.type !== "update") throw new Error("unreachable");
expect(applyUpdateHunks(original, op.hunks)).toBe(`class BaseClass
def method():
new_line
keep
`);
});

test("applies context-aware multi-line hunk", () => {
const original = `line1
line2
target
line4
`;
const hunks = [
{
lines: [
{ kind: " " as const, text: "line2" },
{ kind: "-" as const, text: "target" },
{ kind: "+" as const, text: "replaced" },
{ kind: " " as const, text: "line4" },
],
},
];
expect(applyUpdateHunks(original, hunks)).toBe(`line1
line2
replaced
line4
`);
});

test("fuzzy match: rstrip then trim after exact fail", () => {
const original = `foo
bar
baz
`;
const updated = applyUpdateHunks(original, [
{
lines: [
{ kind: "-", text: "bar" },
{ kind: "+", text: "qux" },
],
},
]);
expect(updated).toBe(`foo
qux
baz
`);

const padded = applyUpdateHunks(` foo \nbar\n`, [
{
lines: [
{ kind: "-", text: "foo" },
{ kind: "+", text: "FOO" },
],
},
]);
expect(padded).toBe(`FOO
bar
`);
});

test("NormalizeToLf: non-empty update result ends with newline", () => {
const updated = applyUpdateHunks("a\nb", [
{
lines: [
{ kind: "-", text: "b" },
{ kind: "+", text: "c" },
],
},
]);
expect(updated).toBe("a\nc\n");
expect(updated.endsWith("\n")).toBe(true);
});

test("throws when context cannot be found", () => {
expect(() =>
applyUpdateHunks("a\nb\n", [
{
lines: [
{ kind: "-", text: "missing" },
{ kind: "+", text: "x" },
],
},
]),
).toThrow(/failed to find expected lines/);
});
});
Loading
Loading