Skip to content

Commit bfb3a55

Browse files
authored
fix(codex): resolve the current turn from the rollout when the Stop payload carries no turn_id (#1534)
Codex added turn_id to StopCommandInput in rust-v0.117.0 (0.116.0-alpha.12), after the hooks engine shipped in rust-v0.114.0, so stable rust-v0.114.0, v0.115.0 and v0.116.0 fire the Stop hook with no turn_id at all. #1169 made resolveCodexStopPlan fail closed on that payload, which silently disabled plan review on those versions. When the payload omits the field, derive the turn from the rollout instead: the id of the last id-carrying turn_context/task_started marker, then the same first-marker-anchored scan, so mid-turn compaction safety and per-turn plan filtering are unchanged. A rollout with no turn markers still fails closed, and so does a payload that carries turn_id as a blank string (truncated or foreign, refused before the file is read). Using the fallback writes one line to stderr, unconditionally — stdout is the hook's JSON decision channel, and Codex only reads a Stop hook's stderr on exit code 2, which this hook never uses. Docs: minimum-Codex-version table in the Codex troubleshooting guide and a turn-resolution section in AGENTS.md.
1 parent 1f9eb45 commit bfb3a55

5 files changed

Lines changed: 267 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,38 @@ Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}}
204204
Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}}
205205
```
206206

207+
### Codex Stop hook: which turn the plan belongs to
208+
209+
Codex has no `ExitPlanMode`, so plan review rides its experimental `Stop` hook:
210+
the hook reads the session rollout and returns the plan **the turn that just
211+
stopped** produced. Anchoring on a turn is what keeps an older, already-decided
212+
`<proposed_plan>` from being reopened (#1169), and the turn is resolved in
213+
`resolveCodexStopPlan` (`apps/hook/server/codex-session.ts`) in two tiers:
214+
215+
1. **The payload's own `turn_id`** when the Stop JSON carries the field.
216+
2. **The rollout's own turn markers** when it does not: the id of the LAST
217+
id-carrying `turn_context` / `task_started` marker in the newest rollout
218+
segment. A turn still in flight owns the last markers by construction, and
219+
when Stop fires at turn end the last marker is that turn's own, so one rule
220+
covers both shapes. The scan then still anchors on that turn's **first**
221+
marker, so a mid-turn compaction (which re-emits `turn_context` with the same
222+
id) cannot hide a plan the turn produced before it.
223+
224+
Tier 2 exists because `StopCommandInput.turn_id` only landed in Codex
225+
**rust-v0.117.0** (`rust-v0.116.0-alpha.12`), while the hooks engine itself
226+
shipped in **rust-v0.114.0**: stable `rust-v0.114.0`, `v0.115.0` and `v0.116.0`
227+
fire the Stop hook with no `turn_id` at all. Their rollouts do record the id on
228+
every turn marker (`TurnContext::to_turn_context_item` writes
229+
`turn_id: Some(sub_id)`), which is what the fallback reads. Using it writes one
230+
unconditional line to **stderr** (`logCodexStopTurnIdFallback`; stdout is the
231+
hook's JSON decision channel, and Codex only reads a Stop hook's stderr on
232+
exit code 2, which this hook never uses).
233+
234+
Fail-closed cases are unchanged: a payload that carries `turn_id` as a blank
235+
string is truncated or foreign, not an old Codex, and is refused **before the
236+
rollout is read**; a rollout with no id-carrying turn marker at all skips too.
237+
Both skips stay silent unless `PLANNOTATOR_DEBUG` is set.
238+
207239
## Code Review Flow
208240

209241
```

apps/hook/server/codex-session.test.ts

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
getLastCodexMessage,
1717
getLatestCodexPlan,
1818
logCodexStopSkip,
19+
logCodexStopTurnIdFallback,
1920
getRecentCodexMessages,
2021
resolveCodexStopPlan,
2122
} from "./codex-session";
@@ -700,14 +701,16 @@ describe("getLatestCodexPlan", () => {
700701
});
701702

702703
describe("Codex Stop skip diagnostics", () => {
703-
test("classifies a missing Stop turn id without reading stale plan content", () => {
704-
// The path does not exist: reaching the file at all would throw, which
705-
// is the point — a turn-id-less Stop must never load plan content.
706-
expect(resolveCodexStopPlan("not-read.jsonl")).toEqual({
704+
test("classifies a blank Stop turn id without reading stale plan content", () => {
705+
// A PRESENT but blank turn id is a truncated or foreign payload, never an
706+
// old Codex, so it still fails closed. The path does not exist: reaching
707+
// the file at all would throw, which is the point — that payload must
708+
// never load plan content.
709+
expect(resolveCodexStopPlan("not-read.jsonl", { turnId: " " })).toEqual({
707710
plan: null,
708711
skipReason: "missing-turn-id",
709712
});
710-
expect(resolveCodexStopPlan("not-read.jsonl", { turnId: " " })).toEqual({
713+
expect(resolveCodexStopPlan("not-read.jsonl", { turnId: "" })).toEqual({
711714
plan: null,
712715
skipReason: "missing-turn-id",
713716
});
@@ -745,6 +748,123 @@ describe("getLatestCodexPlan", () => {
745748
});
746749
});
747750

751+
// Codex rust-v0.114.0/0.115.0/0.116.0 ship the hooks engine but send a Stop
752+
// payload with no `turn_id` field at all (it arrived in rust-v0.117.0). On
753+
// those versions the turn is derived from the rollout instead of failing
754+
// closed, which would leave plan review silently disabled.
755+
describe("Codex Stop turn id fallback", () => {
756+
test("resolves the plan of the rollout's current turn", () => {
757+
const path = writeTempRollout(
758+
buildRollout(
759+
sessionMeta(),
760+
turnStarted("turn-only"),
761+
completedPlanItem("Plan from the turn that just stopped", "turn-only"),
762+
),
763+
);
764+
765+
expect(resolveCodexStopPlan(path)).toEqual({
766+
plan: {
767+
text: "Plan from the turn that just stopped",
768+
source: "plan-item",
769+
},
770+
skipReason: null,
771+
fallbackTurnId: "turn-only",
772+
});
773+
});
774+
775+
test("anchors on the turn's FIRST marker, so a mid-turn compaction keeps the plan visible", () => {
776+
// Compaction re-emits a `turn_context` carrying the in-flight turn's id.
777+
// Naming the turn from the LAST marker is fine; anchoring the scan there
778+
// would hide every plan the turn produced before the compaction point.
779+
const turnId = "turn-compacted";
780+
const path = writeTempRollout(
781+
buildRollout(
782+
sessionMeta(),
783+
turnStarted(turnId),
784+
completedPlanItem("Plan written before compaction", turnId),
785+
compacted("Summary of the conversation so far"),
786+
turnContext(turnId),
787+
assistantMessage("Continuing after compaction."),
788+
),
789+
);
790+
791+
const result = resolveCodexStopPlan(path);
792+
expect(result.plan).toEqual({
793+
text: "Plan written before compaction",
794+
source: "plan-item",
795+
});
796+
expect(result.fallbackTurnId).toBe(turnId);
797+
});
798+
799+
test("never re-reviews a plan from an older, already-completed turn", () => {
800+
const path = writeTempRollout(
801+
buildRollout(
802+
sessionMeta(),
803+
turnStarted("turn-old"),
804+
completedPlanItem("Already-decided plan", "turn-old"),
805+
turnCompleted("turn-old"),
806+
turnStarted("turn-new"),
807+
assistantMessage("Unrelated follow-up answer."),
808+
turnCompleted("turn-new"),
809+
),
810+
);
811+
812+
expect(resolveCodexStopPlan(path)).toEqual({
813+
plan: null,
814+
skipReason: null,
815+
fallbackTurnId: "turn-new",
816+
});
817+
});
818+
819+
test("fails closed when the rollout carries no id-carrying turn marker", () => {
820+
const path = writeTempRollout(
821+
buildRollout(
822+
sessionMeta(),
823+
turnContext(),
824+
assistantMessage("<proposed_plan>\nStale plan\n</proposed_plan>"),
825+
),
826+
);
827+
828+
expect(resolveCodexStopPlan(path)).toEqual({
829+
plan: null,
830+
skipReason: "missing-turn-marker",
831+
});
832+
});
833+
834+
test("a payload-named turn is unaffected by the fallback", () => {
835+
const path = writeTempRollout(
836+
buildRollout(
837+
sessionMeta(),
838+
turnStarted("turn-old"),
839+
completedPlanItem("Older turn's plan", "turn-old"),
840+
turnCompleted("turn-old"),
841+
turnStarted("turn-new"),
842+
assistantMessage("Unrelated follow-up answer."),
843+
),
844+
);
845+
846+
// The payload names the older turn: its plan is returned, and no fallback
847+
// is reported — the rollout's newest turn never enters the decision.
848+
expect(resolveCodexStopPlan(path, { turnId: "turn-old" })).toEqual({
849+
plan: { text: "Older turn's plan", source: "plan-item" },
850+
skipReason: null,
851+
});
852+
});
853+
854+
test("announces the fallback on stderr unconditionally", () => {
855+
const messages: string[] = [];
856+
logCodexStopTurnIdFallback("turn-42", {
857+
write: (message) => messages.push(message),
858+
});
859+
860+
expect(messages).toHaveLength(1);
861+
// The line must name the missing field and the turn it fell back to —
862+
// that is the whole diagnostic value. No debug flag gates it.
863+
expect(messages[0]).toContain("turn_id");
864+
expect(messages[0]).toContain("turn-42");
865+
});
866+
});
867+
748868
test("extracts plan blocks surrounded by assistant prose", () => {
749869
const turnId = "turn-prose";
750870
const path = writeTempRollout(
@@ -808,14 +928,20 @@ describe("getLatestCodexPlan", () => {
808928
expect(getLatestCodexPlan(path, { turnId: requestedTurnId })).toBeNull();
809929
});
810930

811-
test("does not scrape an assistant proposed plan for a Stop event without a turn id", () => {
931+
test("does not scrape an older turn's assistant proposed plan for a Stop event without a turn id", () => {
932+
// A turn-id-less payload now resolves the turn from the rollout instead of
933+
// failing closed (Codex < 0.117 sends no turn_id), but the resolved turn is
934+
// the CURRENT one — an earlier, already-decided turn's plan stays invisible.
812935
const completedTurnId = "turn-completed";
936+
const currentTurnId = "turn-current";
813937
const path = writeTempRollout(
814938
buildRollout(
815939
sessionMeta(),
816940
turnStarted(completedTurnId),
817941
assistantMessage("<proposed_plan>\nPrevious turn plan\n</proposed_plan>"),
818942
turnCompleted(completedTurnId),
943+
turnStarted(currentTurnId),
944+
assistantMessage("Just answering a regular question."),
819945
),
820946
);
821947

apps/hook/server/codex-session.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export interface CodexStopPlanLookup {
6767
* produced none. Diagnostic only: both outcomes are a silent no-op.
6868
*/
6969
skipReason: CodexStopSkipReason | null;
70+
/**
71+
* The turn id the scan used, set ONLY when it was derived from the rollout
72+
* because the Stop payload carried no `turn_id` field. The caller surfaces
73+
* it on stderr; a payload-named turn leaves this undefined.
74+
*/
75+
fallbackTurnId?: string;
7076
}
7177

7278
const TURN_START_TYPES = new Set(["task_started", "turn_started"]);
@@ -254,6 +260,26 @@ function findTurnStartIndex(entries: RolloutEntry[], turnId: string): number {
254260
);
255261
}
256262

263+
/**
264+
* The turn the rollout says is current: the id of the LAST id-carrying turn
265+
* marker in the file, or null when the file has none.
266+
*
267+
* That single rule covers both shapes the Stop hook can catch. A turn still in
268+
* flight owns the last markers by construction — a completed turn's markers can
269+
* only be followed by a newer turn's — and when Stop fires at turn end the last
270+
* marker is that turn's own. Compaction is safe because this only names the
271+
* turn; where it BEGINS is still `findTurnStartIndex`'s first-marker anchor.
272+
*/
273+
function findLatestRolloutTurnId(entries: RolloutEntry[]): string | null {
274+
for (let i = entries.length - 1; i >= 0; i--) {
275+
const entry = entries[i];
276+
if (!isTurnMarker(entry)) continue;
277+
const turnId = getTurnId(entry);
278+
if (turnId) return turnId;
279+
}
280+
return null;
281+
}
282+
257283
function findActiveTurnStartIndex(entries: RolloutEntry[]): number {
258284
const latestTurnStart = findLastIndex(
259285
entries,
@@ -332,6 +358,24 @@ export function logCodexStopSkip(
332358
(opts.write ?? console.error)(`[DEBUG] Codex Stop plan review skipped: ${detail}`);
333359
}
334360

361+
/**
362+
* One unconditional stderr line whenever the rollout fallback named the turn,
363+
* so a user on a Codex that sends no `turn_id` can see WHY plan review behaves
364+
* the way it does without setting PLANNOTATOR_DEBUG.
365+
*
366+
* stderr, never stdout: stdout is the Stop hook's JSON decision channel. Codex
367+
* only reads a Stop hook's stderr when it exits with code 2, and this hook
368+
* always exits 0, so the line is a captured breadcrumb, never agent input.
369+
*/
370+
export function logCodexStopTurnIdFallback(
371+
turnId: string,
372+
opts: { write?: (message: string) => void } = {},
373+
): void {
374+
(opts.write ?? console.error)(
375+
`[plannotator] Codex Stop payload carried no turn_id (Codex < 0.117); resolved the current turn from the rollout instead (turn ${turnId}).`,
376+
);
377+
}
378+
335379
function collectPlanCandidates(
336380
entries: RolloutEntry[],
337381
startIndex: number,
@@ -472,8 +516,9 @@ export function getRecentCodexMessages(
472516
* - no plan after the last hook prompt => null
473517
* - identical plan after the last hook prompt => null
474518
*
475-
* Requires options.turnId, and requires that turn to be anchored in the file:
476-
* see resolveCodexStopPlan, which this thin wrapper drops the reason from.
519+
* The turn comes from options.turnId, or from the rollout's own turn markers
520+
* when the Stop payload carried no `turn_id` field, and must be anchored in the
521+
* file: see resolveCodexStopPlan, which this thin wrapper drops the reason from.
477522
*/
478523
export function getLatestCodexPlan(
479524
rolloutPath: string,
@@ -491,15 +536,26 @@ export function resolveCodexStopPlan(
491536
rolloutPath: string,
492537
options: GetLatestCodexPlanOptions = {}
493538
): CodexStopPlanLookup {
494-
// A Stop payload without a turn id cannot tell a plan produced in this turn
495-
// from an already-decided <proposed_plan> earlier in the thread, so fail
496-
// closed — before the file is read, so stale content is never even loaded.
497-
// Codex itself always sends one (`StopCommandInput.turn_id` is a required
498-
// String), so this is a guard against a foreign or truncated payload.
499-
const turnId = options.turnId?.trim();
500-
if (!turnId) return { plan: null, skipReason: "missing-turn-id" };
539+
// A payload that NAMES a turn but names it blank is truncated or foreign, not
540+
// an old Codex: fail closed before the file is read, so stale plan content is
541+
// never even loaded. Keyed on the field being present, because an absent
542+
// field is the old-Codex shape handled below.
543+
const payloadTurnId = options.turnId?.trim();
544+
if (options.turnId !== undefined && !payloadTurnId) {
545+
return { plan: null, skipReason: "missing-turn-id" };
546+
}
501547

502548
const entries = parseRolloutEntries(rolloutPath);
549+
550+
// Codex only added `turn_id` to the Stop payload in rust-v0.117.0
551+
// (rust-v0.116.0-alpha.12); rust-v0.114.0/0.115.0/0.116.0 ship the hooks
552+
// engine and send a Stop payload without the field. Their rollouts still
553+
// record the turn id on every turn marker (`TurnContext::to_turn_context_item`
554+
// writes `turn_id: Some(sub_id)`), so derive the current turn from the file
555+
// rather than failing closed and silently disabling plan review there.
556+
const turnId = payloadTurnId ?? findLatestRolloutTurnId(entries);
557+
if (!turnId) return { plan: null, skipReason: "missing-turn-marker" };
558+
503559
// The named turn must be anchored in THIS rollout file. Without an anchor,
504560
// every plan in the file belongs to some other turn — the normal shape of an
505561
// older segment of a split thread, which routinely ends with an
@@ -512,6 +568,7 @@ export function resolveCodexStopPlan(
512568
return {
513569
plan: findPlanInTurn(entries, turnStartIndex, turnId, !!options.stopHookActive),
514570
skipReason: null,
571+
...(payloadTurnId ? {} : { fallbackTurnId: turnId }),
515572
};
516573
}
517574

apps/hook/server/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ import {
167167
findCodexRolloutsByThreadId,
168168
getRecentCodexMessages,
169169
logCodexStopSkip,
170+
logCodexStopTurnIdFallback,
170171
resolveCodexStopPlan,
171172
} from "./codex-session";
172173
import { findCopilotPlanContent, findCopilotSessionByAncestorPids, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session";
@@ -2360,13 +2361,21 @@ if (args[0] === "sessions") {
23602361
process.exit(0);
23612362
}
23622363

2363-
const { plan: latestPlan, skipReason } = resolveCodexStopPlan(rolloutPath, {
2364-
turnId: typeof event.turn_id === "string" ? event.turn_id : undefined,
2364+
// Absent `turn_id` means an older Codex (the field arrived in rust-v0.117.0)
2365+
// and hands the lookup its rollout fallback; a PRESENT but unusable value is
2366+
// a truncated or foreign payload and must still fail closed, so it is passed
2367+
// through as a blank string rather than collapsed to "absent".
2368+
const rawTurnId = event.turn_id;
2369+
const { plan: latestPlan, skipReason, fallbackTurnId } = resolveCodexStopPlan(rolloutPath, {
2370+
turnId: rawTurnId === undefined ? undefined : typeof rawTurnId === "string" ? rawTurnId : "",
23652371
stopHookActive: !!event.stop_hook_active,
23662372
});
23672373
if (skipReason) {
23682374
logCodexStopSkip(skipReason, { debug: process.env.PLANNOTATOR_DEBUG });
23692375
}
2376+
if (fallbackTurnId) {
2377+
logCodexStopTurnIdFallback(fallbackTurnId);
2378+
}
23702379

23712380
if (!latestPlan?.text) {
23722381
process.exit(0);

apps/marketing/src/content/docs/guides/troubleshooting.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,32 @@ If a Codex plan turn completes without opening Plannotator:
9292
3. Check `~/.codex/config.toml` contains `hooks = true` under `[features]`
9393
4. Check `~/.codex/hooks.json` has a `Stop` hook whose command points to `plannotator`
9494
5. Run `plannotator sessions` in case the browser failed to open but the session is running
95+
6. Check your Codex version: `codex --version`
96+
97+
### Minimum Codex version
98+
99+
The `Stop` hook Plannotator uses arrived in Codex **rust-v0.114.0**, so anything
100+
older has no Codex plan review at all. Two later changes matter:
101+
102+
| Codex version | What changed |
103+
|---------------|--------------|
104+
| `rust-v0.114.0` | Hooks engine ships; the feature key is `codex_hooks` and it is off by default |
105+
| `rust-v0.117.0` (`0.116.0-alpha.12`) | The Stop payload starts carrying `turn_id` |
106+
| `rust-v0.125.0` | Hooks become enabled by default |
107+
| `rust-v0.131.0` | The feature key is renamed to `hooks`, with `codex_hooks` kept as a legacy alias |
108+
109+
Step 3 above assumes `rust-v0.131.0` or newer, which is the version this
110+
installer targets. On `rust-v0.114.0``rust-v0.130.x` the key Codex reads is
111+
`codex_hooks = true`, so a config that says `hooks = true` alone has no effect
112+
there — write `codex_hooks = true` (both keys together are fine) and restart
113+
Codex.
114+
115+
You do **not** need a Codex new enough to send `turn_id`. On `rust-v0.114.0`,
116+
`v0.115.0` and `v0.116.0` the Stop payload omits that field, and Plannotator
117+
resolves the turn from the session rollout instead, which is why plan review
118+
opens there as well. When that fallback runs it prints one line to stderr
119+
naming the turn it used, so a hook run captured with stderr visible shows
120+
exactly what happened.
95121

96122
Codex hooks are currently disabled on Windows in the official Codex docs, so the Windows installer prints manual guidance instead of changing Codex config automatically.
97123

0 commit comments

Comments
 (0)