mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* 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
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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)
|
||
})
|
||
})
|