Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b8612f2b | |||
| 1ed41e2efa | |||
| f4d107092d | |||
| 3e5c47a62d | |||
| 364679126b | |||
| f17e7f8486 | |||
| 594fd1e8da | |||
| 8e403f9d4b | |||
| 6360899e1c | |||
| 65486f2878 | |||
| 64dc174d83 | |||
| a1266348d6 | |||
| 1a79ae57ec | |||
| 19642ad95f | |||
| 9cb4fe4099 |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "codex",
|
||||
"version": "1.0.2",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
"author": {
|
||||
"name": "OpenAI"
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>/);
|
||||
|
||||
@@ -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}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user