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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ Broker and background-job lifecycle:
| [#779](https://github.com/openai/codex-plugin-cc/pull/779) | `--ephemeral` on `task`, so a disposable run does not leave a persistent Codex thread behind (extended here to refuse this fork's `--resume-thread` as well) |
| [#787](https://github.com/openai/codex-plugin-cc/pull/787) | a graceful teardown reaches a process that is alive but does not lead its own process group: `process.kill(-pid)` reports `ESRCH` for it too, and that was read as "nothing to kill", so such a process only ever met the later force kill |
| [#800](https://github.com/openai/codex-plugin-cc/pull/800) | shortening a thread name or job summary no longer cuts a surrogate pair in half, which left a lone surrogate in the JSON and made the app-server drop the request |
| [#809](https://github.com/openai/codex-plugin-cc/pull/809) | a turn whose `turn/start` response carries no turn id still completes: notifications were held until an id turned up, so every later one, `turn/completed` included, sat in the buffer and the run never finished |

Commands and flags:

Expand All @@ -478,6 +479,8 @@ Commands and flags:
| [#747](https://github.com/openai/codex-plugin-cc/pull/747) | `runCommand` sets an explicit 256 MiB `maxBuffer`, so a large `git diff` is no longer truncated at Node's 1 MiB default |
| [#763](https://github.com/openai/codex-plugin-cc/pull/763) | a turn that fails without throwing stores its error text, so `/codex:result` says why it failed |
| [#799](https://github.com/openai/codex-plugin-cc/pull/799) | the review commands forbid reading or waiting for the background command's output, instead of naming one tool (`BashOutput`) that was only one way to do it; the result-handling skill's stop rule keeps its substance and loses the shouting |
| [#808](https://github.com/openai/codex-plugin-cc/pull/808) | tests only: inline `--key=value` keeps every `=` after the first. The parser already split there, so only the tests are taken; one is adapted, since this fork rejects a boolean value other than `true`/`false` where upstream reads `--flag=false=extra` as true |
| [#811](https://github.com/openai/codex-plugin-cc/pull/811) | `scripts/bump-version.mjs --help` lists the `-h` alias it already accepted |

Where two of these PRs disagreed, the merge commit says which side won and why. The plugin version
is deliberately left at the upstream number: these merges do not cut a release.
Expand Down
3 changes: 3 additions & 0 deletions plugins/codex/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
- Fail a request whose write can no longer reach the runtime, rather than leaving it pending with nothing left to reject it, and stop an `EPIPE` on a dead child's stdin from taking the companion down with it (adapted from openai/codex-plugin-cc#797).
- Record the failure from inside the worker when it is stopped by a signal, crashes, or drains its event loop with the turn unsettled — with the reason, and without overriding a cancellation that owns the terminal status (adapted from openai/codex-plugin-cc#797).
- Refresh a heartbeat on a running job's record, so a worker that died cannot read as alive because its pid was reused, and flag a live run that has gone quiet as possibly stalled in `/codex:status` without ever failing it on that alone. Tunable through `CODEX_COMPANION_HEARTBEAT_INTERVAL_MS`, `CODEX_COMPANION_HEARTBEAT_STALE_MS` and `CODEX_COMPANION_STALL_AFTER_MS` (adapted from openai/codex-plugin-cc#797).
- Complete a turn whose `turn/start` response carries no turn id: notifications are buffered only until that request settles, not until an id appears, so `turn/completed` is no longer held forever (openai/codex-plugin-cc#809).
- Pin inline `--key=value` parsing with tests: every `=` after the first stays in the value. The parser already behaved this way; only the tests are imported (openai/codex-plugin-cc#808).
- List the `-h` alias in `scripts/bump-version.mjs --help` (openai/codex-plugin-cc#811).

## 1.0.0

Expand Down
6 changes: 5 additions & 1 deletion plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,12 @@ function applyTurnNotification(state, message) {
export async function captureTurn(client, threadId, startRequest, options = {}) {
const state = createTurnCaptureState(threadId, options);
const previousHandler = client.notificationHandler;
let startResponded = false;

client.setNotificationHandler((message) => {
if (!state.turnId) {
// A turn/start response may omit turn.id. Buffer only until the start
// request settles, not until a turn ID happens to become available.
if (!startResponded) {
state.bufferedNotifications.push(message);
return;
}
Expand Down Expand Up @@ -719,6 +722,7 @@ export async function captureTurn(client, threadId, startRequest, options = {})
}
}
state.bufferedNotifications.length = 0;
startResponded = true;

if (response.turn?.status && response.turn.status !== "inProgress") {
completeTurn(state, response.turn);
Expand Down
2 changes: 1 addition & 1 deletion scripts/bump-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function usage() {
"Options:",
" --check Verify manifest versions. Uses package.json when version is omitted.",
" --root <dir> Run against a different repository root.",
" --help Print this help."
" -h, --help Print this help."
].join("\n");
}

Expand Down
97 changes: 97 additions & 0 deletions tests/args.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,100 @@ test("splitRawArgumentString keeps a quoted focus phrase together", () => {
["--model", "gpt-6-astra", "--effort", "xhigh", "the auth path"]
);
});

// Imported from upstream PR #808. The fork already split at the first "=" (one parser rule
// since 3d6c7d9); these pin that behaviour so a later rewrite cannot regress it.

test("inline --key=value preserves every subsequent equals sign", () => {
const { options, positionals } = parseArgs(
["review", "--base=feature=x=y", "--model=gpt-6=custom", "--cwd=/tmp/folder=one"],
{ valueOptions: ["base", "model", "cwd"] }
);

assert.deepEqual(options, {
base: "feature=x=y",
model: "gpt-6=custom",
cwd: "/tmp/folder=one"
});
assert.deepEqual(positionals, ["review"]);
});

test("inline options accept a value consisting only of equals signs", () => {
const { options } = parseArgs(
["--base==", "--model===foo"],
{ valueOptions: ["base", "model"] }
);
assert.deepEqual(options, { base: "=", model: "==foo" });
});

test("inline empty value stays empty and does not consume a following token", () => {
const { options, positionals } = parseArgs(
["--base=", "not-the-value"],
{ valueOptions: ["base"] }
);
assert.deepEqual(options, { base: "" });
assert.deepEqual(positionals, ["not-the-value"]);
});

test("unknown inline option is passed through verbatim", () => {
const { options, positionals } = parseArgs(
["--unknown=foo=bar", "next"],
{ valueOptions: ["base"] }
);
assert.deepEqual(options, {});
assert.deepEqual(positionals, ["--unknown=foo=bar", "next"]);
});

test("named and aliased --key=value options preserve equals signs", () => {
const result = parseArgs(
["--b=release=x", "--base", "a=b"],
{
valueOptions: ["base"],
aliasMap: { b: "base" }
}
);
assert.deepEqual(result.options, { base: "a=b" });
assert.deepEqual(result.positionals, []);
});

test("boolean inline options retain their existing parsing behavior", () => {
const { options } = parseArgs(
["--json", "--wait=false", "--background=true"],
{
booleanOptions: ["json", "wait", "background"]
}
);
assert.deepEqual(options, {
json: true,
wait: false,
background: true
});
// Upstream reads "false=extra" as true; the fork rejects anything but true/false, so a value
// with a stray "=" is an error rather than a silent flip.
assert.throws(
() => parseArgs(["--debug=false=extra"], { booleanOptions: ["debug"] }),
/Invalid value for --debug: expected true or false/
);
});

test("bare value option still consumes the following token", () => {
const result = parseArgs(
["--base", "feature=x=y", "--", "--model=left=right"],
{ valueOptions: ["base", "model"] }
);
assert.deepEqual(result.options, { base: "feature=x=y" });
assert.deepEqual(result.positionals, ["--model=left=right"]);
});

test("a missing value still raises a useful error", () => {
assert.throws(
() => parseArgs(["--base"], { valueOptions: ["base"] }),
/Missing value for --base/
);
});

test("raw string splitting does not drop equals signs before parseArgs", () => {
const args = splitRawArgumentString('--base="feature=x=y" --model=test=extra');
const parsed = parseArgs(args, { valueOptions: ["base", "model"] });
assert.deepEqual(parsed.options, { base: "feature=x=y", model: "test=extra" });
});
23 changes: 22 additions & 1 deletion tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,15 @@ rl.on("line", (line) => {
prompt
};
saveState(state);
send({ id: message.id, result: { turn: buildTurn(turnId) } });
if (BEHAVIOR === "missing-turn-id-with-early-event") {
// Simulate a notification sent before the turn/start response.
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
}
if (BEHAVIOR === "missing-turn-id" || BEHAVIOR === "missing-turn-id-with-early-event") {
send({ id: message.id, result: { turn: { status: "inProgress", items: [] } } });
} else {
send({ id: message.id, result: { turn: buildTurn(turnId) } });
}

const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict
? structuredReviewPayload(prompt)
Expand Down Expand Up @@ -783,6 +791,19 @@ rl.on("line", (line) => {
}
];

if (BEHAVIOR === "missing-turn-id" || BEHAVIOR === "missing-turn-id-with-early-event") {
// The remaining notifications arrive after turn/start resolves.
setTimeout(() => {
for (const entry of items) {
if (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") } });
}, 60);
break;
}

if (BEHAVIOR === "crash-mid-turn") {
// The codex app-server dies while a turn is in flight.
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
Expand Down
37 changes: 37 additions & 0 deletions tests/missing-turn-id.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "codex-companion.mjs");

for (const behavior of ["missing-turn-id", "missing-turn-id-with-early-event"]) {
test(`task finishes when turn/start omits turn.id (${behavior})`, () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, behavior);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

// Bound the regression: the old notification gate would wait forever.
const result = spawnSync(process.execPath, [SCRIPT, "task", "verify progress"], {
cwd: repo,
env: buildEnv(binDir),
encoding: "utf8",
timeout: 8000
});

assert.equal(result.status, 0, result.stderr || String(result.error));
assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n");
const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
assert.ok(state.lastTurnStart.turnId);
});
}
Loading