fix(hooks-codex): gate plain-stdout→context on a clean exit; harden HMR + absence tests

Round-2 Codex review of the round-1 fixes:

- (A) The Codex plain-stdout→additionalContext fold (F1) was not gated on exit
  code, so a NON-clean hook's stdout still injected: a SessionStart `echo stale;
  exit 2` (an emit — cannot block) wrongly injected "stale", and a
  UserPromptSubmit `exit 1` (non-blocking error → falls through to context) did
  too. Gate the fold on `output.exitCode === 0`, matching the codec's own
  structured-stdout rule. Guard tests for both paths, proven red without the gate.
- (B) The Codex "SessionStart no-context no-op" absence test was unsound (a
  completed turn doesn't prove the detached hook finished). It now touches a
  marker and waitFor()s it before asserting no context.
- (B) Both HMR tests used a no-op `true` hook, so a leaked listener would still
  pass. They now use a BLOCKING (exit 2) UserPromptSubmit hook and assert the
  post-dispose turn is NOT blocked and logs no hook/invoked — a leaked listener
  fails loudly.
This commit is contained in:
Tianyi Cui
2026-07-01 12:03:23 +08:00
parent a72ebda723
commit 4da2b99bc3
4 changed files with 72 additions and 16 deletions
+9 -5
View File
@@ -110,14 +110,18 @@ export function apply(ctx: Context, config: Config): void {
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
}, () => performance.now())
// Codex's SessionStart/UserPromptSubmit treat a clean hook's PLAIN
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
// `output.stdout` but only sets `additionalContext` from a JSON
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
// merge + contextFrom path carry it. Guarded on the codec's own JSON gate
// (stdout starting with `{`) so a structured hook's raw JSON is never
// injected as prose, and it never clobbers an explicit additionalContext.
if (opts.plainStdoutAsContext === true && output.additionalContext === undefined
// merge + contextFrom path carry it. Gated exactly like the codec's own
// structured-stdout parse: only on a clean `exitCode === 0` (a non-zero
// exit is an error, not context — an `echo x; exit 2` must not inject
// `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured
// hook's raw JSON is never dumped as prose), and never clobbering an
// explicit additionalContext from a JSON block.
if (opts.plainStdoutAsContext === true && output.exitCode === 0
&& output.additionalContext === undefined
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
output.additionalContext = output.stdout
}