fix(runtime): wait out Codex 0.157's startup screen, and stop at Codex's startup dialogs, before typing a worker brief (#23745)

* fix(runtime): wait for Codex's live chat before typing a worker brief

Codex 0.157 draws a provisional startup screen (header reads model: loading) and
discards typed input while it starts its shared daemon behind it; a fresh Codex
home makes that window seconds long, so worker-start pasted briefs that were
truncated or never submitted. Codex 0.158 dropped the header labels Orca matched,
so worker-start stopped seeing Codex as ready at all.

Readiness now requires Codex's live chat on both layouts: the provisional header
vetoes a text match unless the live status row is already painted, and 0.158's
greeting layout counts once that status row appears. Codex 0.158's model
announcement dialog is reported as a blocked prompt instead of receiving the brief.

* fix(runtime): recognise Codex's provisional screen from the text copy and the screen probe

Live worker-start on a fresh Codex 0.157 home still typed during the daemon
start: Codex leaves its alternate screen for that window, so the live screen
showed no header and the screen-based veto never fired. The text copy keeps the
provisional header until the live chat paints its status row, so the veto now
reads it there. The tui-idle visible-screen probe used the bare text rule on the
rendered screen; it now goes through the same body rule.

* test(daemon): register the new Codex captures' known serializer divergences

The serialize round-trip replay picks up every fixture under
runtime/__fixtures__, and the three new Codex 0.157/0.158 captures showed
48/9/8 "new-fail" checkpoints against an expected 0, turning CI red. They
are the existing live-pen colour leak on restored cells, the same class as
the other Codex and DSH entries; this branch changes no serializer code.

* fix(runtime): keep Qoder off the Codex screen probe change; drop an unbacked row filter

- The tui-idle visible-screen probe now classified Qoder panes with
  isQoderComposerReady, which skips the working veto evaluateTuiIdle applies
  first. Qoder paints its composer mid-turn, so an adopted Qoder pane whose
  hooks said "working" settled the wait immediately. Only Codex and unknown
  panes take the body rule there; every other agent keeps its old verdict.
- The live status-row check skipped rows containing "waiting for startup",
  a string Codex 0.157/0.158's TUI never prints. The line-folded text copy
  keeps a whole screen on one line, so the filter could only ever veto the
  real status row. It is now a bounded includes() with no split.
- Lowercase the wait text once in isKnownReadyPromptBody.
- Restore the per-frame "screen never takes a settled header away" check,
  guarded on the provisional veto, instead of checking the final frame only.

* fix(runtime): stop reporting Codex 0.158's model announcement once it is answered

The announcement's choices stay in the text copy after the user answers it,
and the existing dismissal check needed the model:/directory: labels that
0.158's header lacks, so tui-idle waits and the agent-status query kept
reporting codex-model-migration-prompt over a live chat. Codex repaints its
whole screen, header included, when a startup dialog closes, so the header
after the dialog now marks it answered.

Also corrects the live-chat marker comments: the middle dot also comes from
the daemon session's agents hint row and the warnings notice, not only the
status row.

* docs(runtime): note that Codex startup dialogs also draw the live-footer dot

* fix(runtime): recognise Codex 0.157/0.158 startup dialogs by the rows they really print

Codex 0.157 and 0.158 no longer print `Press enter to continue/confirm` on
their startup dialogs; they print key rows instead (`enter continue · esc
skip`, `enter confirm · esc skip`, `enter/esc continue · ctrl+c quit`). The
update, hooks-review and model-migration matchers still required the old
wording, so none of these dialogs was reported as blocked. On 0.157 the
dialog's `·` also satisfied the live-footer check, so a tui-idle wait read
the update dialog as ready and worker-start would type the brief into it,
where Enter picks "Update now" (npm install -g, Codex exits). On 0.158 the
wait timed out instead of reporting blocked.

The matchers now accept the old wording or the new row, tolerating the
spaces the line-folded text copy drops around `·`. Each one matches from
the dialog's first `·` (for the update dialog that is its title row,
`Update available · 0.157.0 → …`), so the dialog is blocked from the same
character that would otherwise make the provisional header read as live.
The retired-model notice without choices has a catalog-supplied heading
(`GPT-5.4 is no longer available`), so it is matched by its own key row.
No new blocked-reason value. The startup-dialog matchers move to
startup-dialog-blocked-signals.ts to keep terminal-wait-detection.ts under
the line limit.

Backed by six real captures (update available, hooks review, retired model
without choices, each on 0.157.0 and 0.158.0), replayed frame by frame and
through a tui-idle wait; the serializer round-trip replay registers their
existing live-pen colour divergences.

* fix(runtime): keep reporting Codex's retired-model notice after a relaunch in the same pane

The retired-model notice is matched by its key row alone, and the matcher
took the first `enter/esc continue ·` in the live window while every other
startup-dialog matcher takes the last. Quitting Codex from the notice and
relaunching it in the same pane leaves the old copy ahead of the new
launch's header, so the header read as having dismissed the new notice:
0.157 then read ready and a worker brief would be typed into the dialog.

Take the last key row, and replay each captured dialog quit-and-relaunched
to pin all six.

* fix(runtime): match Codex startup dialogs by the rows the text copy keeps intact

Codex 0.157+ paints each startup dialog over its startup screen by cell
diff, so Orca's line-folded text copy can drop letters and spaces from a
heading: #23765's 0.157.1 capture reads `Updat available`. The update
matcher needed `update available`, so on that capture tier 1 read the
dialog's own `·` as the live chat's footer and a tui-idle wait settled
ready on the update dialog, whose Enter picks "Update now".

Match each dialog from its first `·` by rows Codex prints as fixed
literals: `available · <version>` and `enter continue · esc skip`
(update), `enter confirm ·` (hooks review), `enter/esc continue|confirm ·`
(model notices, which also covers 0.158's new-model announcement, so
its choice-text matcher goes). Legacy `Press enter to …` wording still
matches. Add the new rows to the blocked-signal prefilter, and replay
#23765's 0.157.1 update capture in the dialog suite.

* fix(runtime): don't name Codex's mid-session pickers a hooks review

Codex's rate-limit reset popup (and its other pickers) end their key row
with `enter confirm · esc back`, which the hooks-review row matched now
that it no longer needs the heading. Exclude `esc back` instead of
requiring `esc skip`, so a half-painted hooks-review row still blocks.
This commit is contained in:
Brennan Benson
2026-09-29 01:16:20 -07:00
committed by GitHub
parent bb91e39eb9
commit 36c473ea1a
25 changed files with 521 additions and 30 deletions
@@ -43,6 +43,17 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
'codex-0157-effort-override-embedded-warning': 4,
'codex-0157-no-daemon-effort-override': 16,
'codex-0157-plain-ready': 18,
// Fresh-home 0.157/0.158 captures: the live pen's true-colour fg/bg leaks onto restored cells.
'codex-0157-fresh-home-daemon-install': 48,
'codex-0158-fresh-home-greeting': 9,
'codex-0158-model-announcement-dialog': 8,
// Codex 0.157/0.158 startup-dialog captures: the same live-pen true-colour leak onto restored cells.
'codex-0157-update-available-dialog': 26,
'codex-0158-update-available-dialog': 8,
'codex-0157-hooks-review-dialog': 24,
'codex-0158-hooks-review-dialog': 8,
'codex-0157-model-retired-dialog': 22,
'codex-0158-model-retired-dialog': 6,
// Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures).
'codex-0-157-1-update-dialog': 16,
'codex-0-158-0-approval': 12,
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-28T21:45:00.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.157.0, plain launch (no -m/-c) on a fresh CODEX_HOME. The provisional screen reads `model: loading` while Codex installs and starts its shared daemon (`Installing daemon from CLI version 0.157.0`); 0.157 discards typed input during that start. The live header fills in at the end. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:30:27.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
@@ -0,0 +1,2 @@
[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-h57/home/.codex/packages/app-server-daemon...
[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:31:06.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets model = \"gpt-5.4-mini\", a model missing from 0.157's catalog, so Codex shows its retired-model notice without choices: `GPT-5.4 Mini is no longer available`, `Switch to GPT-6 Luna to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model; the heading comes from the model catalog, not the older `Codex just got an upgrade` wording. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
@@ -0,0 +1,2 @@
[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-m57/home/.codex/packages/app-server-daemon...
[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 Mini is no longer available  Codex now uses GPT-6 Luna in place of GPT-5.4 Mini. Switch to GPT-6 Luna to continue. enter/esc continue · ctrl+c quit     [?2026l
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:29:52.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.157.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
@@ -0,0 +1,2 @@
[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-u57/home/.codex/packages/app-server-daemon...
[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.157.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-28T21:44:00.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, plain launch (no -m/-c) on a fresh CODEX_HOME. Greeting layout: an unboxed `>_ OpenAI Codex (v0.158.0)` header with no model:/directory: labels, starting from a provisional screen whose directory reads `loading`; the live status row under the composer appears last. Braille logo animation frames included. Cut 4.6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:30:46.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-28T21:44:30.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets an older model with an available upgrade. After the provisional screen, Codex paints its model announcement (`Meet GPT-6 Sol`, `1. Try new model` / `2. Use existing model`, `enter/esc confirm`), which owns Enter. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:31:56.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets model_provider = \"amazon-bedrock\" and model = \"openai.gpt-5.4\" (0.158 keeps the notice for a model missing from its catalog only on the OpenAI and Bedrock providers, and the OpenAI provider asks for a sign-in first, which the rig does not have). Codex shows the notice without choices: `GPT-5.4 on Amazon Bedrock is no longer offered in Codex`, `Switch to GPT-6 Sol on Amazon Bedrock to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T03:30:10.000Z",
"platform": "darwin",
"command": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.158.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.",
"exitCode": null
}
File diff suppressed because one or more lines are too long
@@ -5,7 +5,11 @@ import {
replayTranscript,
type TranscriptReplayFrame
} from './agent-transcript-replay-test-harness'
import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection'
import {
isKnownReadyPromptBody,
isKnownReadyPromptPreview,
isKnownReadyPromptSettled
} from './terminal-wait-detection'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -19,7 +23,9 @@ const PLAIN = 'codex-0157-plain-ready'
const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning'
const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning'
const NO_DAEMON = 'codex-0157-no-daemon-effort-override'
const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON]
// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon.
const FRESH_HOME = 'codex-0157-fresh-home-daemon-install'
const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME]
async function finalFrame(
name: string,
@@ -41,6 +47,16 @@ function screenShowsLoadingHeader(screenLines: string[]): boolean {
return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen)
}
// Codex's default status row opens with `<model> <effort> ·`; only the live chat paints it.
const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/
function screenShowsProvisionalStartup(screenLines: string[]): boolean {
return (
screenShowsLoadingHeader(screenLines) &&
!screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase()))
)
}
describe('Codex 0.157 header readiness from captured bytes', () => {
it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])(
'%s: the line-folded wait text never shows a ready header',
@@ -71,6 +87,26 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
}
)
// Why: 0.157 discards input typed during its daemon start, behind the provisional header.
it.each(ALL_FIXTURES)(
'%s: never ready while the screen shows the provisional `model: loading` startup screen',
async (name) => {
let sawTextOnlyReadiness = false
for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) {
if (screenShowsProvisionalStartup(frame.screenLines)) {
sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText)
expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
false
)
}
}
// Presence precondition for the text-copy fixtures: the text rules alone would say ready here.
if (name === PLAIN || name === FRESH_HOME) {
expect(sawTextOnlyReadiness).toBe(true)
}
}
)
// Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475).
describe.each([
[120, 40],
@@ -78,15 +114,21 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
[30, 50],
[108, 30],
[60, 5]
])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => {
])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => {
it.each(ALL_FIXTURES)('%s', async (name) => {
let settledFrames = 0
for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) {
if (isKnownReadyPromptPreview(frame.waitText)) {
if (isKnownReadyPromptSettled(frame.waitText)) {
settledFrames += 1
expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
true
)
}
}
// Presence precondition: the text-copy fixtures reach a settled header.
if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) {
expect(settledFrames).toBeGreaterThan(0)
}
})
})
@@ -175,6 +217,58 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
15_000
)
it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => {
const data = readRuntimeFixture(FRESH_HOME)
const install = data.indexOf('Installing daemon')
// Presence precondition: the cut keeps the provisional header and stops before the live chat.
expect(install).toBeGreaterThan(0)
const provisional = data.slice(0, data.indexOf('\n', install) + 1)
expect(provisional).toMatch(/model:.*loading/)
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: provisional,
size: { cols: 120, rows: 40 }
})
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 })
).rejects.toThrow(/timeout/)
}, 15_000)
// Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the
// runtime asks for a visible-screen read instead of its own text copy.
it('does not settle from a visible-screen read of the provisional screen', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: '',
size: { cols: 120, rows: 40 }
})
const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({
handle,
status: 'running',
tail: [
'╭──────────────────────────────────────────╮',
'│ >_ OpenAI Codex (v0.157.0) │',
'│ model: loading /model to change │',
'│ directory: ~/repo/app │',
'╰──────────────────────────────────────────╯',
'› Ask Codex to do anything',
' ? for shortcuts'
],
truncated: false,
nextCursor: null,
source: 'screen'
})
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 })
).rejects.toThrow(/timeout/)
// Presence precondition: the visible-screen probe actually ran.
expect(readVisibleScreen).toHaveBeenCalled()
}, 15_000)
it('keeps timing out on the garbled 80x24 default grid, as before', async () => {
const { runtime, handle } = await codexPane(EFFORT_OVERRIDE)
await expect(
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import {
readRuntimeFixture,
replayTranscript,
type TranscriptReplayFrame
} from './agent-transcript-replay-test-harness'
import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody } from './terminal-wait-detection'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
type StartupDialog = {
name: string
reason: RuntimeTerminalWaitBlockedReason
heading: string
keyRow: string
}
// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json).
// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did.
const DIALOGS: StartupDialog[] = [
{
// Recorded separately on 0.157.1 (STA-8834).
name: 'codex-0-157-1-update-dialog',
reason: 'agent-update-prompt',
heading: 'Update available ·',
keyRow: 'enter continue · esc skip'
},
{
name: 'codex-0157-update-available-dialog',
reason: 'agent-update-prompt',
heading: 'Update available ·',
keyRow: 'enter continue · esc skip'
},
{
name: 'codex-0158-update-available-dialog',
reason: 'agent-update-prompt',
heading: 'Update available ·',
keyRow: 'enter continue · esc skip'
},
{
name: 'codex-0157-hooks-review-dialog',
reason: 'agent-hooks-review-prompt',
heading: 'Hooks need review',
keyRow: 'enter confirm · esc skip'
},
{
name: 'codex-0158-hooks-review-dialog',
reason: 'agent-hooks-review-prompt',
heading: 'Hooks need review',
keyRow: 'enter confirm · esc skip'
},
{
name: 'codex-0157-model-retired-dialog',
reason: 'codex-model-migration-prompt',
heading: 'is no longer available',
keyRow: 'enter/esc continue · ctrl+c quit'
},
{
name: 'codex-0158-model-retired-dialog',
reason: 'codex-model-migration-prompt',
heading: 'is no longer offered',
keyRow: 'enter/esc continue · ctrl+c quit'
},
{
name: 'codex-0158-model-announcement-dialog',
reason: 'codex-model-migration-prompt',
heading: 'Try new model',
keyRow: 'enter/esc confirm · ctrl+c quit'
}
]
const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-'))
const LIVE_CHAT_FIXTURES = [
'codex-0157-plain-ready',
'codex-0157-effort-override-embedded-warning',
'codex-0157-config-override-embedded-warning',
'codex-0157-no-daemon-effort-override',
'codex-0157-fresh-home-daemon-install',
'codex-0158-fresh-home-greeting'
]
function screenText(frame: TranscriptReplayFrame): string {
return frame.screenLines.join('\n')
}
async function lastFrame(data: string): Promise<TranscriptReplayFrame | null> {
let last: TranscriptReplayFrame | null = null
for await (const frame of replayTranscript(data, 120, 40)) {
last = frame
}
return last
}
// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup
// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint.
function answered(name: string): string {
const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting')
return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h'))
}
describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => {
it.each(DIALOGS)(
'$name: reports $reason from its key row on, and never reads ready while it is up',
async ({ name, reason, heading, keyRow }) => {
let headingFrames = 0
let keyRowFrames = 0
for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) {
const screen = screenText(frame)
if (screen.includes(heading)) {
headingFrames += 1
expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
false
)
}
if (keyRowFrames > 0 || screen.includes(keyRow)) {
keyRowFrames += 1
expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason)
}
}
// Presence precondition: the dialog and its key row were painted, not just parsed.
expect(headingFrames).toBeGreaterThan(0)
expect(keyRowFrames).toBeGreaterThan(0)
}
)
it.each(DIALOGS_0158)(
'$name: stops reporting once Codex repaints its chat after it',
async ({ name, heading }) => {
const last = await lastFrame(answered(name))
// Presence precondition: the answered dialog is still in the text copy.
expect(last?.waitText).toContain(heading.replace(' ·', ''))
expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull()
}
)
it.each(DIALOGS)(
'$name: still reports $reason when Codex is relaunched in the same pane and shows it again',
async ({ name, reason }) => {
// Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch.
const dialog = readRuntimeFixture(name)
const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`)
expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason)
expect(
isKnownReadyPromptBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null)
).toBe(false)
}
)
it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => {
const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? ''
expect(detectTerminalWaitBlockedReason(waitText)).toBeNull()
// Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`.
const chat = [
'› is there an update available, and do my hooks need review?',
'✨ Update available! 0.158.0 -> 0.159.0',
'Run npm install -g @openai/codex to update.',
' gpt-6-astra default · ~/repo',
' ← for agents · ? for shortcuts'
].join('\n')
expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull()
})
it('does not name a mid-session Codex popup a hooks review', () => {
// Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`.
const popup = [
' Use this reset?',
' 1. Yes, use reset Reset your weekly and 5-hour usage limits.',
'› 2. No, go back Choose a different reset',
' enter confirm · esc back'
].join('\n')
expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt')
})
describe('through the runtime', () => {
it.each(DIALOGS)(
'$name: stops a tui-idle wait as $reason instead of typing into it',
async ({ name, reason }) => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: readRuntimeFixture(name),
size: { cols: 120, rows: 40 }
})
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 })
).resolves.toMatchObject({ satisfied: false, blockedReason: reason })
},
15_000
)
})
})
@@ -34,6 +34,27 @@ export function findCodexScreenReadyPromptIndex(screen: string): number | null {
: null
}
// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live
// screen shows no header then, while the text copy keeps the provisional one until the live chat
// paints its footer after it (a later model repaint rewrites only the value, never the label).
// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`);
// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first.
export function isCodexProvisionalStartupText(normalized: string): boolean {
const headerIndex = normalized.lastIndexOf('openai codex')
if (headerIndex === -1) {
return false
}
const loading = /model:\s+loading/.exec(normalized.slice(headerIndex))
return loading !== null && !normalized.includes('·', headerIndex + loading.index)
}
// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the
// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered.
export function findCodexHeaderIndex(normalized: string): number | null {
const index = normalized.lastIndexOf('openai codex (v')
return index === -1 ? null : index
}
/**
* Tier 1b, codex panes only: the empty composer with no busy status row just above it and no
* header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header
@@ -11,7 +11,7 @@ import {
import { withTimeout } from './runtime-async-boundaries'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
isKnownReadyPromptSettled
} from './terminal-wait-detection'
import type {
RuntimeTerminalWait,
@@ -80,7 +80,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
const ready =
agent === 'antigravity'
? isAntigravityReadyPromptSnapshot(snapshotText)
: isKnownReadyPromptPreview(snapshotText)
: isKnownReadyPromptSettled(snapshotText)
if (!blockedReason && !ready) {
return
}
@@ -0,0 +1,76 @@
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the
// startup header for the live chat's footer, so each dialog must be matched by the time that `·`
// lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the
// text copy can lose letters and spaces (`updat available`); these rows are fixed literals.
// Update: `Update available · 0.157.1 → 0.158.0`, then `enter continue · esc skip`.
const CODEX_UPDATE_ROW_RE = /available\s*·\s*\d+\.\d+|enter\s*continue\s*·\s*esc\s*skip/g
// Why not `esc back`: Codex's mid-session pickers end `enter confirm · esc back`.
const CODEX_HOOKS_REVIEW_KEY_ROW_RE = /enter\s*confirm\s*·(?!\s*esc\s*back)/g
// Why both verbs: the retired-model notice says `continue`, the new-model announcement `confirm`.
const CODEX_MODEL_MIGRATION_KEY_ROW_RE = /enter\/esc\s*(?:continue|confirm)\s*·/g
function lastMatchIndex(text: string, row: RegExp): number {
let index = -1
for (const match of text.matchAll(row)) {
index = match.index
}
return index
}
// Why the last match, and the legacy wording too: an answered dialog stays in the text copy, and
// builds before 0.157 wrote `Press enter to continue/confirm`.
function findDialogIndex(
normalized: string,
legacyHeading: string,
legacyKeys: string,
row: RegExp
): number {
const rowIndex = lastMatchIndex(normalized, row)
const headingIndex = normalized.lastIndexOf(legacyHeading)
const legacyIndex =
headingIndex !== -1 && normalized.includes(legacyKeys, headingIndex) ? headingIndex : -1
return Math.max(rowIndex, legacyIndex)
}
// Why together: each startup dialog owns Enter before the chat exists, so a brief typed into one
// answers it (Codex's update dialog defaults to `Update now`).
export function findStartupDialogBlockedSignals(
normalized: string
): { reason: RuntimeTerminalWaitBlockedReason; index: number }[] {
const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = []
const updateIndex = findDialogIndex(
normalized,
'update available',
'press enter to continue',
CODEX_UPDATE_ROW_RE
)
if (updateIndex !== -1) {
candidates.push({ reason: 'agent-update-prompt', index: updateIndex })
}
const cwdIndex = normalized.lastIndexOf('choose working directory to')
if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) {
candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex })
}
const modelMigrationIndex = findDialogIndex(
normalized,
'codex just got an upgrade',
'press enter to continue',
CODEX_MODEL_MIGRATION_KEY_ROW_RE
)
if (modelMigrationIndex !== -1) {
candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex })
}
const hooksIndex = findDialogIndex(
normalized,
'hooks need review',
'press enter to confirm',
CODEX_HOOKS_REVIEW_KEY_ROW_RE
)
if (hooksIndex !== -1) {
// Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording.
candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex })
}
return candidates
}
+21 -24
View File
@@ -9,9 +9,12 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type
import type { TuiAgent } from '../../shared/tui-agent'
import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness'
import {
findCodexHeaderIndex,
findCodexScreenReadyPromptIndex,
isCodexComposerReadyScreen
isCodexComposerReadyScreen,
isCodexProvisionalStartupText
} from './codex-terminal-readiness'
import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals'
import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window'
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
@@ -52,6 +55,19 @@ export function isKnownReadyPromptPreview(preview: string): boolean {
return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized))
}
/**
* The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview
* (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed
* behind it while its daemon starts.
*/
export function isKnownReadyPromptSettled(preview: string): boolean {
const normalized = preview.toLowerCase()
return (
isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) &&
!isCodexProvisionalStartupText(normalized)
)
}
/**
* Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's
* visible grid, or null when the runtime has no trustworthy one.
@@ -69,7 +85,7 @@ export function isKnownReadyPromptBody(
if (agent === 'qoder') {
return isQoderComposerReady(readScreenLines())
}
if (isKnownReadyPromptPreview(waitText)) {
if (isKnownReadyPromptSettled(waitText)) {
return true
}
// Why the agent gate: another agent's screen can merely mention "OpenAI Codex".
@@ -139,6 +155,7 @@ export function findActionableTerminalWaitBlockedSignal(
function findDismissedStartupModalIndex(normalized: string): number | null {
const indexes = [
findCodexReadyPromptIndex(normalized),
findCodexHeaderIndex(normalized),
findAntigravityReadyPromptIndex(normalized),
findCursorActivePromptIndex(normalized),
findMuseReadyPromptIndex(normalized)
@@ -199,7 +216,7 @@ function findCodexReadyPromptIndex(normalized: string): number | null {
}
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =
/update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
/update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority.
const CURSOR_APPROVAL_CHOICE_MARKERS = [
@@ -268,27 +285,7 @@ function findTerminalWaitBlockedSignal(
function findBlockedSignalInLiveWindow(
normalized: string
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = []
const updateIndex = normalized.lastIndexOf('update available')
if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) {
candidates.push({ reason: 'agent-update-prompt', index: updateIndex })
}
const cwdIndex = normalized.lastIndexOf('choose working directory to')
if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) {
candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex })
}
const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade')
if (
modelMigrationIndex !== -1 &&
normalized.includes('press enter to continue', modelMigrationIndex)
) {
candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex })
}
const hooksIndex = normalized.lastIndexOf('hooks need review')
if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) {
// Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording.
candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex })
}
const candidates = findStartupDialogBlockedSignals(normalized)
const trustIndex = Math.max(
normalized.lastIndexOf('do you trust'),
normalized.lastIndexOf('trust this'),