mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
fix(runtime): settle a quiet Codex composer as ready on every version (#23765)
* fix(runtime): settle a quiet Codex composer as tui-idle on every version Codex 0.158 dropped `model:`/`directory:` from its startup header, which both Codex readiness rules require, so `worker-start --agent codex` timed out; an idle Codex pane after a turn also had no readiness signal once the header left the screen. Generalize the Muse tier-1b lane into a quiet-ready-screen lane: a Codex (or agent-unknown) pane whose live screen shows the empty composer placeholder, no `to interrupt)` status row, no header `loading`, and no dialog wording in its live window, settles once the stream has been quiet for the tui-idle quiescence window. Additive only: the tier-1 rules and the Muse rule are unchanged. Fixtures: codex 0.150.1-0.158.0 captures at 120x40, including chunk-timed turns. * refactor(runtime): anchor the Codex quiet lane to the empty composer line Move the Codex screen rules into codex-terminal-readiness.ts and the quiet-screen body beside isKnownReadyPromptBody. The composer rule now matches only the `› Ask Codex to do anything` line and drops its dialog markers: every Codex dialog replaces the composer, and an answer ending "Would you like to…?" above a live composer must not hold the lane forever. The quiet lane checks quiescence before reading the screen. Trim the redundant startup and untimed turn fixtures. * fix(runtime): read Codex's busy row above the composer and scope the lane to codex panes * fix(runtime): read only Codex's live status row above the composer
This commit is contained in:
+2
-1
@@ -8,6 +8,7 @@
|
||||
"cloud/**",
|
||||
"resources/licenses/**",
|
||||
".github/actions/cloud-sql-rollout-lease/**",
|
||||
".anti-slop-plugin/**"
|
||||
".anti-slop-plugin/**",
|
||||
"src/main/runtime/__fixtures__/*.timing.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ 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,
|
||||
// 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,
|
||||
'codex-0-158-0-timed-turn': 20,
|
||||
'codex-0-158-0-trustprompt': 36,
|
||||
'claude-dialog-trust-workspace-answered': 13,
|
||||
// DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this
|
||||
// transcript's divergences is the same shape: `visible-grid row=0`, a true-colour
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:35:32.695Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"codex",
|
||||
"-c",
|
||||
"check_for_update_on_startup=false",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"-m",
|
||||
"gpt-5.6-sol"
|
||||
],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.150.1: launch, one short turn, settled idle after it. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:44:01.149Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"codex",
|
||||
"-c",
|
||||
"check_for_update_on_startup=false",
|
||||
"--dangerously-bypass-approvals-and-sandbox"
|
||||
],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.155.1, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. <name>.timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:41:43.328Z",
|
||||
"platform": "darwin",
|
||||
"command": ["codex", "--no-daemon", "--dangerously-bypass-approvals-and-sandbox"],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.157.1: launch with the update check on; the update dialog owns the screen. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[?2004h[>4;0m[>7u[?1004h[6n]10;?\]11;?\[?u[c[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[1;1H[J[?2026h[?25l[1;1H[2m╭───────────────────────────────────────╮[2;1H│ >_ [22m[1mOpenAI Codex[22m[2m[2m (v0.157.1) │[3;1H│ │[4;1H│ model: [3mloading[23m [22m[38;2;99;168;248;49m/model[2m[39;49m to change │[5;1H│ directory: [22mloading[2m │[6;1H╰───────────────────────────────────────╯[37;1H[22m[1m›[37;3H[22m[2m[2mAsk Codex to do anything[40;3H[22m[1m?[40;5H[22mfor[40;9Hshortcuts[39m[49m[0m[1;1H[0 q[1;1H[2m╭[39m[49m[0m[37;3H[?25h[?2026l[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[?25l[1;41H[2m───────────╮[2;41H │[3;41H │[4;14H [3mloading[4;24H[23m [22m[38;2;99;168;248;49m/model[2m[39;49m to change │[5;14H [22m~/orca-lanes/sta8834/corpus/scratch[2m │[6;1H│ permissions: [22m[1m[38;5;5;49mYOLO mode[22m[2m[2m[39;49m │[7;1H╰──────────────────────────────────────────────────╯[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[?25l[1;2H[0m[49m[K[2;39H[0m[49m[K[4;2H[0m[49m[K[6;10H[0m[49m[K[7;29H[0m[49m[K[37;2H[0m[49m[K[40;2H[0m[49m[K[1;1H [2;1H [1mUpdat[2;9H available[22m[2m[2m · 0.157.1[2;30H→[2;32H0.158.0[3;1H[22m [2mRelease[3;11Hnotes:[3;18H[4m]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;;[4;1H[24m[22m [5;1H[7m[1m› 1. Update now (runs `npm install -g @openai/codex`) [6;1H[27m[22m 2. Skip[7;1H 3. Skip until next version[9;3H[1menter[22m[2m[2m continue · [22m[1mesc[22m[2m[2m skip[37;1H[22m [39m[49m[0m[?2026l
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:43:13.919Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"codex",
|
||||
"--no-daemon",
|
||||
"-c",
|
||||
"check_for_update_on_startup=false",
|
||||
"-a",
|
||||
"on-request",
|
||||
"-s",
|
||||
"read-only"
|
||||
],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.158.0: read-only sandbox with on-request approvals; a command approval dialog owns the screen at the end. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:46:36.717Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"codex",
|
||||
"--no-daemon",
|
||||
"-c",
|
||||
"check_for_update_on_startup=false",
|
||||
"--dangerously-bypass-approvals-and-sandbox"
|
||||
],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.158.0, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. <name>.timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"capturedAt": "2026-09-29T01:32:55.701Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"codex",
|
||||
"--no-daemon",
|
||||
"-c",
|
||||
"check_for_update_on_startup=false",
|
||||
"--dangerously-bypass-approvals-and-sandbox"
|
||||
],
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"note": "codex-cli 0.158.0: launch in an untrusted folder; the recording ends on the unanswered trust dialog. STA-8834.",
|
||||
"exitCode": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[?2004h[>4;0m[>7u[?1004h[6n]10;?\]11;?\[?u[c[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[1;1H[J[?2026h[?25l[2;3H[38;2;99;168;248;49m>_ [1m[39;49mOpenAI Codex[22m[2m[2m (v0.158.0)[3;6Hloading[5;3H[22m[38;2;99;168;248;49mHello, world. Hello, you.[12;56H[2m[39;49m⣀⣤⣤⣤⣀⣀[13;53H⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀[14;51H⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄[15;50H⣀⣾⣿⣿⠃[15;56H⣀⣴⣾⣿⣿⡿⠟⠋[15;65H⣀⡀[15;68H⠈⠙⢿⣿⣿⣧[16;47H⣀⣶⣿⣿⣿⣿⡇[16;55H⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇[17;46H⣰⣿⣿⡿⢿⣿⣿⡇[17;55H⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷[18;45H⢰⣿⣿⡟⠁⢸⣿⣿⡇[18;55H⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀[19;45H⣾⣿⣿⠃[19;50H⢸⣿⣿⡇[19;55H⢸⣿⣿⠋⠁[19;62H⠈⠙⢻⣿⣿⣿⣶⣤⡀[19;72H⠹⢿⣿⣷⡄[20;45H⢸⣿⣿⣇[20;50H⠸⣿⣿⣷⣦⣼⣿⣿[20;64H⢸⣿⣿⠻⢿⣿⣿⡇[20;73H⠘⣿⣿⣿[21;45H⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀[21;62H⢀⣠⣼⣿⣿[21;69H⣿⣿⡇[21;74H⣿⣿⣿⠇[22;47H⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿[22;69H⣿⣿⡇[22;73H⣠⣿⣿⣿[23;47H⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿[23;69H⣿⣿⣷⣴⣿⣿⣿⠃[24;47H⠘⣿⣿⣷[24;52H⠈⠛⢿⣿⣿⣿⠿⠋⠉[24;62H⣠⣴⣿⣿⣿[24;69H⣿⣿⣿⣿⣿⠟⠁[25;48H⠻⣿⣿⣿⣤⣀[25;55H⠈⠉[25;58H⢀⣤⣶⣿⣿⣿⠿⠟⠁[25;68H⣰⣿⣿⡟⠋⠁[26;49H⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁[26;66H⣠⣼⣿⣿⡟[27;52H⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏[28;59H⠈⠙⠛⠿⠿⠿⠿⠛⠉[37;1H[22m[1m›[37;3H[22m[2m[2mAsk Codex to do anything[40;3H[22m[1m?[40;5H[22mfor[40;9Hshortcuts[39m[49m[0m[1;1H[0 q[1;1H [39m[49m[0m[37;3H[?25h[?2026l[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[?25l[5;2H[0m[49m[K[3;6H[2m~/orca-lanes/sta8834/corpus/scratch[4;1H permissions: [22m[1m[38;5;5;49mYOLO mode[6;3H[22m[38;2;99;168;248;49mHello, world. Hello, you.[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?2026h[39m[49m[0m[37;3H[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[?1007h[?2026h[?25l[2;16H[0m[49m[K[4;2H[0m[49m[K[12;28H[0m[49m[K[13;2H[0m[49m[K[14;2H[0m[49m[K[15;2H[0m[49m[K[16;2H[0m[49m[K[17;2H[0m[49m[K[18;2H[0m[49m[K[19;2H[0m[49m[K[20;2H[0m[49m[K[21;2H[0m[49m[K[22;2H[0m[49m[K[23;2H[0m[49m[K[24;2H[0m[49m[K[25;2H[0m[49m[K[26;2H[0m[49m[K[27;2H[0m[49m[K[28;2H[0m[49m[K[37;2H[0m[49m[K[40;2H[0m[49m[K[2;1H[1m Folder access[3;3H[22m[2m[2m/Users/xxxxxx/orca-lanes/sta8834/corpus/scratch [4;1H[22m [5;3HTrust[5;9Hthis[5;14Hfolder?[5;22HCodex[5;28Hcan[5;32Hread,[5;38Hedit,[5;44Hand[5;48Hrun[5;52Hfiles[5;58Hhere,[5;64Hsubject[5;72Hto[5;75Hyour[5;80Hpermission[5;91Hsettings.[5;101HFolder[5;108Hsettings[6;3Hcan run code automatically,[6;31Heven[6;36Hwithout[6;44Ha[6;46Hmodel[6;52Hrequest.[6;61HContinue[6;70Honly[6;75Hif[6;78Hyou[6;82Htrust[6;88Hthese[6;94Hfiles.[6;101HYour[6;106Htrust[7;3Hdecision[7;12Hwill[7;17Hbe[7;20Hsaved.[9;1H[7m[1m› 1. Trust and continue [10;3H[27m[22m2.[10;6HQuit[12;3H[1menter[22m[2m[2m continue · [22m[1mesc[22m[2m[2m quit[37;1H[22m [39m[49m[0m[?2026l
|
||||
@@ -0,0 +1,56 @@
|
||||
// Replays captured PTY bytes the way onPtyData does, for suites asserting a rule on every frame.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection'
|
||||
import { normalizeTerminalChunk } from './terminal-ansi-normalization'
|
||||
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
|
||||
import { buildPreview } from './terminal-tail-state'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
|
||||
const DEFAULT_CHUNK_CHARS = 64
|
||||
|
||||
export type TranscriptReplayFrame = { screenLines: string[]; waitText: string }
|
||||
|
||||
export function readRuntimeFixture(name: string): string {
|
||||
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
|
||||
}
|
||||
|
||||
/** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */
|
||||
export async function* replayTranscript(
|
||||
data: string | readonly string[],
|
||||
cols: number,
|
||||
rows: number
|
||||
): AsyncGenerator<TranscriptReplayFrame> {
|
||||
const chunks = typeof data === 'string' ? splitIntoChunks(data) : data
|
||||
const emulator = new HeadlessEmulator({ cols, rows })
|
||||
let lines: string[] = []
|
||||
let partialLine = ''
|
||||
let pendingAnsi = ''
|
||||
let redrawCursor: ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor'] = null
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
await emulator.write(chunk)
|
||||
const normalized = normalizeTerminalChunk(chunk, pendingAnsi)
|
||||
pendingAnsi = normalized.pendingAnsi
|
||||
const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor)
|
||||
lines = tail.lines
|
||||
partialLine = tail.partialLine
|
||||
redrawCursor = tail.redrawCursor
|
||||
yield {
|
||||
screenLines: projectTerminalVisibleLines(emulator).lines,
|
||||
waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function splitIntoChunks(data: string): string[] {
|
||||
const chunks: string[] = []
|
||||
for (let offset = 0; offset < data.length; offset += DEFAULT_CHUNK_CHARS) {
|
||||
chunks.push(data.slice(offset, offset + DEFAULT_CHUNK_CHARS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
|
||||
import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection'
|
||||
import { normalizeTerminalChunk } from './terminal-ansi-normalization'
|
||||
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
|
||||
import { buildPreview } from './terminal-tail-state'
|
||||
import {
|
||||
readRuntimeFixture,
|
||||
replayTranscript,
|
||||
type TranscriptReplayFrame
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
@@ -23,44 +20,14 @@ 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]
|
||||
const CHUNK_CHARS = 64
|
||||
|
||||
function readFixture(name: string): string {
|
||||
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
|
||||
}
|
||||
|
||||
type ReplayFrame = { screenLines: string[]; waitText: string }
|
||||
|
||||
/** Feeds the bytes the way onPtyData does: one emulator grid, one line-folded wait text. */
|
||||
async function* replay(data: string, cols: number, rows: number): AsyncGenerator<ReplayFrame> {
|
||||
const emulator = new HeadlessEmulator({ cols, rows })
|
||||
let lines: string[] = []
|
||||
let partialLine = ''
|
||||
let pendingAnsi = ''
|
||||
let redrawCursor: ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor'] = null
|
||||
try {
|
||||
for (let offset = 0; offset < data.length; offset += CHUNK_CHARS) {
|
||||
const chunk = data.slice(offset, offset + CHUNK_CHARS)
|
||||
await emulator.write(chunk)
|
||||
const normalized = normalizeTerminalChunk(chunk, pendingAnsi)
|
||||
pendingAnsi = normalized.pendingAnsi
|
||||
const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor)
|
||||
lines = tail.lines
|
||||
partialLine = tail.partialLine
|
||||
redrawCursor = tail.redrawCursor
|
||||
yield {
|
||||
screenLines: projectTerminalVisibleLines(emulator).lines,
|
||||
waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function finalFrame(name: string, cols: number, rows: number): Promise<ReplayFrame> {
|
||||
let last: ReplayFrame | null = null
|
||||
for await (const frame of replay(readFixture(name), cols, rows)) {
|
||||
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) {
|
||||
@@ -89,8 +56,8 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
|
||||
'%s: the screen never adds readiness while loading, and is ready at the final screen',
|
||||
async (name) => {
|
||||
let sawLoadingHeader = false
|
||||
let last: ReplayFrame | null = null
|
||||
for await (const frame of replay(readFixture(name), 120, 40)) {
|
||||
let last: TranscriptReplayFrame | null = null
|
||||
for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) {
|
||||
if (screenShowsLoadingHeader(frame.screenLines)) {
|
||||
sawLoadingHeader = true
|
||||
expect(isKnownReadyPromptBody('', 'codex', () => frame.screenLines)).toBe(false)
|
||||
@@ -113,7 +80,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
|
||||
[60, 5]
|
||||
])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => {
|
||||
it.each(ALL_FIXTURES)('%s', async (name) => {
|
||||
for await (const frame of replay(readFixture(name), cols, rows)) {
|
||||
for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) {
|
||||
if (isKnownReadyPromptPreview(frame.waitText)) {
|
||||
expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
|
||||
true
|
||||
@@ -191,7 +158,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
|
||||
paneTitle: 'Terminal',
|
||||
foregroundProcess: 'codex',
|
||||
launchAgent: 'codex',
|
||||
data: readFixture(name),
|
||||
data: readRuntimeFixture(name),
|
||||
size
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
|
||||
import {
|
||||
readRuntimeFixture,
|
||||
replayTranscript,
|
||||
type TranscriptReplayFrame
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isCodexComposerReadyScreen } from './codex-terminal-readiness'
|
||||
import {
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptBody,
|
||||
isQuietReadyScreenBody
|
||||
} from './terminal-wait-detection'
|
||||
import {
|
||||
evaluateTuiIdle,
|
||||
hasQuietReadyScreen,
|
||||
isTuiIdleReadyVerdict,
|
||||
type TuiIdleEvaluationInput
|
||||
} from './tui-idle-evidence'
|
||||
|
||||
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') }
|
||||
}))
|
||||
|
||||
const QUIESCENCE_MS = 3000
|
||||
|
||||
// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn,
|
||||
// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's.
|
||||
const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn']
|
||||
const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES]
|
||||
const DIALOG_FIXTURES = [
|
||||
'codex-0-157-1-update-dialog',
|
||||
'codex-0-158-0-approval',
|
||||
'codex-0-158-0-trustprompt'
|
||||
]
|
||||
const STA_8628_FIXTURES = [
|
||||
'codex-0157-plain-ready',
|
||||
'codex-0157-effort-override-embedded-warning',
|
||||
'codex-0157-config-override-embedded-warning',
|
||||
'codex-0157-no-daemon-effort-override'
|
||||
]
|
||||
|
||||
const BUSY_STATUS_RE = /to interrupt\)/
|
||||
const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m
|
||||
const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m
|
||||
const DIALOG_RE =
|
||||
/update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/
|
||||
|
||||
function screenOf(frame: TranscriptReplayFrame): string {
|
||||
return frame.screenLines.join('\n').toLowerCase()
|
||||
}
|
||||
|
||||
async function collectFrames(
|
||||
data: string | readonly string[],
|
||||
cols = 120,
|
||||
rows = 40
|
||||
): Promise<TranscriptReplayFrame[]> {
|
||||
const frames: TranscriptReplayFrame[] = []
|
||||
for await (const frame of replayTranscript(data, cols, rows)) {
|
||||
frames.push(frame)
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('Codex composer ready screen, frame by frame', () => {
|
||||
it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])(
|
||||
'%s: never ready while loading, mid-turn, or under a drawn dialog',
|
||||
async (name) => {
|
||||
const seen = { loading: 0, busy: 0, dialog: 0 }
|
||||
for (const frame of await collectFrames(readRuntimeFixture(name))) {
|
||||
const screen = screenOf(frame)
|
||||
// Why only once the composer is gone: a half-drawn dialog keeps it for a few frames,
|
||||
// which quiescence absorbs; a drawn dialog replaces it.
|
||||
const kind = HEADER_LOADING_RE.test(screen)
|
||||
? 'loading'
|
||||
: BUSY_STATUS_RE.test(screen)
|
||||
? 'busy'
|
||||
: DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen)
|
||||
? 'dialog'
|
||||
: null
|
||||
if (kind) {
|
||||
seen[kind] += 1
|
||||
expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({
|
||||
kind,
|
||||
ready: false
|
||||
})
|
||||
}
|
||||
}
|
||||
// Presence preconditions: each fixture exercises the states it was recorded for.
|
||||
expect(seen.loading).toBeGreaterThan(0)
|
||||
if (DIALOG_FIXTURES.includes(name)) {
|
||||
expect(seen.dialog).toBeGreaterThan(0)
|
||||
} else {
|
||||
expect(seen.busy).toBeGreaterThan(0)
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])(
|
||||
'%s: ready on the final idle screen',
|
||||
async (name) => {
|
||||
const frames = await collectFrames(readRuntimeFixture(name))
|
||||
expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => {
|
||||
const frames = await collectFrames(readRuntimeFixture(name))
|
||||
expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false)
|
||||
})
|
||||
|
||||
it('is not ready without a live screen', () => {
|
||||
expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the placeholder only as the composer line, not quoted mid-line', () => {
|
||||
const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$']
|
||||
expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the busy row only above the composer, not a status row quoted in the answer', () => {
|
||||
const screenLines = [
|
||||
'>_ OpenAI Codex (v0.158.0)',
|
||||
' ~/repo',
|
||||
'',
|
||||
'› what does the status row look like mid-turn?',
|
||||
'',
|
||||
'• It reads like this:',
|
||||
'',
|
||||
' • Working (0s • esc to interrupt)',
|
||||
'',
|
||||
' The timer counts up until the turn ends, and the row',
|
||||
' disappears once the answer is complete.',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
' gpt-5.6-sol medium · ~/repo',
|
||||
' ? for shortcuts'
|
||||
]
|
||||
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true)
|
||||
const composer = screenLines.indexOf('› Ask Codex to do anything')
|
||||
const busy = [
|
||||
...screenLines.slice(0, composer),
|
||||
'• Working (3s • esc to interrupt)',
|
||||
' └ Tip: start a fresh idea with /new',
|
||||
'',
|
||||
...screenLines.slice(composer)
|
||||
]
|
||||
expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false)
|
||||
})
|
||||
|
||||
it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => {
|
||||
// Live 0.158 screen after a worker answered with a status row verbatim (STA-8834).
|
||||
const screenLines = [
|
||||
' === TASK ===',
|
||||
' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.',
|
||||
'• • Working (0s • esc to interrupt)',
|
||||
'• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…',
|
||||
' └ Sent msg_ff7be3f25d7e',
|
||||
' + Show details',
|
||||
'• • Working (0s • esc to interrupt)',
|
||||
' 11:15 PM',
|
||||
'› Ask Codex to do anything',
|
||||
' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome',
|
||||
' ? for shortcuts'
|
||||
]
|
||||
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true)
|
||||
})
|
||||
|
||||
it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => {
|
||||
const screenLines = [
|
||||
'>_ OpenAI Codex (v0.158.0)',
|
||||
' ~/repo',
|
||||
'',
|
||||
'› tidy the README',
|
||||
'',
|
||||
'• Done. Would you like to proceed with the changelog too?',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
' ? for shortcuts'
|
||||
]
|
||||
const waitText = screenLines.join('\n')
|
||||
const verdict = evaluateTuiIdle({
|
||||
record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null },
|
||||
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText),
|
||||
readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText, 'codex', () => screenLines),
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(waitText, 'codex', () => screenLines),
|
||||
agent: 'codex',
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
})
|
||||
expect(verdict.kind).toBe('ready-strong')
|
||||
})
|
||||
})
|
||||
|
||||
type Timing = { promptSentAtMs: number; chunks: [number, number][] }
|
||||
|
||||
function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } {
|
||||
const data = readRuntimeFixture(name)
|
||||
const timing: Timing = JSON.parse(
|
||||
readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')
|
||||
)
|
||||
const chunks: string[] = []
|
||||
let offset = 0
|
||||
for (const [, length] of timing.chunks) {
|
||||
chunks.push(data.slice(offset, offset + length))
|
||||
offset += length
|
||||
}
|
||||
expect(offset).toBe(data.length)
|
||||
return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs }
|
||||
}
|
||||
|
||||
describe('the quiet lane over recorded chunk timing (default animations)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each(TIMED_FIXTURES)(
|
||||
'%s: never settles mid-turn, and settles once the finished turn is quiet',
|
||||
async (name) => {
|
||||
const { chunks, times, promptAt } = readTimedFixture(name)
|
||||
const frames = await collectFrames(chunks)
|
||||
// Why after the replay: the emulator's write flush runs on real timers.
|
||||
vi.useFakeTimers()
|
||||
const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame)))
|
||||
const firstTurnChunk = times.findIndex((at) => at >= promptAt)
|
||||
expect(lastBusy).toBeGreaterThan(firstTurnChunk)
|
||||
// The latest moment each frame stays on screen: just before the next chunk lands.
|
||||
const settlesAt = (index: number, now: number): boolean => {
|
||||
vi.setSystemTime(now)
|
||||
return hasQuietReadyScreen(
|
||||
{ lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null },
|
||||
'codex',
|
||||
() =>
|
||||
isQuietReadyScreenBody(
|
||||
frames[index]!.waitText,
|
||||
'codex',
|
||||
() => frames[index]!.screenLines
|
||||
),
|
||||
QUIESCENCE_MS
|
||||
)
|
||||
}
|
||||
let readyBodyMidTurn = 0
|
||||
for (let index = firstTurnChunk; index <= lastBusy; index += 1) {
|
||||
if (isCodexComposerReadyScreen(screenOf(frames[index]!))) {
|
||||
readyBodyMidTurn += 1
|
||||
}
|
||||
expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({
|
||||
index,
|
||||
settles: false
|
||||
})
|
||||
}
|
||||
// Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing.
|
||||
expect(readyBodyMidTurn).toBeGreaterThan(0)
|
||||
const last = frames.length - 1
|
||||
// Why +1: the fake clock keeps whole milliseconds; the recorded times do not.
|
||||
expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true)
|
||||
},
|
||||
120_000
|
||||
)
|
||||
})
|
||||
|
||||
describe('never less ready than origin/main', () => {
|
||||
const records = [
|
||||
{ lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null },
|
||||
{ lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' },
|
||||
{ lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' }
|
||||
]
|
||||
describe.each([
|
||||
[120, 40],
|
||||
[80, 24]
|
||||
])('at %ix%i', (cols, rows) => {
|
||||
it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])(
|
||||
'%s',
|
||||
async (name) => {
|
||||
for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) {
|
||||
for (const agent of ['codex', null] as const) {
|
||||
const base = {
|
||||
readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText),
|
||||
readPositiveBodyEvidence: () =>
|
||||
isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines),
|
||||
agent,
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
} satisfies Omit<TuiIdleEvaluationInput, 'record' | 'readQuietReadyBodyEvidence'>
|
||||
for (const record of records) {
|
||||
const before = evaluateTuiIdle({
|
||||
...base,
|
||||
record,
|
||||
// Why a withheld screen: that leaves exactly main's Muse lane, the only widening.
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(frame.waitText, agent, () => null)
|
||||
})
|
||||
const after = evaluateTuiIdle({
|
||||
...base,
|
||||
record,
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines)
|
||||
})
|
||||
if (isTuiIdleReadyVerdict(before)) {
|
||||
expect(isTuiIdleReadyVerdict(after)).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent gate', () => {
|
||||
const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts']
|
||||
|
||||
it("never reads another agent's screen, even one showing Codex's placeholder", () => {
|
||||
const readScreenLines = vi.fn(() => placeholderScreen)
|
||||
for (const agent of ['claude', 'muse', 'gemini'] as const) {
|
||||
expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false)
|
||||
}
|
||||
expect(readScreenLines).not.toHaveBeenCalled()
|
||||
expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => {
|
||||
const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ ']
|
||||
const readScreenLines = vi.fn(() => catted)
|
||||
expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false)
|
||||
expect(readScreenLines).not.toHaveBeenCalled()
|
||||
expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('through the runtime', () => {
|
||||
// Why 0.158 alone: its header carries no `model:`, so only this lane settles it.
|
||||
const CODEX_0158 = 'codex-0-158-0-timed-turn'
|
||||
async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) {
|
||||
return createTranscriptPane({
|
||||
paneTitle: 'Terminal',
|
||||
foregroundProcess: launchAgent,
|
||||
launchAgent,
|
||||
data: readRuntimeFixture(name),
|
||||
size
|
||||
})
|
||||
}
|
||||
|
||||
// Why 8s: quiescence (3s) plus the 2s poll re-reading the grid.
|
||||
it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => {
|
||||
const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 })
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 })
|
||||
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
|
||||
}, 15_000)
|
||||
|
||||
it('keeps a Claude pane showing the same screen pending', async () => {
|
||||
const { runtime, handle } = await pane(CODEX_0158, 'claude', {
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 })
|
||||
).rejects.toThrow(/timeout/)
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title.
|
||||
const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m
|
||||
// Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat.
|
||||
const CODEX_HEADER_LINES = 6
|
||||
// Why the whole line: a pager, a `cat`ed transcript, or chat can quote the placeholder mid-line.
|
||||
const CODEX_EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/
|
||||
// Why not "working": reasoning summaries replace it, and a remapped key still ends this way.
|
||||
const CODEX_BUSY_STATUS_MARKER = 'to interrupt)'
|
||||
// Why only a tip: it is the one line Codex draws between its status row and the composer (120x40 corpus).
|
||||
const CODEX_STATUS_TIP_PREFIX = '└ tip:'
|
||||
|
||||
// Why the header only: chat below it can mention "OpenAI Codex" or `model: loading`.
|
||||
function findCodexHeader(screen: string): { index: number; text: string } | null {
|
||||
const index = screen.indexOf('openai codex')
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
const boxEnd = screen.indexOf('╰', index)
|
||||
const text = screen
|
||||
.slice(index, boxEnd === -1 ? undefined : boxEnd)
|
||||
.split('\n', CODEX_HEADER_LINES)
|
||||
.join('\n')
|
||||
return { index, text }
|
||||
}
|
||||
|
||||
/** Tier 1: the 0.150-0.157 header, which only a grid reassembles (see isKnownReadyPromptBody). */
|
||||
export function findCodexScreenReadyPromptIndex(screen: string): number | null {
|
||||
const header = findCodexHeader(screen)
|
||||
return header !== null &&
|
||||
header.text.includes('model:') &&
|
||||
header.text.includes('directory:') &&
|
||||
!CODEX_HEADER_LOADING_RE.test(header.text)
|
||||
? header.index
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* away, so this is its only version-stable rest body. No dialog check: every Codex dialog
|
||||
* replaces the composer, while an answer ending "Would you like to…?" must not block the lane.
|
||||
* The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status
|
||||
* timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a
|
||||
* `tui.terminal_title` without activity/spinner removes it.
|
||||
*/
|
||||
export function isCodexComposerReadyScreen(screen: string): boolean {
|
||||
const lines = screen.split('\n')
|
||||
const composer = lines.findLastIndex((line) => CODEX_EMPTY_COMPOSER_RE.test(line))
|
||||
if (composer === -1 || hasBusyStatusRowAbove(lines, composer)) {
|
||||
return false
|
||||
}
|
||||
const header = findCodexHeader(screen)
|
||||
return header === null || !CODEX_HEADER_LOADING_RE.test(header.text)
|
||||
}
|
||||
|
||||
// Why only the row above the composer: a finished answer (or one above 0.158's timestamp) can quote it.
|
||||
function hasBusyStatusRowAbove(lines: readonly string[], composer: number): boolean {
|
||||
const above = lines.slice(0, composer).filter((line) => line.trim() !== '')
|
||||
const row = above.at(-1)?.trimStart().startsWith(CODEX_STATUS_TIP_PREFIX)
|
||||
? above.at(-2)
|
||||
: above.at(-1)
|
||||
return row?.includes(CODEX_BUSY_STATUS_MARKER) ?? false
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness'
|
||||
import {
|
||||
findCodexScreenReadyPromptIndex,
|
||||
isCodexComposerReadyScreen
|
||||
} from './codex-terminal-readiness'
|
||||
import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window'
|
||||
|
||||
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
|
||||
@@ -72,12 +76,29 @@ export function isKnownReadyPromptBody(
|
||||
if (agent !== null && agent !== 'codex') {
|
||||
return false
|
||||
}
|
||||
const screenLines = readScreenLines()
|
||||
if (screenLines === null) {
|
||||
return false
|
||||
const screen = readScreen(readScreenLines)
|
||||
return screen !== null && isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1
|
||||
* it only proves the TUI is up, so the ranking holds it to quiescence.
|
||||
* Why codex panes only: a `cat`ed transcript or pager in an unknown pane can show the composer.
|
||||
*/
|
||||
export function isQuietReadyScreenBody(
|
||||
waitText: string,
|
||||
agent: TuiAgent | null,
|
||||
readScreenLines: () => readonly string[] | null
|
||||
): boolean {
|
||||
if (agent === 'codex') {
|
||||
const screen = readScreen(readScreenLines)
|
||||
return screen !== null && isCodexComposerReadyScreen(screen)
|
||||
}
|
||||
const screen = screenLines.join('\n').toLowerCase()
|
||||
return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen))
|
||||
return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText)
|
||||
}
|
||||
|
||||
function readScreen(readScreenLines: () => readonly string[] | null): string | null {
|
||||
return readScreenLines()?.join('\n').toLowerCase() ?? null
|
||||
}
|
||||
|
||||
function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean {
|
||||
@@ -88,8 +109,6 @@ function isReadyPromptUnblocked(normalized: string, readyIndex: number | null):
|
||||
return blockedSignal === null || blockedSignal.index <= readyIndex
|
||||
}
|
||||
|
||||
// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while
|
||||
// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence.
|
||||
export function isMuseReadyPromptPreview(preview: string): boolean {
|
||||
const normalized = preview.toLowerCase()
|
||||
return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized))
|
||||
@@ -179,24 +198,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null {
|
||||
return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null
|
||||
}
|
||||
|
||||
const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading/
|
||||
|
||||
// Why the header box only: chat below it can mention "OpenAI Codex" or `model: loading`.
|
||||
// Why `loading`: a header still loading is not ready; the screen must not add readiness early.
|
||||
function findCodexScreenReadyPromptIndex(screen: string): number | null {
|
||||
const headerIndex = screen.indexOf('openai codex')
|
||||
if (headerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const boxEnd = screen.indexOf('╰', headerIndex)
|
||||
const header = screen.slice(headerIndex, boxEnd === -1 ? undefined : boxEnd)
|
||||
return header.includes('model:') &&
|
||||
header.includes('directory:') &&
|
||||
!CODEX_HEADER_LOADING_RE.test(header)
|
||||
? headerIndex
|
||||
: 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
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { isKnownReadyPromptBody } from './terminal-wait-detection'
|
||||
import {
|
||||
evaluateTuiIdle,
|
||||
hasFreshDoneFirstPartyStatus,
|
||||
hasQuietReadyScreen,
|
||||
isTuiIdleReadyVerdict,
|
||||
hasQuietMuseReadyPrompt,
|
||||
nameOnlyIdleNeedsCorroboration,
|
||||
type TuiIdleEvaluationInput,
|
||||
type TuiIdleEvidenceRecord
|
||||
@@ -30,7 +30,7 @@ function input(overrides: Partial<TuiIdleEvaluationInput> = {}): TuiIdleEvaluati
|
||||
record: record(),
|
||||
readTailBlockedReason: () => null,
|
||||
readPositiveBodyEvidence: () => false,
|
||||
readMuseReadyBodyEvidence: () => true,
|
||||
readQuietReadyBodyEvidence: () => true,
|
||||
agent: 'muse',
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS,
|
||||
@@ -38,39 +38,38 @@ function input(overrides: Partial<TuiIdleEvaluationInput> = {}): TuiIdleEvaluati
|
||||
}
|
||||
}
|
||||
|
||||
describe('hasQuietMuseReadyPrompt', () => {
|
||||
describe('hasQuietReadyScreen', () => {
|
||||
it('settles a Muse ready screen once the stream has gone quiet', () => {
|
||||
expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true)
|
||||
expect(hasQuietReadyScreen(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses while the pane is still streaming', () => {
|
||||
expect(
|
||||
hasQuietMuseReadyPrompt(
|
||||
record({ lastOutputAt: Date.now() }),
|
||||
'muse',
|
||||
() => true,
|
||||
QUIESCENCE_MS
|
||||
)
|
||||
hasQuietReadyScreen(record({ lastOutputAt: Date.now() }), 'muse', () => true, QUIESCENCE_MS)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses without an output clock, like the tier-3 lane', () => {
|
||||
expect(
|
||||
hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS)
|
||||
hasQuietReadyScreen(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses without a ready screen', () => {
|
||||
expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
|
||||
expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
|
||||
})
|
||||
|
||||
it('covers adopted panes that carry no launch metadata', () => {
|
||||
expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true)
|
||||
expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true)
|
||||
expect(hasQuietReadyScreen(record(), null, () => true, QUIESCENCE_MS)).toBe(true)
|
||||
expect(hasQuietReadyScreen(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses another agent quoting Muse in its scrollback', () => {
|
||||
expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false)
|
||||
it('covers Codex, whose title carries no rest signal once idle', () => {
|
||||
expect(hasQuietReadyScreen(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses another agent quoting Muse or Codex in its scrollback', () => {
|
||||
expect(hasQuietReadyScreen(record(), 'claude', () => true, QUIESCENCE_MS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,7 +86,7 @@ describe('evaluateTuiIdle muse lane', () => {
|
||||
})
|
||||
|
||||
describe('evaluateTuiIdle ranking', () => {
|
||||
const noMuse = { readMuseReadyBodyEvidence: () => false }
|
||||
const noMuse = { readQuietReadyBodyEvidence: () => false }
|
||||
|
||||
it('ranks a blocking prompt in the tail above an explicit idle title', () => {
|
||||
const verdict = evaluateTuiIdle(
|
||||
@@ -201,13 +200,14 @@ describe('rest signal agrees with the lanes that can settle a wait', () => {
|
||||
screenRead = true
|
||||
return null
|
||||
})
|
||||
const museBody = hasQuietMuseReadyPrompt(record(), agent, () => true, QUIESCENCE_MS)
|
||||
if (museBody) {
|
||||
expect(signal).toBe('ready-body')
|
||||
const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS)
|
||||
// Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane.
|
||||
if (quietScreenBody) {
|
||||
expect(signal).not.toBe('none')
|
||||
}
|
||||
// Why a screen read also counts: Qoder's ready body is its composer, read by identity.
|
||||
if (signal === 'ready-body') {
|
||||
expect(museBody || screenRead).toBe(true)
|
||||
expect(quietScreenBody || screenRead).toBe(true)
|
||||
}
|
||||
if (signal !== 'none') {
|
||||
return
|
||||
@@ -241,7 +241,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => {
|
||||
record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' },
|
||||
rendererTitle: undefined,
|
||||
readPositiveBodyEvidence: () => false,
|
||||
readMuseReadyBodyEvidence: () => false,
|
||||
readQuietReadyBodyEvidence: () => false,
|
||||
readTailBlockedReason: () => null,
|
||||
agent: 'dsh' as const,
|
||||
firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() },
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
detectExplicitIdleStatusFromTitle,
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptBody,
|
||||
isMuseReadyPromptPreview
|
||||
isQuietReadyScreenBody
|
||||
} from './terminal-wait-detection'
|
||||
|
||||
/**
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
* 0. BLOCKED — the tail shows a prompt waiting on the user.
|
||||
* 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own
|
||||
* title, or a known ready-prompt body.
|
||||
* 1b. MUSE — Muse emits no title signal at all, so its ready-screen body stands in
|
||||
* for the strong evidence, believed only once the stream has gone quiet.
|
||||
* 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their
|
||||
* ready-screen body stands in for the strong evidence, believed only once quiet.
|
||||
* 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/
|
||||
* waiting, or a working title. The agent's own account of itself outranks anything
|
||||
* inferred.
|
||||
@@ -187,8 +187,8 @@ export type TuiIdleEvaluationInput = {
|
||||
* (~11us and a multi-KB string on a full tail); the title check below usually answers
|
||||
* first, and then none of that has to happen at all. */
|
||||
readPositiveBodyEvidence: () => boolean
|
||||
/** Tier 1b body evidence: a Muse ready screen. Thunk for the same reason as above. */
|
||||
readMuseReadyBodyEvidence: () => boolean
|
||||
/** Tier 1b body evidence: a Muse or Codex ready screen. Thunk for the same reason as above. */
|
||||
readQuietReadyBodyEvidence: () => boolean
|
||||
agent: TuiAgent | null | undefined
|
||||
firstPartyStatus: FirstPartyAgentStatus
|
||||
quiescenceMs: number
|
||||
@@ -207,34 +207,34 @@ const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' }
|
||||
const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' }
|
||||
const WORKING: TuiIdleVerdict = { kind: 'working' }
|
||||
|
||||
const QUIET_READY_SCREEN_AGENTS: ReadonlySet<TuiAgent> = new Set(['muse', 'codex'])
|
||||
|
||||
/**
|
||||
* Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet.
|
||||
* Tier 1b: a ready screen in the body, believed only once the stream has gone quiet.
|
||||
*
|
||||
* Muse is the one agent with no title signal at all — its OSC title is the bare cwd and
|
||||
* never changes — so neither the explicit-idle nor the sustained-title lane can fire.
|
||||
* The ready screen proves the TUI is up; the quiescence demand keeps a mid-turn
|
||||
* streaming pane from satisfying, mirroring the codex tier-3 lane's
|
||||
* positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another
|
||||
* agent's scrollback quoting Muse must not settle its wait.
|
||||
* Muse's OSC title is the bare cwd and never changes, and an idle Codex titles its pane with
|
||||
* the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the
|
||||
* sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence
|
||||
* demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's
|
||||
* positive-evidence-plus-quiet shape. Scoped to those agents and agent-unknown panes (which
|
||||
* read only Muse's screen): another agent's scrollback quoting them must not settle its wait.
|
||||
*/
|
||||
export function hasQuietMuseReadyPrompt(
|
||||
export function hasQuietReadyScreen(
|
||||
record: TuiIdleEvidenceRecord,
|
||||
agent: TuiAgent | null | undefined,
|
||||
readBodyEvidence: () => boolean,
|
||||
quiescenceMs: number
|
||||
): boolean {
|
||||
if (agent !== null && agent !== undefined && agent !== 'muse') {
|
||||
return false
|
||||
}
|
||||
if (!readBodyEvidence()) {
|
||||
if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent)) {
|
||||
return false
|
||||
}
|
||||
// Why: same rule as the tier-3 lane — without an output clock there is no
|
||||
// corroboration available, so hold out instead of settling.
|
||||
if (record.lastOutputAt === null) {
|
||||
if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) {
|
||||
return false
|
||||
}
|
||||
return Date.now() - record.lastOutputAt >= quiescenceMs
|
||||
// Why last: a streaming pane never pays for the screen projection.
|
||||
return readBodyEvidence()
|
||||
}
|
||||
|
||||
/** The one place the tiers are combined; every settle site branches only on the verdict. */
|
||||
@@ -273,11 +273,13 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
|
||||
: { kind: 'pending', quietForeground: 'closed' }
|
||||
}
|
||||
// Why after the veto: a first-party working account outranks inferred body evidence.
|
||||
// Why before the working title: Codex can leave a stale spinner title after a turn, and a
|
||||
// live spinner emits output every ~100 ms, so a spinning pane is never quiet here.
|
||||
if (
|
||||
hasQuietMuseReadyPrompt(
|
||||
hasQuietReadyScreen(
|
||||
input.record,
|
||||
input.agent,
|
||||
input.readMuseReadyBodyEvidence,
|
||||
input.readQuietReadyBodyEvidence,
|
||||
input.quiescenceMs
|
||||
)
|
||||
) {
|
||||
@@ -328,7 +330,8 @@ export function leafTuiIdleEvidence(
|
||||
rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId),
|
||||
readPositiveBodyEvidence: () =>
|
||||
isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)),
|
||||
readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()),
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)),
|
||||
agent,
|
||||
firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId),
|
||||
quiescenceMs: source.quiescenceMs
|
||||
@@ -348,7 +351,8 @@ export function ptyTuiIdleEvidence(
|
||||
readPositiveBodyEvidence: () =>
|
||||
(agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
|
||||
isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)),
|
||||
readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()),
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)),
|
||||
agent,
|
||||
firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId),
|
||||
quiescenceMs: source.quiescenceMs
|
||||
|
||||
Reference in New Issue
Block a user