Compare commits

..

15 Commits

Author SHA1 Message Date
Dominik Kundel 98b8612f2b fix: handle ENOBUFS type check in git diff sizing 2026-04-07 21:35:40 -07:00
Dominik Kundel 1ed41e2efa address comments 2026-04-07 21:26:38 -07:00
Dominik Kundel f4d107092d fix: preserve untracked content in lightweight review 2026-04-07 20:29:55 -07:00
Dominik Kundel 3e5c47a62d Merge remote-tracking branch 'origin/main' into codex/plugin-large-diff-fix 2026-04-07 20:18:53 -07:00
Dominik Kundel 364679126b fix: avoid embedding large adversarial review diffs 2026-04-07 20:11:20 -07:00
Dominik Kundel f17e7f8486 fix: respect SHELL on Windows for Git Bash (#178) 2026-04-07 19:56:34 -07:00
Dominik Kundel 594fd1e8da Fix working-tree review crash on untracked directories (#166)
* fix: skip untracked directories in review context

* fix: skip broken untracked symlinks in reviews
2026-04-06 20:45:33 -07:00
VOIDXAI 8e403f9d4b tests: reduce background task timing flakiness (#37)
Co-authored-by: VOIDXAI <VOIDXAI@users.noreply.github.com>
2026-03-31 13:04:24 -07:00
Dominik Kundel 6360899e1c chore: bump plugin version to 1.0.2 (#74) 2026-03-31 12:59:31 -07:00
Dominik Kundel 65486f2878 ci: add pull request workflow for tests and build (#73) 2026-03-31 12:56:09 -07:00
Pranav 64dc174d83 Fix /codex:rescue AskUserQuestion contract (#43) 2026-03-31 12:44:30 -07:00
ZETA a1266348d6 fix: make test suite portable across platforms (#56)
* fix: make test suite portable across platforms

Replace hardcoded macOS path with fileURLToPath for cross-platform
compatibility. Add .cmd wrapper creation and platform-aware PATH
separator in test fixtures so the fake codex binary is discoverable
on Windows. Add shell and windowsHide options to the test helper
run() function to match production behavior.

Test results on Windows improve from 12/64 pass to 59/64 pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip shell for absolute-path commands to avoid Windows space-in-path breakage

When `process.execPath` resolves to a path with spaces (e.g.,
`C:\Program Files\nodejs\node.exe`), `shell: true` causes cmd.exe
to split the path at the space. Guard with `path.isAbsolute()` so
only bare command names (which need `.cmd` shim resolution) use the
shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Dominik Kundel <dkundel@openai.com>
2026-03-31 12:40:08 -07:00
ZETA 1a79ae57ec fix: resolve Windows ENOENT when spawning codex app-server (#55)
* fix: add shell and windowsHide options for Windows spawn in app-server

On Windows, spawn("codex", ["app-server"]) fails with ENOENT because
Node.js cannot resolve .cmd shims without shell: true. This adds
platform-gated shell and windowsHide options to the app-server spawn
call, and uses terminateProcessTree for proper process tree cleanup
since shell: true wraps the child in cmd.exe.

Without terminateProcessTree, plain SIGTERM only kills cmd.exe and
leaves the actual codex node process orphaned — verified with 274+
zombie node.exe processes accumulating on Windows.

Fixes #32
Fixes #46

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard terminateProcessTree against PID reuse after process exit

ChildProcess.killed only reflects whether .kill() was called by this
process — it stays false when the child exits on its own. On Windows,
where PIDs are recycled quickly, the 50 ms timer could fire after
cmd.exe has exited and its PID has been reassigned, causing taskkill
to terminate an unrelated process.

Add an exitCode === null check so the tree-kill path is skipped once
the child has already exited.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:39:32 -07:00
VOIDXAI 19642ad95f tests: derive repo roots from test file locations (#34)
Co-authored-by: VOIDXAI <VOIDXAI@users.noreply.github.com>
2026-03-31 12:33:32 -07:00
Dominik Kundel 9cb4fe4099 Bump v1.0.1 (#22)
* chore: bump plugin version to 1.0.1

* chore: bump plugin metadata version to 1.0.1
2026-03-30 19:23:08 -07:00
16 changed files with 349 additions and 47 deletions
+2 -2
View File
@@ -5,13 +5,13 @@
},
"metadata": {
"description": "Codex plugins to use in Claude Code for delegation and code review.",
"version": "1.0.0"
"version": "1.0.2"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.0",
"version": "1.0.2",
"author": {
"name": "OpenAI"
},
+35
View File
@@ -0,0 +1,35 @@
name: Pull Request CI
on:
pull_request:
permissions:
contents: read
jobs:
ci:
name: CI
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Codex CLI
run: npm install -g @openai/codex
- name: Run test suite
run: npm test
- name: Run build
run: npm run build
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"version": "1.0.2",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^25.5.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"version": "1.0.2",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "codex",
"version": "1.0.2",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
+1 -1
View File
@@ -2,7 +2,7 @@
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*)
allowed-tools: Bash(node:*), AskUserQuestion
---
Route this request to the `codex:codex-rescue` subagent.
@@ -32,6 +32,7 @@ Actively try to disprove the change.
Look for violated invariants, missing guards, unhandled failure paths, and assumptions that stop being true under stress.
Trace how bad inputs, retries, concurrent actions, or partially completed operations move through the code.
If the user supplied a focus area, weight it heavily, but still report any other material issue you can defend.
{{REVIEW_COLLECTION_GUIDANCE}}
</review_method>
<finding_bar>
@@ -241,6 +241,7 @@ function buildAdversarialReviewPrompt(context, focusText) {
REVIEW_KIND: "Adversarial Review",
TARGET_LABEL: context.target.label,
USER_FOCUS: focusText || "No extra focus provided.",
REVIEW_COLLECTION_GUIDANCE: context.collectionGuidance,
REVIEW_INPUT: context.content
});
}
+18 -3
View File
@@ -14,6 +14,7 @@ import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
import { terminateProcessTree } from "./process.mjs";
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8"));
@@ -188,7 +189,9 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
this.proc = spawn("codex", ["app-server"], {
cwd: this.cwd,
env: this.options.env,
stdio: ["pipe", "pipe", "pipe"]
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
windowsHide: true
});
this.proc.stdout.setEncoding("utf8");
@@ -237,8 +240,20 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
if (this.proc && !this.proc.killed) {
this.proc.stdin.end();
setTimeout(() => {
if (this.proc && !this.proc.killed) {
this.proc.kill("SIGTERM");
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
// On Windows with shell: true, the direct child is cmd.exe.
// Use terminateProcessTree to kill the entire tree including
// the grandchild node process.
if (process.platform === "win32") {
try {
terminateProcessTree(this.proc.pid);
} catch {
// Best-effort cleanup inside an unref'd timer — swallow errors
// to avoid crashing the host process during shutdown.
}
} else {
this.proc.kill("SIGTERM");
}
}
}, 50).unref?.();
}
+154 -30
View File
@@ -2,9 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import { isProbablyText } from "./fs.mjs";
import { runCommand, runCommandChecked } from "./process.mjs";
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
const MAX_UNTRACKED_BYTES = 24 * 1024;
const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
function git(cwd, args, options = {}) {
return runCommand("git", args, { cwd, ...options });
@@ -14,6 +16,64 @@ function gitChecked(cwd, args, options = {}) {
return runCommandChecked("git", args, { cwd, ...options });
}
function listUniqueFiles(...groups) {
return [...new Set(groups.flat().filter(Boolean))].sort();
}
function normalizeMaxInlineFiles(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_INLINE_DIFF_MAX_FILES;
}
return Math.floor(parsed);
}
function normalizeMaxInlineDiffBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_INLINE_DIFF_MAX_BYTES;
}
return Math.floor(parsed);
}
function measureGitOutputBytes(cwd, args, maxBytes) {
const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
return maxBytes + 1;
}
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(formatCommandFailure(result));
}
return Buffer.byteLength(result.stdout, "utf8");
}
function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
let totalBytes = 0;
for (const args of argSets) {
const remainingBytes = maxBytes - totalBytes;
if (remainingBytes < 0) {
return maxBytes + 1;
}
totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
if (totalBytes > maxBytes) {
return totalBytes;
}
}
return totalBytes;
}
function buildBranchComparison(cwd, baseRef) {
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
return {
mergeBase,
commitRange: `${mergeBase}..HEAD`,
reviewRange: `${baseRef}...HEAD`
};
}
export function ensureGitRepository(cwd) {
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
const errorCode = result.error && "code" in result.error ? result.error.code : null;
@@ -161,55 +221,115 @@ function formatUntrackedFile(cwd, relativePath) {
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
}
function collectWorkingTreeContext(cwd, state) {
const status = gitChecked(cwd, ["status", "--short"]).stdout.trim();
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
function collectWorkingTreeContext(cwd, state, options = {}) {
const includeDiff = options.includeDiff !== false;
const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
const parts = [
formatSection("Git Status", status),
formatSection("Staged Diff", stagedDiff),
formatSection("Unstaged Diff", unstagedDiff),
formatSection("Untracked Files", untrackedBody)
];
let parts;
if (includeDiff) {
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
parts = [
formatSection("Git Status", status),
formatSection("Staged Diff", stagedDiff),
formatSection("Unstaged Diff", unstagedDiff),
formatSection("Untracked Files", untrackedBody)
];
} else {
const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
parts = [
formatSection("Git Status", status),
formatSection("Staged Diff Stat", stagedStat),
formatSection("Unstaged Diff Stat", unstagedStat),
formatSection("Changed Files", changedFiles.join("\n")),
formatSection("Untracked Files", untrackedBody)
];
}
return {
mode: "working-tree",
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
content: parts.join("\n")
content: parts.join("\n"),
changedFiles
};
}
function collectBranchContext(cwd, baseRef) {
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
const commitRange = `${mergeBase}..HEAD`;
function collectBranchContext(cwd, baseRef, options = {}) {
const includeDiff = options.includeDiff !== false;
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
const currentBranch = getCurrentBranch(cwd);
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", commitRange]).stdout.trim();
const diffStat = gitChecked(cwd, ["diff", "--stat", commitRange]).stdout.trim();
const diff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", commitRange]).stdout;
const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
return {
mode: "branch",
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${mergeBase}.`,
content: [
formatSection("Commit Log", logOutput),
formatSection("Diff Stat", diffStat),
formatSection("Branch Diff", diff)
].join("\n")
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
content: includeDiff
? [
formatSection("Commit Log", logOutput),
formatSection("Diff Stat", diffStat),
formatSection(
"Branch Diff",
gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
)
].join("\n")
: [
formatSection("Commit Log", logOutput),
formatSection("Diff Stat", diffStat),
formatSection("Changed Files", changedFiles.join("\n"))
].join("\n"),
changedFiles,
comparison
};
}
export function collectReviewContext(cwd, target) {
function buildAdversarialCollectionGuidance(options = {}) {
if (options.includeDiff !== false) {
return "Use the repository context below as primary evidence.";
}
return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
}
export function collectReviewContext(cwd, target, options = {}) {
const repoRoot = getRepoRoot(cwd);
const state = getWorkingTreeState(cwd);
const currentBranch = getCurrentBranch(cwd);
const currentBranch = getCurrentBranch(repoRoot);
const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
let details;
let includeDiff;
let diffBytes;
if (target.mode === "working-tree") {
details = collectWorkingTreeContext(repoRoot, state);
const state = getWorkingTreeState(repoRoot);
diffBytes = measureCombinedGitOutputBytes(
repoRoot,
[
["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
],
maxInlineDiffBytes
);
includeDiff =
options.includeDiff ??
(listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
diffBytes <= maxInlineDiffBytes);
details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
} else {
details = collectBranchContext(repoRoot, target.baseRef);
const comparison = buildBranchComparison(repoRoot, target.baseRef);
const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
diffBytes = measureGitOutputBytes(
repoRoot,
["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
maxInlineDiffBytes
);
includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
}
return {
@@ -217,6 +337,10 @@ export function collectReviewContext(cwd, target) {
repoRoot,
branch: currentBranch,
target,
fileCount: details.changedFiles.length,
diffBytes,
inputMode: includeDiff ? "inline-diff" : "self-collect",
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
...details
};
}
+3 -1
View File
@@ -7,8 +7,10 @@ export function runCommand(command, args = [], options = {}) {
env: options.env,
encoding: "utf8",
input: options.input,
maxBuffer: options.maxBuffer,
stdio: options.stdio ?? "pipe",
shell: process.platform === "win32"
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
windowsHide: true
});
return {
+3 -1
View File
@@ -2,8 +2,9 @@ import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
const ROOT = "/Users/dkundel/code/codex-plugin";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");
function read(relativePath) {
@@ -89,6 +90,7 @@ test("rescue command absorbs continue semantics", () => {
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
assert.match(rescue, /The final user-visible response must be Codex's output verbatim/i);
assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion/);
assert.match(rescue, /--background\|--wait/);
assert.match(rescue, /--resume\|--fresh/);
assert.match(rescue, /--model <model\|spark>/);
+12 -2
View File
@@ -1,4 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { writeExecutable } from "./helpers.mjs";
@@ -465,7 +467,7 @@ rl.on("line", (line) => {
}
}
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}, 400);
}, 5000);
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
} else if (BEHAVIOR === "slow-task") {
emitTurnCompletedLater(thread.id, turnId, items, 400);
@@ -507,11 +509,19 @@ rl.on("line", (line) => {
});
`;
writeExecutable(scriptPath, source);
// On Windows, npm global binaries are invoked via .cmd wrappers.
// Create a codex.cmd so the fake binary is discoverable by spawn with shell: true.
if (process.platform === "win32") {
const cmdWrapper = `@echo off\r\nnode "%~dp0codex" %*\r\n`;
fs.writeFileSync(path.join(binDir, "codex.cmd"), cmdWrapper, { encoding: "utf8" });
}
}
export function buildEnv(binDir) {
const sep = process.platform === "win32" ? ";" : ":";
return {
...process.env,
PATH: `${binDir}:${process.env.PATH}`
PATH: `${binDir}${sep}${process.env.PATH}`
};
}
+80
View File
@@ -69,6 +69,23 @@ test("resolveReviewTarget requires an explicit base when no default branch can b
);
});
test("collectReviewContext keeps inline diffs for tiny adversarial reviews", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('INLINE_MARKER');\n");
const target = resolveReviewTarget(cwd, {});
const context = collectReviewContext(cwd, target);
assert.equal(context.inputMode, "inline-diff");
assert.equal(context.fileCount, 1);
assert.match(context.collectionGuidance, /primary evidence/i);
assert.match(context.content, /INLINE_MARKER/);
});
test("collectReviewContext skips untracked directories in working tree review", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
@@ -101,3 +118,66 @@ test("collectReviewContext skips broken untracked symlinks instead of crashing",
assert.match(context.content, /### broken-link/);
assert.match(context.content, /skipped: broken symlink or unreadable file/i);
});
test("collectReviewContext falls back to lightweight context for larger adversarial reviews", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
for (const name of ["a.js", "b.js", "c.js"]) {
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
}
run("git", ["add", "a.js", "b.js", "c.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "SELF_COLLECT_MARKER_A";\n');
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "SELF_COLLECT_MARKER_B";\n');
fs.writeFileSync(path.join(cwd, "c.js"), 'export const value = "SELF_COLLECT_MARKER_C";\n');
const target = resolveReviewTarget(cwd, {});
const context = collectReviewContext(cwd, target);
assert.equal(context.inputMode, "self-collect");
assert.equal(context.fileCount, 3);
assert.match(context.collectionGuidance, /lightweight summary/i);
assert.match(context.collectionGuidance, /read-only git commands/i);
assert.doesNotMatch(context.content, /SELF_COLLECT_MARKER_[ABC]/);
assert.match(context.content, /## Changed Files/);
});
test("collectReviewContext falls back to lightweight context for oversized single-file diffs", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "export const value = 'v1';\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
fs.writeFileSync(path.join(cwd, "app.js"), `export const value = '${"x".repeat(512)}';\n`);
const target = resolveReviewTarget(cwd, {});
const context = collectReviewContext(cwd, target, { maxInlineDiffBytes: 128 });
assert.equal(context.fileCount, 1);
assert.equal(context.inputMode, "self-collect");
assert.ok(context.diffBytes > 128);
assert.doesNotMatch(context.content, /xxx/);
assert.match(context.content, /## Changed Files/);
});
test("collectReviewContext keeps untracked file content in lightweight working tree context", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
for (const name of ["a.js", "b.js"]) {
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
}
run("git", ["add", "a.js", "b.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "TRACKED_MARKER_A";\n');
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "TRACKED_MARKER_B";\n');
fs.writeFileSync(path.join(cwd, "new-risk.js"), 'export const value = "UNTRACKED_RISK_MARKER";\n');
const target = resolveReviewTarget(cwd, {});
const context = collectReviewContext(cwd, target);
assert.equal(context.inputMode, "self-collect");
assert.equal(context.fileCount, 3);
assert.doesNotMatch(context.content, /TRACKED_MARKER_[AB]/);
assert.match(context.content, /## Untracked Files/);
assert.match(context.content, /UNTRACKED_RISK_MARKER/);
});
+4 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
export function makeTempDir(prefix = "codex-plugin-test-") {
@@ -16,7 +17,9 @@ export function run(command, args, options = {}) {
cwd: options.cwd,
env: options.env,
encoding: "utf8",
input: options.input
input: options.input,
shell: process.platform === "win32" && !path.isAbsolute(command),
windowsHide: true
});
}
+31 -3
View File
@@ -3,13 +3,14 @@ import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";
import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs";
const ROOT = "/Users/dkundel/code/codex-plugin";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");
const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs");
const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs");
@@ -147,6 +148,33 @@ test("adversarial review accepts the same base-branch targeting as review", () =
assert.match(result.stdout, /Missing empty-state guard/);
});
test("adversarial review asks Codex to inspect larger diffs itself", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir);
initGitRepo(repo);
fs.mkdirSync(path.join(repo, "src"));
for (const name of ["a.js", "b.js", "c.js"]) {
fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`);
}
run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n');
fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n');
fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n');
const result = run("node", [SCRIPT, "adversarial-review"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
assert.match(state.lastTurnStart.prompt, /lightweight summary/i);
assert.match(state.lastTurnStart.prompt, /read-only git commands/i);
assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/);
});
test("review includes reasoning output when the app server returns it", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
@@ -553,7 +581,7 @@ test("task --background enqueues a detached worker and exposes per-job status",
const waitedStatus = run(
"node",
[SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "5000", "--json"],
[SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"],
{
cwd: repo,
env: buildEnv(binDir)
@@ -1275,7 +1303,7 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok
return job;
}
return null;
});
}, { timeoutMs: 15000 });
const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], {
cwd: repo,