From 86b9a733301f7473d93266d0f4e0c95b7e8ba8bb Mon Sep 17 00:00:00 2001 From: cubicj <124673057+cubicj@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:59:49 +0900 Subject: [PATCH] Capture resolved model, effort, and sandbox in job records --- plugins/codex/scripts/codex-companion.mjs | 3 + plugins/codex/scripts/lib/codex.mjs | 44 +++++++++++---- plugins/codex/scripts/lib/tracked-jobs.mjs | 11 ++++ tests/fake-codex-fixture.mjs | 7 ++- tests/runtime.test.mjs | 65 +++++++++++++++++++++- 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468a..30a1d351 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -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.`), @@ -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, @@ -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.`)), diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc..c06584cd 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -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"; @@ -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, @@ -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 || ""; @@ -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, diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 90286901..0c7d56d3 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -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() @@ -27,6 +28,7 @@ function normalizeProgressEvent(value) { phase: null, threadId: null, turnId: null, + resolved: null, stderrMessage: String(value ?? "").trim(), logTitle: null, logBody: null @@ -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); @@ -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; } @@ -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, @@ -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, diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0..57441095 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -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; } @@ -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; } @@ -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") diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835..2aaecf12 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -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(); @@ -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); @@ -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", () => { @@ -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", () => { @@ -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", () => { @@ -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 }); @@ -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", () => { @@ -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"], @@ -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/); });