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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ async function executeReviewRun(request) {
exitStatus: result.status,
threadId: result.threadId,
turnId: result.turnId,
resolved: result.resolved,
payload,
rendered,
summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`),
Expand Down Expand Up @@ -444,6 +445,7 @@ async function executeReviewRun(request) {
exitStatus: result.status,
threadId: result.threadId,
turnId: result.turnId,
resolved: result.resolved,
payload,
rendered: renderReviewResult(parsed, {
reviewLabel: reviewName,
Expand Down Expand Up @@ -520,6 +522,7 @@ async function executeTaskRun(request) {
exitStatus: result.status,
threadId: result.threadId,
turnId: result.turnId,
resolved: result.resolved,
payload,
rendered,
summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)),
Expand Down
44 changes: 34 additions & 10 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,15 +1007,22 @@ export async function runAppServerReview(cwd, options = {}) {

return withAppServer(cwd, async (client) => {
emitProgress(options.onProgress, "Starting Codex review thread.", "starting");
const thread = await startThread(client, cwd, {
const response = await startThread(client, cwd, {
model: options.model,
sandbox: "read-only",
ephemeral: true,
threadName: options.threadName
});
const sourceThreadId = thread.thread.id;
const sourceThreadId = response.thread.id;
const resolved = {
model: response.model,
modelProvider: response.modelProvider,
reasoningEffort: response.reasoningEffort,
sandbox: response.sandbox
};
emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", {
threadId: sourceThreadId
threadId: sourceThreadId,
resolved
});
const delivery = options.delivery ?? "inline";

Expand Down Expand Up @@ -1046,6 +1053,7 @@ export async function runAppServerReview(cwd, options = {}) {
threadId: turnState.threadId,
sourceThreadId,
turnId: turnState.turnId,
resolved,
reviewText: turnState.reviewText,
reasoningSummary: turnState.reasoningSummary,
turn: turnState.finalTurn,
Expand Down Expand Up @@ -1099,29 +1107,35 @@ export async function runAppServerTurn(cwd, options = {}) {
}

return withAppServer(cwd, async (client) => {
let threadId;
let response;

if (options.resumeThreadId) {
emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
const response = await resumeThread(client, options.resumeThreadId, cwd, {
response = await resumeThread(client, options.resumeThreadId, cwd, {
model: options.model,
sandbox: options.sandbox,
ephemeral: false
});
threadId = response.thread.id;
} else {
emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
const response = await startThread(client, cwd, {
response = await startThread(client, cwd, {
model: options.model,
sandbox: options.sandbox,
ephemeral: options.persistThread ? false : true,
threadName: options.persistThread ? options.threadName : options.threadName ?? null
});
threadId = response.thread.id;
}

const threadId = response.thread.id;
let resolved = {
model: response.model,
modelProvider: response.modelProvider,
reasoningEffort: response.reasoningEffort,
sandbox: response.sandbox
};
emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", {
threadId
threadId,
resolved
});

const prompt = options.prompt?.trim() || options.defaultPrompt || "";
Expand All @@ -1140,13 +1154,23 @@ export async function runAppServerTurn(cwd, options = {}) {
effort: options.effort ?? null,
outputSchema: options.outputSchema ?? null
}),
{ onProgress: options.onProgress }
{
onProgress: options.onProgress,
onResponse() {
if (!options.effort) {
return;
}
resolved = { ...resolved, reasoningEffort: options.effort };
options.onProgress?.({ message: "", resolved });
}
}
);

return {
status: buildResultStatus(turnState),
threadId,
turnId: turnState.turnId,
resolved,
finalMessage: turnState.lastAgentMessage,
reasoningSummary: turnState.reasoningSummary,
turn: turnState.finalTurn,
Expand Down
11 changes: 11 additions & 0 deletions plugins/codex/scripts/lib/tracked-jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function normalizeProgressEvent(value) {
phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null,
threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null,
turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null,
resolved: value.resolved && typeof value.resolved === "object" && !Array.isArray(value.resolved) ? value.resolved : null,
stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
Expand All @@ -27,6 +28,7 @@ function normalizeProgressEvent(value) {
phase: null,
threadId: null,
turnId: null,
resolved: null,
stderrMessage: String(value ?? "").trim(),
logTitle: null,
logBody: null
Expand Down Expand Up @@ -71,6 +73,7 @@ export function createJobProgressUpdater(workspaceRoot, jobId) {
let lastPhase = null;
let lastThreadId = null;
let lastTurnId = null;
let lastResolved = null;

return (event) => {
const normalized = normalizeProgressEvent(event);
Expand All @@ -95,6 +98,12 @@ export function createJobProgressUpdater(workspaceRoot, jobId) {
changed = true;
}

if (normalized.resolved && normalized.resolved !== lastResolved) {
lastResolved = normalized.resolved;
patch.resolved = normalized.resolved;
changed = true;
}

if (!changed) {
return;
}
Expand Down Expand Up @@ -160,6 +169,7 @@ export async function runTrackedJob(job, runner, options = {}) {
status: completionStatus,
threadId: execution.threadId ?? null,
turnId: execution.turnId ?? null,
resolved: execution.resolved ?? null,
pid: null,
phase: completionStatus === "completed" ? "done" : "failed",
completedAt,
Expand All @@ -171,6 +181,7 @@ export async function runTrackedJob(job, runner, options = {}) {
status: completionStatus,
threadId: execution.threadId ?? null,
turnId: execution.turnId ?? null,
resolved: execution.resolved ?? null,
summary: execution.summary,
phase: completionStatus === "completed" ? "done" : "failed",
pid: null,
Expand Down
7 changes: 5 additions & 2 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ rl.on("line", (line) => {
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({ 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: BEHAVIOR === "resolved-effort" ? "medium" : null } });
send({ method: "thread/started", params: { thread: { id: thread.id } } });
break;
}
Expand Down Expand Up @@ -347,7 +347,7 @@ rl.on("line", (line) => {
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 } });
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: BEHAVIOR === "resolved-effort" ? "medium" : null } });
break;
}

Expand Down Expand Up @@ -437,6 +437,9 @@ rl.on("line", (line) => {
}

case "turn/start": {
if (BEHAVIOR === "turn-start-fails") {
throw new Error("turn/start failed after thread resolution");
}
const thread = ensureThread(state, message.params.threadId);
const prompt = (message.params.input || [])
.filter((item) => item.type === "text")
Expand Down
65 changes: 64 additions & 1 deletion tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ 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");
const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs");
const FAKE_RESOLVED_SETTINGS = {
model: "gpt-5.4",
modelProvider: "openai",
reasoningEffort: null,
sandbox: {
type: "readOnly",
access: { type: "fullAccess" },
networkAccess: false
}
};

async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) {
const start = Date.now();
Expand All @@ -28,6 +38,13 @@ async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) {
throw new Error("Timed out waiting for condition.");
}

function readPersistedJob(workspaceRoot, jobId = null) {
const stateDir = resolveStateDir(workspaceRoot);
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
const resolvedJobId = jobId ?? state.jobs[0].id;
return JSON.parse(fs.readFileSync(path.join(stateDir, "jobs", `${resolvedJobId}.json`), "utf8"));
}

test("setup reports ready when fake codex is installed and authenticated", () => {
const binDir = makeTempDir();
installFakeCodex(binDir);
Expand Down Expand Up @@ -155,6 +172,7 @@ test("review renders a no-findings result from app-server review/start", () => {
assert.equal(result.status, 0);
assert.match(result.stdout, /Reviewed uncommitted changes/);
assert.match(result.stdout, /No material issues found/);
assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS);
});

test("task runs when the active provider does not require OpenAI login", () => {
Expand Down Expand Up @@ -384,6 +402,7 @@ test("adversarial review renders structured findings over app-server turn/start"

assert.equal(result.status, 0);
assert.match(result.stdout, /Missing empty-state guard/);
assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS);
});

test("adversarial review accepts the same base-branch targeting as review", () => {
Expand Down Expand Up @@ -501,6 +520,7 @@ test("task --resume-last resumes the latest persisted task thread", () => {

assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n");
assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS);
});

test("task-resume-candidate returns the latest rescue thread from the current session", () => {
Expand Down Expand Up @@ -767,7 +787,7 @@ test("task forwards model selection and reasoning effort to app-server turn/star
const repo = makeTempDir();
const binDir = makeTempDir();
const statePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir);
installFakeCodex(binDir, "resolved-effort");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
Expand All @@ -782,6 +802,35 @@ test("task forwards model selection and reasoning effort to app-server turn/star
const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark");
assert.equal(fakeState.lastTurnStart.effort, "low");
assert.deepEqual(readPersistedJob(repo).resolved, {
...FAKE_RESOLVED_SETTINGS,
model: "gpt-5.3-codex-spark",
reasoningEffort: "low"
});
});

test("task preserves resolved settings when turn/start fails", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "turn-start-fails");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const result = run("node", [SCRIPT, "task", "--effort", "xhigh", "diagnose the failing test"], {
cwd: repo,
env: buildEnv(binDir)
});

assert.notEqual(result.status, 0);
assert.match(result.stderr, /turn\/start failed after thread resolution/);
const storedJob = readPersistedJob(repo);
assert.equal(storedJob.status, "failed");
assert.deepEqual(storedJob.resolved, FAKE_RESOLVED_SETTINGS);
const stateDir = resolveStateDir(repo);
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
assert.deepEqual(state.jobs[0].resolved, FAKE_RESOLVED_SETTINGS);
});

test("task logs reasoning summaries and assistant messages to the job log", () => {
Expand Down Expand Up @@ -939,6 +988,18 @@ test("task --background enqueues a detached worker and exposes per-job status",
assert.equal(launchPayload.status, "queued");
assert.match(launchPayload.jobId, /^task-/);

const runningJob = await waitFor(() => {
try {
const storedJob = readPersistedJob(repo, launchPayload.jobId);
return storedJob.status === "running" && storedJob.resolved ? storedJob : null;
} catch {
return null;
}
});
assert.deepEqual(runningJob.resolved, FAKE_RESOLVED_SETTINGS);
const runningState = JSON.parse(fs.readFileSync(path.join(resolveStateDir(repo), "state.json"), "utf8"));
assert.deepEqual(runningState.jobs.find((job) => job.id === launchPayload.jobId).resolved, FAKE_RESOLVED_SETTINGS);

const waitedStatus = run(
"node",
[SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"],
Expand Down Expand Up @@ -966,6 +1027,8 @@ test("task --background enqueues a detached worker and exposes per-job status",

assert.equal(resultPayload.job.id, launchPayload.jobId);
assert.equal(resultPayload.job.status, "completed");
assert.deepEqual(resultPayload.job.resolved, FAKE_RESOLVED_SETTINGS);
assert.deepEqual(resultPayload.storedJob.resolved, FAKE_RESOLVED_SETTINGS);
assert.match(resultPayload.storedJob.rendered, /Handled the requested task/);
});

Expand Down