Files
orca/src/main/runtime/codex-header-readiness-transcripts.test.ts
T
Jinwoo Hong fb67d5d7c3 fix(runtime): stop a busy Codex 0.150-0.157 pane reading as tui-idle (#23805)
* fix(runtime): stop a busy Codex 0.150-0.157 pane reading as tui-idle

The startup header box (OpenAI Codex / model: / directory:) stays on
screen and in the tail for the whole session, so as tier-1 evidence it
settled tui-idle mid-turn. For a codex pane it now counts only in the
quiet lane, held to the same quiescence as the composer.

* fix(runtime): keep a restored Codex pane's header as tier-1 readiness

A restored or reattached pane has no lastOutputAt, so the quiet lane that
now holds a Codex header can never fire and the wait sat pending until
timeout, where main settled it. Gate the Codex tier-1 veto on the output
clock rather than the agent name, and share one settled-prompt helper.

* test(runtime): read no screen in the restored Codex pane test

* test(runtime): pin the clock in the clockless Codex header cases

* test(runtime): name the screen-readiness comparison for what it proves
2026-09-29 22:28:55 -04:00

318 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
import {
readRuntimeFixture,
replayTranscript,
type TranscriptReplayFrame
} from './agent-transcript-replay-test-harness'
import {
isKnownReadyPromptBody,
isKnownReadyPromptPreview,
isKnownReadyPromptSettled,
isQuietReadyScreenBody
} 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') }
}))
// codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628.
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'
// 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,
cols: number,
rows: number
): Promise<TranscriptReplayFrame> {
let last: TranscriptReplayFrame | null = null
for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) {
last = frame
}
if (!last) {
throw new Error(`empty fixture ${name}`)
}
return last
}
function screenShowsLoadingHeader(screenLines: string[]): boolean {
const screen = screenLines.join('\n').toLowerCase()
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',
async (name) => {
const { waitText } = await finalFrame(name, 120, 40)
// Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout.
expect(waitText.toLowerCase()).toContain('dirctory:')
expect(isKnownReadyPromptPreview(waitText)).toBe(false)
}
)
it.each(ALL_FIXTURES)(
'%s: the screen never adds readiness while loading, and is ready at the final screen',
async (name) => {
let sawLoadingHeader = false
let last: TranscriptReplayFrame | null = null
for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) {
if (screenShowsLoadingHeader(frame.screenLines)) {
sawLoadingHeader = true
expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false)
}
last = frame
}
// Presence precondition: a loading frame was actually exercised.
expect(sawLoadingHeader).toBe(true)
expect(last).not.toBeNull()
expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true)
}
)
// 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(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
false
)
expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).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],
[80, 24],
[30, 50],
[108, 30],
[60, 5]
])('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 (isKnownReadyPromptSettled(frame.waitText)) {
settledFrames += 1
expect(isQuietReadyScreenBody(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)
}
})
})
it('keeps the text rules when there is no live screen', async () => {
const { waitText } = await finalFrame(PLAIN, 120, 40)
expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(
isKnownReadyPromptPreview(waitText)
)
expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true)
})
it('does not read a mid-turn composer as ready', () => {
const screenLines = [
'› Summarize the repository layout',
'• Working (12s • esc to interrupt)',
'› Ask Codex to do anything',
' GPT-6-Sol high · ~/repo/app'
]
expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false)
})
it('does not settle when a blocking dialog is painted below the header', () => {
const screenLines = [
'│ >_ OpenAI Codex (v0.157.1) │',
'│ model: GPT-6-Sol high /model to change │',
'│ directory: ~/repo/app │',
'Do you trust the contents of this directory?',
'Press enter to continue'
]
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false)
})
it('reads only the header box, not chat below it that mentions Codex', () => {
const screenLines = [
'╭──────────────────────────────────────────────────────────╮',
'│ >_ OpenAI Codex (v0.157.1) │',
'│ model: GPT-6-Sol high /model to change │',
'│ directory: ~/repo/app │',
'╰──────────────────────────────────────────────────────────╯',
'› Why does OpenAI Codex print model: loading at startup?'
]
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true)
})
it('never reads a non-codex screen, even one showing the Codex header', () => {
const screenLines = [
'│ >_ OpenAI Codex (v0.157.1) │',
'│ model: GPT-6-Sol high /model to change │',
'│ directory: ~/repo/app │'
]
const readScreenLines = vi.fn(() => screenLines)
expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false)
expect(readScreenLines).not.toHaveBeenCalled()
expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true)
})
describe('at the 80x24 default grid the header garbles and today’s answer stands', () => {
it.each(ALL_FIXTURES)('%s', async (name) => {
const { screenLines, waitText } = await finalFrame(name, 80, 24)
expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe(
isKnownReadyPromptPreview(waitText)
)
})
})
describe('through the runtime', () => {
async function codexPane(name: string, size?: { cols: number; rows: number }) {
return createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: readRuntimeFixture(name),
size
})
}
it.each(ALL_FIXTURES)(
'%s: a tui-idle wait settles from the live screen',
async (name) => {
const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 })
// Why 8s: quiescence (3s) plus the 2s poll re-reading the grid.
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 })
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
},
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 }
})
// Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds.
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 })
).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)
// Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out.
it('settles a restored Codex pane from its header, as main did', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: ''
})
runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, {
text: [
'╭──────────────────────────────────────────╮',
'│ >_ OpenAI Codex (v0.157.1) │',
'│ model: gpt-6-sol high /model to change │',
'│ directory: ~/repo/app │',
'╰──────────────────────────────────────────╯',
'› Ask Codex to do anything',
' gpt-6-sol high · ~/repo/app'
].join('\r\n')
})
// Why no screen: the visible-screen probe must not be what settles the wait.
vi.spyOn(runtime, 'readTerminal').mockResolvedValue({
handle,
status: 'running',
tail: [],
truncated: false,
nextCursor: null,
source: 'screen-unavailable'
})
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 })
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
}, 15_000)
it('keeps timing out on the garbled 80x24 default grid, as before', async () => {
const { runtime, handle } = await codexPane(EFFORT_OVERRIDE)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 })
).rejects.toThrow(/timeout/)
}, 15_000)
})
})