diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..cc68d2d32 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -559,9 +559,12 @@ function applyTurnNotification(state, message) { 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; } @@ -598,6 +601,7 @@ 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); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..b048b222b 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -452,7 +452,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) @@ -585,7 +593,17 @@ rl.on("line", (line) => { } ]; - if (BEHAVIOR === "interruptible-slow-task") { + 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); + } else if (BEHAVIOR === "interruptible-slow-task") { send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); const timer = setTimeout(() => { if (!interruptibleTurns.has(turnId)) { diff --git a/tests/missing-turn-id.test.mjs b/tests/missing-turn-id.test.mjs new file mode 100644 index 000000000..4a14acf0d --- /dev/null +++ b/tests/missing-turn-id.test.mjs @@ -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); + }); +}