chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs";
|
||||
|
||||
test("createBrokerEndpoint uses Unix sockets on non-Windows platforms", () => {
|
||||
const endpoint = createBrokerEndpoint("/tmp/cxc-12345", "darwin");
|
||||
assert.equal(endpoint, "unix:/tmp/cxc-12345/broker.sock");
|
||||
assert.deepEqual(parseBrokerEndpoint(endpoint), {
|
||||
kind: "unix",
|
||||
path: "/tmp/cxc-12345/broker.sock"
|
||||
});
|
||||
});
|
||||
|
||||
test("createBrokerEndpoint uses named pipes on Windows", () => {
|
||||
const endpoint = createBrokerEndpoint("C:\\\\Temp\\\\cxc-12345", "win32");
|
||||
assert.equal(endpoint, "pipe:\\\\.\\pipe\\cxc-12345-codex-app-server");
|
||||
assert.deepEqual(parseBrokerEndpoint(endpoint), {
|
||||
kind: "pipe",
|
||||
path: "\\\\.\\pipe\\cxc-12345-codex-app-server"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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";
|
||||
|
||||
import { makeTempDir, run } from "./helpers.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SCRIPT = path.join(ROOT, "scripts", "bump-version.mjs");
|
||||
|
||||
function writeJson(filePath, json) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function makeVersionFixture() {
|
||||
const root = makeTempDir();
|
||||
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, "package-lock.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
}
|
||||
}
|
||||
});
|
||||
writeJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json"), {
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, ".claude-plugin", "marketplace.json"), {
|
||||
metadata: {
|
||||
version: "1.0.2"
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
test("bump-version updates every release manifest", () => {
|
||||
const root = makeVersionFixture();
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "1.2.3"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(readJson(path.join(root, "package.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).packages[""].version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).metadata.version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).plugins[0].version, "1.2.3");
|
||||
});
|
||||
|
||||
test("bump-version check mode reports stale metadata", () => {
|
||||
const root = makeVersionFixture();
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.3"
|
||||
});
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "--check"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /plugins\/codex\/\.claude-plugin\/plugin\.json version/);
|
||||
assert.match(result.stderr, /\.claude-plugin\/marketplace\.json metadata\.version/);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
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 = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(PLUGIN_ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("review command uses AskUserQuestion and background Bash while staying review-only", () => {
|
||||
const source = read("commands/review.md");
|
||||
assert.match(source, /AskUserQuestion/);
|
||||
assert.match(source, /\bBash\(/);
|
||||
assert.match(source, /Do not fix issues/i);
|
||||
assert.match(source, /review-only/i);
|
||||
assert.match(source, /return Codex's output verbatim to the user/i);
|
||||
assert.match(source, /```bash/);
|
||||
assert.match(source, /```typescript/);
|
||||
assert.match(source, /review "\$ARGUMENTS"/);
|
||||
assert.match(source, /\[--scope auto\|working-tree\|branch\]/);
|
||||
assert.match(source, /run_in_background:\s*true/);
|
||||
assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" review "\$ARGUMENTS"`/);
|
||||
assert.match(source, /description:\s*"Codex review"/);
|
||||
assert.match(source, /Do not call `BashOutput`/);
|
||||
assert.match(source, /Return the command stdout verbatim, exactly as-is/i);
|
||||
assert.match(source, /git status --short --untracked-files=all/);
|
||||
assert.match(source, /git diff --shortstat/);
|
||||
assert.match(source, /Treat untracked files or directories as reviewable work/i);
|
||||
assert.match(source, /Recommend waiting only when the review is clearly tiny, roughly 1-2 files total/i);
|
||||
assert.match(source, /In every other case, including unclear size, recommend background/i);
|
||||
assert.match(source, /The companion script parses `--wait` and `--background`/i);
|
||||
assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i);
|
||||
assert.match(source, /When in doubt, run the review/i);
|
||||
assert.match(source, /\(Recommended\)/);
|
||||
assert.match(source, /does not support staged-only review, unstaged-only review, or extra focus text/i);
|
||||
});
|
||||
|
||||
test("adversarial review command uses AskUserQuestion and background Bash while staying review-only", () => {
|
||||
const source = read("commands/adversarial-review.md");
|
||||
assert.match(source, /AskUserQuestion/);
|
||||
assert.match(source, /\bBash\(/);
|
||||
assert.match(source, /Do not fix issues/i);
|
||||
assert.match(source, /review-only/i);
|
||||
assert.match(source, /return Codex's output verbatim to the user/i);
|
||||
assert.match(source, /```bash/);
|
||||
assert.match(source, /```typescript/);
|
||||
assert.match(source, /adversarial-review "\$ARGUMENTS"/);
|
||||
assert.match(source, /\[--scope auto\|working-tree\|branch\] \[focus \.\.\.\]/);
|
||||
assert.match(source, /run_in_background:\s*true/);
|
||||
assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" adversarial-review "\$ARGUMENTS"`/);
|
||||
assert.match(source, /description:\s*"Codex adversarial review"/);
|
||||
assert.match(source, /Do not call `BashOutput`/);
|
||||
assert.match(source, /Return the command stdout verbatim, exactly as-is/i);
|
||||
assert.match(source, /git status --short --untracked-files=all/);
|
||||
assert.match(source, /git diff --shortstat/);
|
||||
assert.match(source, /Treat untracked files or directories as reviewable work/i);
|
||||
assert.match(source, /Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total/i);
|
||||
assert.match(source, /In every other case, including unclear size, recommend background/i);
|
||||
assert.match(source, /The companion script parses `--wait` and `--background`/i);
|
||||
assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i);
|
||||
assert.match(source, /When in doubt, run the review/i);
|
||||
assert.match(source, /\(Recommended\)/);
|
||||
assert.match(source, /uses the same review target selection as `\/codex:review`/i);
|
||||
assert.match(source, /supports working-tree review, branch review, and `--base <ref>`/i);
|
||||
assert.match(source, /does not support `--scope staged` or `--scope unstaged`/i);
|
||||
assert.match(source, /can still take extra focus text after the flags/i);
|
||||
});
|
||||
|
||||
test("continue is not exposed as a user-facing command", () => {
|
||||
const commandFiles = fs.readdirSync(path.join(PLUGIN_ROOT, "commands")).sort();
|
||||
assert.deepEqual(commandFiles, [
|
||||
"adversarial-review.md",
|
||||
"cancel.md",
|
||||
"rescue.md",
|
||||
"result.md",
|
||||
"review.md",
|
||||
"setup.md",
|
||||
"status.md",
|
||||
"transfer.md"
|
||||
]);
|
||||
});
|
||||
|
||||
test("rescue command absorbs continue semantics", () => {
|
||||
const rescue = read("commands/rescue.md");
|
||||
const agent = read("agents/codex-rescue.md");
|
||||
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
|
||||
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,\s*Agent/);
|
||||
// Regression for #234: `Skill(codex:rescue)` from the main agent recursed
|
||||
// because rescue.md named the routing with ambiguous prose ("Route this
|
||||
// request to the `codex:codex-rescue` subagent") while running under
|
||||
// `context: fork` — forked general-purpose subagents do not expose the
|
||||
// `Agent` tool, so the fork fell back to `Skill` and re-entered this
|
||||
// command. Pin the explicit transport and the inline (no-fork) execution.
|
||||
assert.match(rescue, /subagent_type: "codex:codex-rescue"/);
|
||||
assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i);
|
||||
assert.doesNotMatch(rescue, /^context:\s*fork\b/m);
|
||||
assert.match(rescue, /--background\|--wait/);
|
||||
assert.match(rescue, /--resume\|--fresh/);
|
||||
assert.match(rescue, /--model <model\|spark>/);
|
||||
assert.match(rescue, /--effort <none\|minimal\|low\|medium\|high\|xhigh>/);
|
||||
assert.match(rescue, /task-resume-candidate --json/);
|
||||
assert.match(rescue, /AskUserQuestion/);
|
||||
assert.match(rescue, /Continue current Codex thread/);
|
||||
assert.match(rescue, /Start a new Codex thread/);
|
||||
assert.match(rescue, /run the `codex:codex-rescue` subagent in the background/i);
|
||||
assert.match(rescue, /default to foreground/i);
|
||||
assert.match(rescue, /Do not forward them to `task`/i);
|
||||
assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i);
|
||||
assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i);
|
||||
assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i);
|
||||
assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i);
|
||||
assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i);
|
||||
assert.match(rescue, /If the user chooses continue, add `--resume`/i);
|
||||
assert.match(rescue, /If the user chooses a new thread, add `--fresh`/i);
|
||||
assert.match(rescue, /thin forwarder only/i);
|
||||
assert.match(rescue, /Return the Codex companion stdout verbatim to the user/i);
|
||||
assert.match(rescue, /Do not paraphrase, summarize, rewrite, or add commentary before or after it/i);
|
||||
assert.match(rescue, /return that command's stdout as-is/i);
|
||||
assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i);
|
||||
assert.match(agent, /--resume/);
|
||||
assert.match(agent, /--fresh/);
|
||||
assert.match(agent, /thin forwarding wrapper/i);
|
||||
assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i);
|
||||
assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i);
|
||||
assert.match(agent, /Use exactly one `Bash` call/i);
|
||||
assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i);
|
||||
assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i);
|
||||
assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i);
|
||||
assert.match(agent, /Leave model unset by default/i);
|
||||
assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i);
|
||||
assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i);
|
||||
assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i);
|
||||
assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
|
||||
assert.match(agent, /gpt-5-4-prompting/);
|
||||
assert.match(agent, /only to tighten the user's request into a better Codex prompt/i);
|
||||
assert.match(agent, /Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work/i);
|
||||
assert.match(runtimeSkill, /only job is to invoke `task` once and return that stdout unchanged/i);
|
||||
assert.match(runtimeSkill, /Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel`/i);
|
||||
assert.match(runtimeSkill, /use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt/i);
|
||||
assert.match(runtimeSkill, /That prompt drafting is the only Claude-side work allowed/i);
|
||||
assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i);
|
||||
assert.match(runtimeSkill, /Leave model unset by default/i);
|
||||
assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i);
|
||||
assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i);
|
||||
assert.match(runtimeSkill, /Strip it before calling `task`/i);
|
||||
assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i);
|
||||
assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i);
|
||||
assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
|
||||
assert.match(readme, /`codex:codex-rescue` subagent/i);
|
||||
assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i);
|
||||
assert.match(readme, /--model gpt-5\.4-mini --effort medium/i);
|
||||
assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i);
|
||||
assert.match(readme, /continue a previous Codex task/i);
|
||||
assert.match(readme, /### `\/codex:setup`/);
|
||||
assert.match(readme, /### `\/codex:review`/);
|
||||
assert.match(readme, /### `\/codex:adversarial-review`/);
|
||||
assert.match(readme, /uses the same review target selection as `\/codex:review`/i);
|
||||
assert.match(readme, /--base main challenge whether this was the right caching and retry design/);
|
||||
assert.match(readme, /### `\/codex:rescue`/);
|
||||
assert.match(readme, /### `\/codex:transfer`/);
|
||||
assert.match(readme, /### `\/codex:status`/);
|
||||
assert.match(readme, /### `\/codex:result`/);
|
||||
assert.match(readme, /### `\/codex:cancel`/);
|
||||
});
|
||||
|
||||
test("transfer, result, and cancel commands are exposed as deterministic runtime entrypoints", () => {
|
||||
const transfer = read("commands/transfer.md");
|
||||
const result = read("commands/result.md");
|
||||
const cancel = read("commands/cancel.md");
|
||||
const resultHandling = read("skills/codex-result-handling/SKILL.md");
|
||||
|
||||
assert.match(transfer, /disable-model-invocation:\s*true/);
|
||||
assert.match(transfer, /codex-companion\.mjs" transfer "\$ARGUMENTS"/);
|
||||
assert.match(transfer, /codex resume <session-id>/);
|
||||
assert.match(result, /disable-model-invocation:\s*true/);
|
||||
assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/);
|
||||
assert.match(cancel, /disable-model-invocation:\s*true/);
|
||||
assert.match(cancel, /codex-companion\.mjs" cancel "\$ARGUMENTS"/);
|
||||
assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i);
|
||||
assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i);
|
||||
});
|
||||
|
||||
test("internal docs use task terminology for rescue runs", () => {
|
||||
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
|
||||
const promptingSkill = read("skills/gpt-5-4-prompting/SKILL.md");
|
||||
const promptRecipes = read("skills/gpt-5-4-prompting/references/codex-prompt-recipes.md");
|
||||
|
||||
assert.match(runtimeSkill, /codex-companion\.mjs" task "<raw arguments>"/);
|
||||
assert.match(runtimeSkill, /Use `task` for every rescue request/i);
|
||||
assert.match(runtimeSkill, /task --resume-last/i);
|
||||
assert.match(promptingSkill, /Use `task` when the task is diagnosis/i);
|
||||
assert.match(promptRecipes, /Codex task prompts/i);
|
||||
assert.match(promptRecipes, /Use these as starting templates for Codex task prompts/i);
|
||||
assert.match(promptRecipes, /## Diagnosis/);
|
||||
assert.match(promptRecipes, /## Narrow Fix/);
|
||||
});
|
||||
|
||||
test("hooks keep session-end cleanup and stop gating enabled", () => {
|
||||
const source = read("hooks/hooks.json");
|
||||
assert.match(source, /SessionStart/);
|
||||
assert.match(source, /SessionEnd/);
|
||||
assert.match(source, /stop-review-gate-hook\.mjs/);
|
||||
assert.match(source, /session-lifecycle-hook\.mjs/);
|
||||
});
|
||||
|
||||
test("setup command can offer Codex install and still points users to codex login", () => {
|
||||
const setup = read("commands/setup.md");
|
||||
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
|
||||
|
||||
assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/);
|
||||
assert.match(setup, /AskUserQuestion/);
|
||||
assert.match(setup, /npm install -g @openai\/codex/);
|
||||
assert.match(setup, /codex-companion\.mjs" setup --json \$ARGUMENTS/);
|
||||
assert.match(readme, /!codex login/);
|
||||
assert.match(readme, /offer to install Codex for you/i);
|
||||
assert.match(readme, /\/codex:setup --enable-review-gate/);
|
||||
assert.match(readme, /\/codex:setup --disable-review-gate/);
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { writeExecutable } from "./helpers.mjs";
|
||||
|
||||
export function installFakeCodex(binDir, behavior = "review-ok") {
|
||||
const statePath = path.join(binDir, "fake-codex-state.json");
|
||||
const scriptPath = path.join(binDir, "codex");
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const STATE_PATH = ${JSON.stringify(statePath)};
|
||||
const BEHAVIOR = ${JSON.stringify(behavior)};
|
||||
const interruptibleTurns = new Map();
|
||||
|
||||
function loadState() {
|
||||
if (!fs.existsSync(STATE_PATH)) {
|
||||
return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null };
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function requiresExperimental(field, message, state) {
|
||||
if (!(field in (message.params || {}))) {
|
||||
return false;
|
||||
}
|
||||
return !state.capabilities || state.capabilities.experimentalApi !== true;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function buildThread(thread) {
|
||||
return {
|
||||
id: thread.id,
|
||||
preview: thread.preview || "",
|
||||
ephemeral: Boolean(thread.ephemeral),
|
||||
modelProvider: "openai",
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: thread.cwd,
|
||||
cliVersion: "fake-codex",
|
||||
source: "appServer",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: thread.name || null,
|
||||
turns: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildTurn(id, status = "inProgress", error = null) {
|
||||
return { id, status, items: [], error };
|
||||
}
|
||||
|
||||
function buildAccountReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "logged-out":
|
||||
case "refreshable-auth":
|
||||
case "auth-run-fails":
|
||||
return { account: null, requiresOpenaiAuth: true };
|
||||
case "provider-no-auth":
|
||||
case "env-key-provider":
|
||||
return { account: null, requiresOpenaiAuth: false };
|
||||
case "api-key-account-only":
|
||||
return { account: { type: "apiKey" }, requiresOpenaiAuth: true };
|
||||
default:
|
||||
return {
|
||||
account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
|
||||
requiresOpenaiAuth: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "provider-no-auth":
|
||||
return {
|
||||
config: { model_provider: "ollama" },
|
||||
origins: {}
|
||||
};
|
||||
case "env-key-provider":
|
||||
return {
|
||||
config: {
|
||||
model_provider: "openai-custom",
|
||||
model_providers: {
|
||||
"openai-custom": {
|
||||
name: "OpenAI custom",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
requires_openai_auth: false
|
||||
}
|
||||
}
|
||||
},
|
||||
origins: {}
|
||||
};
|
||||
default:
|
||||
return {
|
||||
config: { model_provider: "openai" },
|
||||
origins: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
}
|
||||
|
||||
function nextThread(state, cwd, ephemeral) {
|
||||
const thread = {
|
||||
id: "thr_" + state.nextThreadId++,
|
||||
cwd: cwd || process.cwd(),
|
||||
name: null,
|
||||
preview: "",
|
||||
ephemeral: Boolean(ephemeral),
|
||||
createdAt: now(),
|
||||
updatedAt: now()
|
||||
};
|
||||
state.threads.unshift(thread);
|
||||
saveState(state);
|
||||
return thread;
|
||||
}
|
||||
|
||||
function ensureThread(state, threadId) {
|
||||
const thread = state.threads.find((candidate) => candidate.id === threadId);
|
||||
if (!thread) {
|
||||
throw new Error("unknown thread " + threadId);
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
function nextTurnId(state) {
|
||||
const turnId = "turn_" + state.nextTurnId++;
|
||||
saveState(state);
|
||||
return turnId;
|
||||
}
|
||||
|
||||
function importLedgerPath() {
|
||||
return path.join(process.env.CODEX_HOME || path.join(process.env.HOME, ".codex"), "external_agent_session_imports.json");
|
||||
}
|
||||
|
||||
function loadImportLedger() {
|
||||
const ledgerPath = importLedgerPath();
|
||||
return fs.existsSync(ledgerPath) ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) : { records: [] };
|
||||
}
|
||||
|
||||
function saveImportLedger(ledger) {
|
||||
const ledgerPath = importLedgerPath();
|
||||
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
||||
fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2));
|
||||
}
|
||||
|
||||
function emitTurnCompleted(threadId, turnId, item) {
|
||||
const items = Array.isArray(item) ? item : [item];
|
||||
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
|
||||
for (const entry of items) {
|
||||
if (entry && entry.started) {
|
||||
send({ method: "item/started", params: { threadId, turnId, item: entry.started } });
|
||||
}
|
||||
if (entry && entry.completed) {
|
||||
send({ method: "item/completed", params: { threadId, turnId, item: entry.completed } });
|
||||
}
|
||||
}
|
||||
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } });
|
||||
}
|
||||
|
||||
function emitTurnCompletedLater(threadId, turnId, item, delayMs) {
|
||||
setTimeout(() => {
|
||||
emitTurnCompleted(threadId, turnId, item);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function nativeReviewText(target) {
|
||||
if (target.type === "baseBranch") {
|
||||
return "Reviewed changes against " + target.branch + ".\\nNo material issues found.";
|
||||
}
|
||||
if (target.type === "custom") {
|
||||
return "Reviewed custom target.\\nNo material issues found.";
|
||||
}
|
||||
return "Reviewed uncommitted changes.\\nNo material issues found.";
|
||||
}
|
||||
|
||||
function structuredReviewPayload(prompt) {
|
||||
if (prompt.includes("adversarial software review")) {
|
||||
if (BEHAVIOR === "adversarial-clean") {
|
||||
return JSON.stringify({
|
||||
verdict: "approve",
|
||||
summary: "No material issues found.",
|
||||
findings: [],
|
||||
next_steps: []
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
verdict: "needs-attention",
|
||||
summary: "One adversarial concern surfaced.",
|
||||
findings: [
|
||||
{
|
||||
severity: "high",
|
||||
title: "Missing empty-state guard",
|
||||
body: "The change assumes data is always present.",
|
||||
file: "src/app.js",
|
||||
line_start: 4,
|
||||
line_end: 6,
|
||||
confidence: 0.87,
|
||||
recommendation: "Handle empty collections before indexing."
|
||||
}
|
||||
],
|
||||
next_steps: ["Add an empty-state test."]
|
||||
});
|
||||
}
|
||||
|
||||
if (BEHAVIOR === "invalid-json") {
|
||||
return "not valid json";
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
verdict: "approve",
|
||||
summary: "No material issues found.",
|
||||
findings: [],
|
||||
next_steps: []
|
||||
});
|
||||
}
|
||||
|
||||
function taskPayload(prompt, resume) {
|
||||
if (prompt.includes("<task>") && prompt.includes("Only review the work from the previous Claude turn.")) {
|
||||
if (BEHAVIOR === "adversarial-clean") {
|
||||
return "ALLOW: No blocking issues found in the previous turn.";
|
||||
}
|
||||
return "BLOCK: Missing empty-state guard in src/app.js:4-6.";
|
||||
}
|
||||
|
||||
if (resume || prompt.includes("Continue from the current thread state") || prompt.includes("follow up")) {
|
||||
return "Resumed the prior run.\\nFollow-up prompt accepted.";
|
||||
}
|
||||
|
||||
return "Handled the requested task.\\nTask prompt accepted.";
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "--version") {
|
||||
console.log("codex-cli test");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "app-server" && args[1] === "--help") {
|
||||
console.log("fake app-server help");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "login" && args[1] === "status") {
|
||||
if (BEHAVIOR === "logged-out" || BEHAVIOR === "refreshable-auth" || BEHAVIOR === "auth-run-fails" || BEHAVIOR === "provider-no-auth" || BEHAVIOR === "env-key-provider" || BEHAVIOR === "api-key-account-only") {
|
||||
console.error("not authenticated");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("logged in");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "login") {
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] !== "app-server") {
|
||||
process.exit(1);
|
||||
}
|
||||
const bootState = loadState();
|
||||
bootState.appServerStarts = (bootState.appServerStarts || 0) + 1;
|
||||
saveState(bootState);
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(line);
|
||||
const state = loadState();
|
||||
|
||||
try {
|
||||
switch (message.method) {
|
||||
case "initialize":
|
||||
state.capabilities = message.params.capabilities || null;
|
||||
saveState(state);
|
||||
send({ id: message.id, result: { userAgent: "fake-codex-app-server" } });
|
||||
break;
|
||||
|
||||
case "initialized":
|
||||
break;
|
||||
|
||||
case "account/read":
|
||||
send({ id: message.id, result: buildAccountReadResult() });
|
||||
break;
|
||||
|
||||
case "config/read":
|
||||
if (BEHAVIOR === "config-read-fails") {
|
||||
throw new Error("config/read failed for cwd");
|
||||
}
|
||||
send({ id: message.id, result: buildConfigReadResult() });
|
||||
break;
|
||||
|
||||
case "thread/start": {
|
||||
if (BEHAVIOR === "auth-run-fails") {
|
||||
throw new Error("authentication expired; run codex login");
|
||||
}
|
||||
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
|
||||
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
|
||||
}
|
||||
const thread = nextThread(state, message.params.cwd, message.params.ephemeral);
|
||||
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
|
||||
send({ method: "thread/started", params: { thread: { id: thread.id } } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "thread/name/set": {
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
thread.name = message.params.name;
|
||||
thread.updatedAt = now();
|
||||
saveState(state);
|
||||
send({ id: message.id, result: {} });
|
||||
break;
|
||||
}
|
||||
|
||||
case "thread/list": {
|
||||
let threads = state.threads.slice();
|
||||
if (message.params.cwd) {
|
||||
threads = threads.filter((thread) => thread.cwd === message.params.cwd);
|
||||
}
|
||||
if (message.params.searchTerm) {
|
||||
threads = threads.filter((thread) => (thread.name || "").includes(message.params.searchTerm));
|
||||
}
|
||||
threads.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
send({ id: message.id, result: { data: threads.map(buildThread), nextCursor: null } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "thread/resume": {
|
||||
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
|
||||
throw new Error("thread/resume.persistFullHistory requires experimentalApi capability");
|
||||
}
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
thread.updatedAt = now();
|
||||
saveState(state);
|
||||
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "externalAgentConfig/import": {
|
||||
if (BEHAVIOR === "external-import-unsupported") {
|
||||
send({ id: message.id, error: { code: -32601, message: "Unsupported method: externalAgentConfig/import" } });
|
||||
break;
|
||||
}
|
||||
if (BEHAVIOR === "external-import-fails") {
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
const sessions = (message.params.migrationItems || [])
|
||||
.flatMap((item) => item.details && Array.isArray(item.details.sessions) ? item.details.sessions : []);
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error("missing external session migration");
|
||||
}
|
||||
const sourcePath = fs.realpathSync(session.path);
|
||||
const contents = fs.readFileSync(sourcePath, "utf8");
|
||||
const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
const ledger = loadImportLedger();
|
||||
let record = ledger.records.find(
|
||||
(candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256
|
||||
);
|
||||
let thread;
|
||||
if (record) {
|
||||
thread = ensureThread(state, record.imported_thread_id);
|
||||
} else {
|
||||
const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null;
|
||||
const messages = records
|
||||
.filter((entry) => entry.type === "user" || entry.type === "assistant")
|
||||
.map((entry) => ({ role: entry.type, text: entry.message?.content || "" }));
|
||||
thread = nextThread(state, session.cwd, false);
|
||||
thread.name = title;
|
||||
thread.preview = messages.find((entry) => entry.role === "user")?.text || "";
|
||||
thread.visibleMessages = messages;
|
||||
state.lastExternalAgentImport = { sourcePath, threadId: thread.id, messages };
|
||||
record = {
|
||||
source_path: sourcePath,
|
||||
content_sha256: contentSha256,
|
||||
imported_thread_id: thread.id,
|
||||
imported_at: now(),
|
||||
source_modified_at: null
|
||||
};
|
||||
ledger.records.push(record);
|
||||
saveState(state);
|
||||
saveImportLedger(ledger);
|
||||
}
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
|
||||
case "review/start": {
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
let reviewThread = thread;
|
||||
if (message.params.delivery === "detached") {
|
||||
reviewThread = nextThread(state, thread.cwd, true);
|
||||
send({ method: "thread/started", params: { thread: { id: reviewThread.id } } });
|
||||
}
|
||||
const turnId = nextTurnId(state);
|
||||
send({ id: message.id, result: { turn: buildTurn(turnId), reviewThreadId: reviewThread.id } });
|
||||
emitTurnCompleted(reviewThread.id, turnId, [
|
||||
{
|
||||
started: { type: "enteredReviewMode", id: turnId, review: "current changes" }
|
||||
},
|
||||
...(BEHAVIOR === "with-reasoning"
|
||||
? [
|
||||
{
|
||||
completed: {
|
||||
type: "reasoning",
|
||||
id: "reasoning_" + turnId,
|
||||
summary: [{ text: "Reviewed the changed files and checked the likely regression paths." }],
|
||||
content: []
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
completed: { type: "exitedReviewMode", id: turnId, review: nativeReviewText(message.params.target) }
|
||||
}
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case "turn/start": {
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
const prompt = (message.params.input || [])
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\\n");
|
||||
const turnId = nextTurnId(state);
|
||||
thread.updatedAt = now();
|
||||
state.lastTurnStart = {
|
||||
threadId: message.params.threadId,
|
||||
turnId,
|
||||
model: message.params.model ?? null,
|
||||
effort: message.params.effort ?? null,
|
||||
prompt
|
||||
};
|
||||
saveState(state);
|
||||
send({ id: message.id, result: { turn: buildTurn(turnId) } });
|
||||
|
||||
const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict
|
||||
? structuredReviewPayload(prompt)
|
||||
: taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state"));
|
||||
|
||||
if (
|
||||
BEHAVIOR === "with-subagent" ||
|
||||
BEHAVIOR === "with-late-subagent-message" ||
|
||||
BEHAVIOR === "with-subagent-no-main-turn-completed"
|
||||
) {
|
||||
const subThread = nextThread(state, thread.cwd, true);
|
||||
const subThreadRecord = ensureThread(state, subThread.id);
|
||||
subThreadRecord.name = "design-challenger";
|
||||
saveState(state);
|
||||
const subTurnId = nextTurnId(state);
|
||||
|
||||
send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } });
|
||||
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
|
||||
send({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
id: "collab_" + turnId,
|
||||
tool: "wait",
|
||||
status: "inProgress",
|
||||
senderThreadId: thread.id,
|
||||
receiverThreadIds: [subThread.id],
|
||||
prompt: "Challenge the implementation approach",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agentsStates: {
|
||||
[subThread.id]: { status: "inProgress", message: "Investigating design tradeoffs" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (BEHAVIOR === "with-late-subagent-message") {
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
|
||||
}
|
||||
});
|
||||
}
|
||||
send({ method: "turn/started", params: { threadId: subThread.id, turn: buildTurn(subTurnId) } });
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: subThread.id,
|
||||
turnId: subTurnId,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "reasoning_" + subTurnId,
|
||||
summary: [{ text: "Questioned the retry strategy and the cache invalidation boundaries." }],
|
||||
content: []
|
||||
}
|
||||
}
|
||||
});
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: subThread.id,
|
||||
turnId: subTurnId,
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg_" + subTurnId,
|
||||
text: "The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees.",
|
||||
phase: "analysis"
|
||||
}
|
||||
}
|
||||
});
|
||||
send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } });
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
id: "collab_" + turnId,
|
||||
tool: "wait",
|
||||
status: "completed",
|
||||
senderThreadId: thread.id,
|
||||
receiverThreadIds: [subThread.id],
|
||||
prompt: "Challenge the implementation approach",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agentsStates: {
|
||||
[subThread.id]: { status: "completed", message: "Finished" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (BEHAVIOR !== "with-late-subagent-message") {
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
|
||||
}
|
||||
});
|
||||
}
|
||||
if (BEHAVIOR !== "with-subagent-no-main-turn-completed") {
|
||||
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const items = [
|
||||
...(BEHAVIOR === "with-reasoning"
|
||||
? [
|
||||
{
|
||||
completed: {
|
||||
type: "reasoning",
|
||||
id: "reasoning_" + turnId,
|
||||
summary: [{ text: "Inspected the prompt, gathered evidence, and checked the highest-risk paths first." }],
|
||||
content: []
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
|
||||
}
|
||||
];
|
||||
|
||||
if (BEHAVIOR === "interruptible-slow-task") {
|
||||
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
|
||||
const timer = setTimeout(() => {
|
||||
if (!interruptibleTurns.has(turnId)) {
|
||||
return;
|
||||
}
|
||||
interruptibleTurns.delete(turnId);
|
||||
for (const entry of items) {
|
||||
if (entry && entry.completed) {
|
||||
send({ method: "item/completed", params: { threadId: thread.id, turnId, item: entry.completed } });
|
||||
}
|
||||
}
|
||||
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
|
||||
}, 5000);
|
||||
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
|
||||
} else if (BEHAVIOR === "slow-task") {
|
||||
emitTurnCompletedLater(thread.id, turnId, items, 400);
|
||||
} else {
|
||||
emitTurnCompleted(thread.id, turnId, items);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "turn/interrupt": {
|
||||
state.lastInterrupt = {
|
||||
threadId: message.params.threadId,
|
||||
turnId: message.params.turnId
|
||||
};
|
||||
saveState(state);
|
||||
const pending = interruptibleTurns.get(message.params.turnId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
interruptibleTurns.delete(message.params.turnId);
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: pending.threadId,
|
||||
turn: buildTurn(message.params.turnId, "interrupted")
|
||||
}
|
||||
});
|
||||
}
|
||||
send({ id: message.id, result: {} });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
send({ id: message.id, error: { code: -32601, message: "Unsupported method: " + message.method } });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
send({ id: message.id, error: { code: -32000, message: error.message } });
|
||||
}
|
||||
});
|
||||
`;
|
||||
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}${sep}${process.env.PATH}`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { collectReviewContext, resolveReviewTarget } from "../plugins/codex/scripts/lib/git.mjs";
|
||||
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";
|
||||
|
||||
test("resolveReviewTarget prefers working tree when repo is dirty", () => {
|
||||
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('v2');\n");
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
|
||||
assert.equal(target.mode, "working-tree");
|
||||
});
|
||||
|
||||
test("resolveReviewTarget falls back to branch diff when repo is clean", () => {
|
||||
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 });
|
||||
run("git", ["checkout", "-b", "feature/test"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "change"], { cwd });
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "branch");
|
||||
assert.match(target.label, /main/);
|
||||
assert.match(context.content, /Branch Diff/);
|
||||
});
|
||||
|
||||
test("default branch names with special characters are passed to git literally", () => {
|
||||
const cwd = makeTempDir();
|
||||
const branchName = "main&branch-helper&x";
|
||||
const helperOutputPath = path.join(cwd, "branch-helper-output");
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "branch-helper.cmd"), "@echo branch-helper>branch-helper-output\r\n");
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('base');\n");
|
||||
run("git", ["add", "app.js", "branch-helper.cmd"], { cwd });
|
||||
run("git", ["commit", "-m", "base"], { cwd });
|
||||
run("git", ["branch", "-m", branchName], { cwd, shell: false });
|
||||
run("git", ["update-ref", `refs/remotes/origin/${branchName}`, branchName], { cwd, shell: false });
|
||||
run("git", ["symbolic-ref", "refs/remotes/origin/HEAD", `refs/remotes/origin/${branchName}`], {
|
||||
cwd,
|
||||
shell: false
|
||||
});
|
||||
run("git", ["checkout", "-b", "feature/test"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('feature');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "feature"], { cwd });
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "branch");
|
||||
assert.equal(target.baseRef, branchName);
|
||||
assert.match(context.content, /Branch Diff/);
|
||||
assert.equal(fs.existsSync(helperOutputPath), false);
|
||||
});
|
||||
|
||||
test("resolveReviewTarget honors explicit base overrides", () => {
|
||||
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 });
|
||||
run("git", ["checkout", "-b", "feature/test"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "change"], { cwd });
|
||||
|
||||
const target = resolveReviewTarget(cwd, { base: "main" });
|
||||
|
||||
assert.equal(target.mode, "branch");
|
||||
assert.equal(target.baseRef, "main");
|
||||
});
|
||||
|
||||
test("resolveReviewTarget requires an explicit base when no default branch can be inferred", () => {
|
||||
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 });
|
||||
run("git", ["branch", "-m", "feature-only"], { cwd });
|
||||
|
||||
assert.throws(
|
||||
() => resolveReviewTarget(cwd, {}),
|
||||
/Unable to detect the repository default branch\. Pass --base <ref> or use --scope working-tree\./
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
|
||||
const nestedRepoDir = path.join(cwd, ".claude", "worktrees", "agent-test");
|
||||
fs.mkdirSync(nestedRepoDir, { recursive: true });
|
||||
initGitRepo(nestedRepoDir);
|
||||
|
||||
const target = resolveReviewTarget(cwd, { scope: "working-tree" });
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.match(context.content, /### \.claude\/worktrees\/agent-test\/\n\(skipped: directory\)/);
|
||||
});
|
||||
|
||||
test("collectReviewContext skips broken untracked symlinks instead of crashing", () => {
|
||||
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.symlinkSync("missing-target", path.join(cwd, "broken-link"));
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "working-tree");
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
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-") {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function writeExecutable(filePath, source) {
|
||||
fs.writeFileSync(filePath, source, { encoding: "utf8", mode: 0o755 });
|
||||
}
|
||||
|
||||
export function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: "utf8",
|
||||
input: options.input,
|
||||
shell: options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)),
|
||||
windowsHide: true
|
||||
});
|
||||
}
|
||||
|
||||
export function initGitRepo(cwd) {
|
||||
run("git", ["init", "-b", "main"], { cwd });
|
||||
run("git", ["config", "user.name", "Codex Plugin Tests"], { cwd });
|
||||
run("git", ["config", "user.email", "tests@example.com"], { cwd });
|
||||
run("git", ["config", "commit.gpgsign", "false"], { cwd });
|
||||
run("git", ["config", "tag.gpgsign", "false"], { cwd });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";
|
||||
|
||||
test("terminateProcessTree uses taskkill on Windows", () => {
|
||||
let captured = null;
|
||||
const outcome = terminateProcessTree(1234, {
|
||||
platform: "win32",
|
||||
runCommandImpl(command, args) {
|
||||
captured = { command, args };
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: null
|
||||
};
|
||||
},
|
||||
killImpl() {
|
||||
throw new Error("kill fallback should not run");
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(captured, {
|
||||
command: "taskkill",
|
||||
args: ["/PID", "1234", "/T", "/F"]
|
||||
});
|
||||
assert.equal(outcome.delivered, true);
|
||||
assert.equal(outcome.method, "taskkill");
|
||||
});
|
||||
|
||||
test("terminateProcessTree treats missing Windows processes as already stopped", () => {
|
||||
const outcome = terminateProcessTree(1234, {
|
||||
platform: "win32",
|
||||
runCommandImpl(command, args) {
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
status: 128,
|
||||
signal: null,
|
||||
stdout: "ERROR: The process \"1234\" not found.",
|
||||
stderr: "",
|
||||
error: null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(outcome.attempted, true);
|
||||
assert.equal(outcome.method, "taskkill");
|
||||
assert.equal(outcome.result.status, 128);
|
||||
assert.match(outcome.result.stdout, /not found/i);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs";
|
||||
|
||||
test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => {
|
||||
const output = renderReviewResult(
|
||||
{
|
||||
parsed: {
|
||||
verdict: "approve",
|
||||
summary: "Looks fine."
|
||||
},
|
||||
rawOutput: JSON.stringify({
|
||||
verdict: "approve",
|
||||
summary: "Looks fine."
|
||||
}),
|
||||
parseError: null
|
||||
},
|
||||
{
|
||||
reviewLabel: "Adversarial Review",
|
||||
targetLabel: "working tree diff"
|
||||
}
|
||||
);
|
||||
|
||||
assert.match(output, /Codex returned JSON with an unexpected review shape\./);
|
||||
assert.match(output, /Missing array `findings`\./);
|
||||
assert.match(output, /Raw final message:/);
|
||||
});
|
||||
|
||||
test("renderStoredJobResult prefers rendered output for structured review jobs", () => {
|
||||
const output = renderStoredJobResult(
|
||||
{
|
||||
id: "review-123",
|
||||
status: "completed",
|
||||
title: "Codex Adversarial Review",
|
||||
jobClass: "review",
|
||||
threadId: "thr_123"
|
||||
},
|
||||
{
|
||||
threadId: "thr_123",
|
||||
rendered: "# Codex Adversarial Review\n\nTarget: working tree diff\nVerdict: needs-attention\n",
|
||||
result: {
|
||||
result: {
|
||||
verdict: "needs-attention",
|
||||
summary: "One issue.",
|
||||
findings: [],
|
||||
next_steps: []
|
||||
},
|
||||
rawOutput:
|
||||
'{"verdict":"needs-attention","summary":"One issue.","findings":[],"next_steps":[]}'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.match(output, /^# Codex Adversarial Review/);
|
||||
assert.doesNotMatch(output, /^\{/);
|
||||
assert.match(output, /Codex session ID: thr_123/);
|
||||
assert.match(output, /Resume in Codex: codex resume thr_123/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { makeTempDir } from "./helpers.mjs";
|
||||
import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs";
|
||||
|
||||
test("resolveStateDir uses a temp-backed per-workspace directory", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
|
||||
assert.equal(stateDir.startsWith(os.tmpdir()), true);
|
||||
assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/);
|
||||
assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
||||
});
|
||||
|
||||
test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => {
|
||||
const workspace = makeTempDir();
|
||||
const pluginDataDir = makeTempDir();
|
||||
const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
|
||||
process.env.CLAUDE_PLUGIN_DATA = pluginDataDir;
|
||||
|
||||
try {
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
|
||||
assert.equal(stateDir.startsWith(path.join(pluginDataDir, "state")), true);
|
||||
assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/);
|
||||
assert.match(
|
||||
stateDir,
|
||||
new RegExp(`^${path.join(pluginDataDir, "state").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)
|
||||
);
|
||||
} finally {
|
||||
if (previousPluginDataDir == null) {
|
||||
delete process.env.CLAUDE_PLUGIN_DATA;
|
||||
} else {
|
||||
process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateFile = resolveStateFile(workspace);
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
|
||||
const jobs = Array.from({ length: 51 }, (_, index) => {
|
||||
const jobId = `job-${index}`;
|
||||
const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString();
|
||||
const logFile = resolveJobLogFile(workspace, jobId);
|
||||
const jobFile = resolveJobFile(workspace, jobId);
|
||||
fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8");
|
||||
fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8");
|
||||
return {
|
||||
id: jobId,
|
||||
status: "completed",
|
||||
logFile,
|
||||
updatedAt,
|
||||
createdAt: updatedAt
|
||||
};
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
stateFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
saveState(workspace, {
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs
|
||||
});
|
||||
|
||||
const prunedJobFile = resolveJobFile(workspace, "job-0");
|
||||
const prunedLogFile = resolveJobLogFile(workspace, "job-0");
|
||||
const retainedJobFile = resolveJobFile(workspace, "job-50");
|
||||
const retainedLogFile = resolveJobLogFile(workspace, "job-50");
|
||||
const jobsDir = path.dirname(prunedJobFile);
|
||||
|
||||
assert.equal(fs.existsSync(retainedJobFile), true);
|
||||
assert.equal(fs.existsSync(retainedLogFile), true);
|
||||
|
||||
const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
assert.equal(savedState.jobs.length, 50);
|
||||
assert.deepEqual(
|
||||
savedState.jobs.map((job) => job.id),
|
||||
Array.from({ length: 50 }, (_, index) => `job-${50 - index}`)
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(jobsDir).sort(),
|
||||
Array.from({ length: 50 }, (_, index) => `job-${index + 1}`)
|
||||
.flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`])
|
||||
.sort()
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user