diff --git a/docs/plans/2026-09-27-prompt-input-profile-0157.md b/docs/plans/2026-09-27-prompt-input-profile-0157.md new file mode 100644 index 0000000..5a1cc00 --- /dev/null +++ b/docs/plans/2026-09-27-prompt-input-profile-0157.md @@ -0,0 +1,60 @@ +# Prompt-input profile for Codex 0.157.1 (#63) + +## Evidence +- `prepareCodex01491PromptInputProfile` refuses any CLI whose app-server `userAgent` is not exactly `0.149.1`. + - The result at 0.157.1, the version Agent Code runs daily, is `unsupported-cli`. + - `CodexHeadless` then gets no profile and `PromptInputEvidence` yields nothing. + - Fresh-rollout ownership survives only through the proxy identity path. +- The pin is deliberate. The profile is a recorded contract, not a version range. The fix is to record 0.157.1 against the same corpus and issue a profile only for a version whose recording matches. +- **First run of the unchanged recorder against 0.157.1 (2026-09-27):** + 1. **Codex never started.** 0.157 auto-starts a managed app-server daemon whose control socket lives under `CODEX_HOME`. The recorder's isolated home under macOS `$TMPDIR` makes the socket path longer than SUN_LEN, and Codex exits with "app server did not become ready … use --no-daemon". The spawned daemon also outlived its deleted home. Filed as #66. + - `--no-daemon` exists from 0.156.0: absent in the installed 0.150.1–0.155.1 standalone releases, present in 0.156.0–0.157.1. + 2. **The trust case waited forever** for the pre-0.156 dialog text. The 0.156+ dialog is different (#65). There, `1` only moves the highlight and Enter confirms. +- **Agent Code does not pass `--no-alt-screen`**, and 0.157 made the fullscreen transcript (alternate screen) the default. The recorder always passed `--no-alt-screen`, so its inline corpus alone does not describe the screen the app's panes show. + +## Change (recorder, this commit's scope) +- Pass `--no-daemon` from 0.156, gated by version because older CLIs reject unknown flags. +- `CODEX_INPUT_RECORD_ALT_SCREEN=1` records with the app's launch shape (no `--no-alt-screen`). +- **The trust case handles both dialogs.** On the 0.156+ dialog it writes `1`, then asserts after 1 s that the dialog is STILL up, then writes Enter. This live-verifies (in the isolated home) the keystroke contract that #65 took from upstream source. + +## Result (recorded 2026-09-27) +- **Corpus.** All 16 cases were recorded against codex-cli 0.157.1, inline and fullscreen. Rollout and request agree in every case. The only provider semantic that differs from 0.149.1 is the Vim-default composer opening in Insert (`vim-normal-default` submits `iabc`), and the issued profile forces Vim off anyway. +- **`config/read`.** The projection is identical to 0.149.1's. The per-tag audit of the config precedence code is in `codex-01571-config-source.json`: all nine claims hold at new coordinates. New in 0.157: thread config layers, which use the same precedence table. + +### Recorder fixes the recording needed +Each is gated to 0.156+ and explained where it is made: +- `--no-daemon` (SUN_LEN, #66). +- A seen model migration. +- Skill frontmatter. +- The trust case: `1` then Enter, asserting `1` alone leaves the dialog up. This is live verification for #65. +- Title side requests (`thread_title.rs`) are answered unheld and unrecorded. My first diagnosis called them retries; that was wrong, and the comment says so. +- The request that carries the prompt is matched, not the newest one (0.157 does re-send a held request). +- Frames and screens are windowed on the last painted row (0.157 paints short sessions from the top). +- The resize redraw is taken when the rows actually change. Fullscreen repaints on a frame timer after a quiet gap. +- The popup wait uses the popup's own hint. + +### Classifier fixes: real 0.157 surfaces the 0.149.1 classifier misread +Each is pinned by the recorded corpora: +- **The trust dialog's hint row has the idle-footer shape.** The dialog read as a composer drafting "1. Trust and continue". It is now matched as a bottom-row structural modal check, shared byte-for-byte with #67. +- **The skill popup moved above the composer**, with the hint `enter insert · esc close`. Read as an idle composer, Enter-to-insert would have produced evidence for a prompt Codex never sent. It is now `completion-popup`. +- **Fullscreen paints a two-row footer:** the status line, then `? for shortcuts` or `tab to queue message`. Only those exact rows are accepted; anything else is `unknown`. + +### Profile +Issued for a table of exact recorded versions (0.149.1, 0.157.1), never a range. Unrecorded versions still get `unsupported-cli`. + +### Fail-first +Against origin/main, both 0.157.1 suites fail at profile issuance. With only the classifier reverted, 6 tests fail. + +### Found and filed, not fixed here +- **#68:** 0.157.1 drops a `?` from a single-chunk typed draft in about 1 of 5 runs. That is the `sendPrompt` single-line path. The committed corpus is from intact runs, and the catalog says so. + +## Review round (a, b): popups above the composer +Both reviewers found the same critical gap. +- **What 0.157.1 paints.** It paints EVERY popup above the composer (`chat_composer.rs`, `popup_state.rs`). The slash-command and file popups have no hint row, the unified-mention popup has `enter/tab insert · esc close · …`, and a short skill popup omits its hint. +- **The failure.** A frame with a popup open read as an idle composer holding the draft, so Enter, which selects the popup item, produced evidence for a prompt Codex never sent. Reviewer b reproduced it with upstream's `slash_popup_footer_wide` snapshot: `/m` over `/memories`, giving false evidence of `/m`. +- **The fix is fail-closed on the draft.** Codex opens these popups from the draft itself (a leading `/`; an `@` or `$` token), so such a draft never yields prompt evidence. A real prompt starting with `/` or mentioning `$HOME` becomes a safe miss (proxy fallback), never a false prompt. The unified-mention hint is also recognised structurally. +- **Evidence.** + - Two new recorded cases on 0.156+: `slash-popup-enter-selects-command` (`/stat` + Enter dispatches `/status`) and `file-popup-enter-inserts-mention` (`@READ` + Enter inserts `README.md`). Neither submitted anything, and both corpora (inline and fullscreen) were re-recorded with them. + - Upstream's snapshot is a unit test. + - Removing the draft rule fails the slash cases in both corpora, and the snapshot test. +- **Also from review a.** The fullscreen `? for shortcuts` row is now asserted. The claim that the `config/read` projection is identical covers `effectiveInputProjection` only; the 0.149.1 fixture's extra `layerShapeEvidence` has no 0.157.1 counterpart. diff --git a/src/transcript/SubmittedPromptInput.recorded.test.ts b/src/transcript/SubmittedPromptInput.recorded.test.ts index 637815a..b001971 100644 --- a/src/transcript/SubmittedPromptInput.recorded.test.ts +++ b/src/transcript/SubmittedPromptInput.recorded.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import type { StableTerminalFrame } from '../terminal/HeadlessTerminal.js' import { @@ -10,6 +10,7 @@ import { type SubmittedPromptInputContext, } from './SubmittedPromptInput.js' import { + type CodexPromptInputProfile, prepareCodex01491PromptInputProfile, } from './prompt-input/CodexPromptInputProfile.js' import { @@ -128,535 +129,650 @@ type RecordedConsumer = ( context: RecordedInputContext, ) => string[] -const fixturePath = fileURLToPath(new URL( - '../../testing/fixtures/prompt-input/codex-01491-recorded.json', - import.meta.url, -)) -const catalogPath = fileURLToPath(new URL( - '../../testing/fixtures/prompt-input/catalog.md', - import.meta.url, -)) -const configSourcePath = fileURLToPath(new URL( - '../../testing/fixtures/prompt-input/codex-01491-config-source.json', - import.meta.url, -)) -const appServerFixturePath = fileURLToPath(new URL( - '../../testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs', - import.meta.url, -)) -const corpus = JSON.parse( - readFileSync(fixturePath, 'utf8'), -) as RecordedPromptInputCorpus -const catalog = readFileSync(catalogPath, 'utf8') -const configSource = JSON.parse(readFileSync(configSourcePath, 'utf8')) as { - schemaVersion: number - upstreamTag: string +// WHY one suite per recorded Codex version (#63). The prompt-input profile is +// issued only for versions with their own full live recording, so every +// recording must pass the same executable contract. Only provenance differs +// per version: the recorded corpus, its config/read projection, and the +// audited upstream config source. Everything below the spec is shared, so a +// behavioural difference between versions shows up as a failing case rather +// than as a second hand-maintained copy of the rules. +type RecordedCorpusSpec = { + title: string + corpusFile: string + configSourceFile: string + configReadRecordingFile: string + provider: { cliVersion: string; binarySha256: string; upstreamTag: string } upstreamCommitSha: string - recordedCases: Record - files: Array<{ - path: string - sha256: string - coordinates: Array<{ startLine: number; endLine: number; claim: string }> - }> -} -const recordedProfilePreparation = await prepareRecordedProfile() -if (!recordedProfilePreparation.ok) { - throw new Error('recorded config/read profile fixture was refused') + configSourceFiles: Array<[string, string]> + /** The two popup-Enter cases exist only in corpora recorded on 0.156+. */ + recordsPopupEnterCases: boolean } -const recordedIssuedProfile = recordedProfilePreparation.profile - -const recordedCaseIds = [ - 'trust-action-then-submit', - 'combining-grapheme-backspace', - 'mixed-cjk-ctrl-w', - 'repeated-line-boundaries', - 'remapped-kill-line-start', - 'vim-normal-default', - 'unbound-submit-enter', - 'modal-ctrl-c-preserves-draft', - 'tab-footer-spoof-skill-popup', - 'active-footer-tab-queue', - 'narrow-soft-wrap-resize-redraw', - 'unchanged-redraw-after-edit', - 'ordinary-modal-sentinel-draft', - 'ordinary-vim-sentinel-cwd', - 'lower-layer-keymap-valid-control', - 'lower-layer-keymap-issued-profile-conflict', -] as const -const priorReplayCaseIds = new Set(recordedCaseIds.slice(0, 10)) - -const unicodeBoundaryCases = new Set([ - 'combining-grapheme-backspace', - 'mixed-cjk-ctrl-w', -]) - -const casesById = corpus.cases.map(recordedCase => [ - recordedCase.id, - recordedCase, -] as const) -const priorReplayCasesById = casesById.filter(([caseId]) => - priorReplayCaseIds.has(caseId as typeof recordedCaseIds[number]), -) - -function contextFor( - recordedCase: RecordedPromptInputCase, - screenRows?: string[], -): RecordedInputContext { - const screenBeforeWrite = screenRows?.join('\n') - return { - // WHY the old helper must remain in this replay until Stage 27 replaces its - // whole-screen guess. Feeding the exact recorded rows makes the popup-spoof - // case fail for the real reason: transcript prose currently masquerades as - // the active bottom footer, while the genuine queue footer remains green. - tabBehavior: inferCodexTabBehavior(screenBeforeWrite ?? ''), - screenBeforeWrite, - inputProfile: recordedCase.configClass === 'recorded-default-01491' - ? recordedIssuedProfile - : { - cliVersion: corpus.provider.cliVersion, - upstreamTag: corpus.provider.upstreamTag, - configClass: recordedCase.configClass, - configOverrides: [...recordedCase.configOverrides], - }, - } -} +// Recorded only on 0.156+ (review a of codex-headless#69): Enter while a popup +// that paints ABOVE the composer owns it. The 0.149.1 corpus predates them, and +// that binary can no longer be re-recorded here. +const POPUP_ENTER_CASE_IDS = [ + 'slash-popup-enter-selects-command', + 'file-popup-enter-inserts-mention', +] as const -function recordedScreenBeforeChunk( - recordedCase: RecordedPromptInputCase, - chunkIndex: number, -): string[] | undefined { - const finalChunkIndex = recordedCase.inputChunks.length - 1 - if (chunkIndex === finalChunkIndex) return recordedCase.screenBeforeFinalWrite - - // WHY these are the only retained intermediate frame boundaries in the - // sanitized corpus. The Stage 25 recorder captured history search after - // Ctrl+R, so its popup is the pre-Ctrl+C surface. It captured the active-turn - // footer before typing the queued draft. Inventing frames for every ordinary - // character would turn provider recordings back into imagined fixtures. - if (recordedCase.id === 'modal-ctrl-c-preserves-draft' && chunkIndex === 2) { - return recordedCase.popup +function defineRecordedCorpusSuite(spec: RecordedCorpusSpec): void { + const fixturePath = fileURLToPath(new URL( + `../../testing/fixtures/prompt-input/${spec.corpusFile}`, + import.meta.url, + )) + const catalogPath = fileURLToPath(new URL( + '../../testing/fixtures/prompt-input/catalog.md', + import.meta.url, + )) + const configSourcePath = fileURLToPath(new URL( + `../../testing/fixtures/prompt-input/${spec.configSourceFile}`, + import.meta.url, + )) + const appServerFixturePath = fileURLToPath(new URL( + '../../testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs', + import.meta.url, + )) + const corpus = JSON.parse( + readFileSync(fixturePath, 'utf8'), + ) as RecordedPromptInputCorpus + const catalog = readFileSync(catalogPath, 'utf8') + const configSource = JSON.parse(readFileSync(configSourcePath, 'utf8')) as { + schemaVersion: number + upstreamTag: string + upstreamCommitSha: string + recordedCases: Record + files: Array<{ + path: string + sha256: string + coordinates: Array<{ startLine: number; endLine: number; claim: string }> + }> } - if (recordedCase.id === 'active-footer-tab-queue' && chunkIndex === 0) { - return recordedCase.activeTurnFooter + // Issued in beforeAll (below) from this version's own config/read recording. + let recordedIssuedProfile!: CodexPromptInputProfile + + const recordedCaseIds = [ + 'trust-action-then-submit', + 'combining-grapheme-backspace', + 'mixed-cjk-ctrl-w', + 'repeated-line-boundaries', + 'remapped-kill-line-start', + 'vim-normal-default', + 'unbound-submit-enter', + 'modal-ctrl-c-preserves-draft', + 'tab-footer-spoof-skill-popup', + 'active-footer-tab-queue', + 'narrow-soft-wrap-resize-redraw', + 'unchanged-redraw-after-edit', + 'ordinary-modal-sentinel-draft', + 'ordinary-vim-sentinel-cwd', + 'lower-layer-keymap-valid-control', + 'lower-layer-keymap-issued-profile-conflict', + ] as const + + const priorReplayCaseIds = new Set(recordedCaseIds.slice(0, 10)) + + const unicodeBoundaryCases = new Set([ + 'combining-grapheme-backspace', + 'mixed-cjk-ctrl-w', + ]) + + const casesById = corpus.cases.map(recordedCase => [ + recordedCase.id, + recordedCase, + ] as const) + const priorReplayCasesById = casesById.filter(([caseId]) => + priorReplayCaseIds.has(caseId as typeof recordedCaseIds[number]), + ) + + function contextFor( + recordedCase: RecordedPromptInputCase, + screenRows?: string[], + ): RecordedInputContext { + const screenBeforeWrite = screenRows?.join('\n') + return { + // WHY the old helper must remain in this replay until Stage 27 replaces its + // whole-screen guess. Feeding the exact recorded rows makes the popup-spoof + // case fail for the real reason: transcript prose currently masquerades as + // the active bottom footer, while the genuine queue footer remains green. + tabBehavior: inferCodexTabBehavior(screenBeforeWrite ?? ''), + screenBeforeWrite, + inputProfile: recordedCase.configClass === 'recorded-default-01491' + ? recordedIssuedProfile + : { + cliVersion: corpus.provider.cliVersion, + upstreamTag: corpus.provider.upstreamTag, + configClass: recordedCase.configClass, + configOverrides: [...recordedCase.configOverrides], + }, + } } - return undefined -} -function replayRecordedInput(recordedCase: RecordedPromptInputCase): string[] { - const input = new SubmittedPromptInput() - // WHY Stage 26 intentionally describes the context Stage 27 must consume - // before production declares it. Casting only the bound test call lets the - // pre-repair implementation compile and fail behaviorally; weakening the - // fixture or editing production types merely to make a red test compile would - // collapse the required tests-before-implementation boundary. - const consume = input.consume.bind(input) as RecordedConsumer - const submitted: string[] = [] - - for (const modalWrite of recordedCase.nonComposerWrites ?? []) { - submitted.push(...consume( - modalWrite, - contextFor(recordedCase, recordedCase.modal), - )) + function recordedScreenBeforeChunk( + recordedCase: RecordedPromptInputCase, + chunkIndex: number, + ): string[] | undefined { + const finalChunkIndex = recordedCase.inputChunks.length - 1 + if (chunkIndex === finalChunkIndex) return recordedCase.screenBeforeFinalWrite + + // WHY these are the only retained intermediate frame boundaries in the + // sanitized corpus. The Stage 25 recorder captured history search after + // Ctrl+R, so its popup is the pre-Ctrl+C surface. It captured the active-turn + // footer before typing the queued draft. Inventing frames for every ordinary + // character would turn provider recordings back into imagined fixtures. + if (recordedCase.id === 'modal-ctrl-c-preserves-draft' && chunkIndex === 2) { + return recordedCase.popup + } + if (recordedCase.id === 'active-footer-tab-queue' && chunkIndex === 0) { + return recordedCase.activeTurnFooter + } + return undefined } - recordedCase.inputChunks.forEach((chunk, chunkIndex) => { - submitted.push(...consume( - chunk, - contextFor( - recordedCase, - recordedScreenBeforeChunk(recordedCase, chunkIndex), - ), - )) - }) + function replayRecordedInput(recordedCase: RecordedPromptInputCase): string[] { + const input = new SubmittedPromptInput() + // WHY Stage 26 intentionally describes the context Stage 27 must consume + // before production declares it. Casting only the bound test call lets the + // pre-repair implementation compile and fail behaviorally; weakening the + // fixture or editing production types merely to make a red test compile would + // collapse the required tests-before-implementation boundary. + const consume = input.consume.bind(input) as RecordedConsumer + const submitted: string[] = [] + + for (const modalWrite of recordedCase.nonComposerWrites ?? []) { + submitted.push(...consume( + modalWrite, + contextFor(recordedCase, recordedCase.modal), + )) + } - return submitted -} + recordedCase.inputChunks.forEach((chunk, chunkIndex) => { + submitted.push(...consume( + chunk, + contextFor( + recordedCase, + recordedScreenBeforeChunk(recordedCase, chunkIndex), + ), + )) + }) -function caseById(id: typeof recordedCaseIds[number]): RecordedPromptInputCase { - const recordedCase = corpus.cases.find(value => value.id === id) - if (!recordedCase) throw new Error(`missing recorded case ${id}`) - return recordedCase -} + return submitted + } -function stableFrame( - recorded: RecordedStableFrame, - layout: { - layoutEpoch: number - providerLayoutEpoch: number - layoutStartGeneration?: number - rowPaintGeneration?: number - cursorPaintGeneration?: number - } = { - layoutEpoch: 0, - providerLayoutEpoch: 0, - }, -): StableTerminalFrame { - const { - rowPaintGeneration, - ...frameLayout - } = layout - return { - generation: recorded.generation, - ...frameLayout, - cols: recorded.cols, - cursor: recorded.cursor, - rows: recorded.rows.map(row => ({ - text: row.text, - cells: [...row.text], - isWrapped: row.isWrapped, - paintGeneration: rowPaintGeneration, - })), + function caseById(id: typeof recordedCaseIds[number] | typeof POPUP_ENTER_CASE_IDS[number]): RecordedPromptInputCase { + const recordedCase = corpus.cases.find(value => value.id === id) + if (!recordedCase) throw new Error(`missing recorded case ${id}`) + return recordedCase } -} -function frameFromRows(rows: readonly string[], generation = 1): StableTerminalFrame { - return { - generation, - layoutEpoch: 0, - providerLayoutEpoch: 0, - cols: Math.max(140, ...rows.map(row => [...row].length)), - cursor: { x: 0, y: 0 }, - rows: rows.map(text => ({ text, cells: [...text], isWrapped: false })), + function stableFrame( + recorded: RecordedStableFrame, + layout: { + layoutEpoch: number + providerLayoutEpoch: number + layoutStartGeneration?: number + rowPaintGeneration?: number + cursorPaintGeneration?: number + } = { + layoutEpoch: 0, + providerLayoutEpoch: 0, + }, + ): StableTerminalFrame { + const { + rowPaintGeneration, + ...frameLayout + } = layout + return { + generation: recorded.generation, + ...frameLayout, + cols: recorded.cols, + cursor: recorded.cursor, + rows: recorded.rows.map(row => ({ + text: row.text, + cells: [...row.text], + isWrapped: row.isWrapped, + paintGeneration: rowPaintGeneration, + })), + } } -} -function issuedEvidence(): PromptInputEvidence { - return new PromptInputEvidence(recordedIssuedProfile) -} + function frameFromRows(rows: readonly string[], generation = 1): StableTerminalFrame { + return { + generation, + layoutEpoch: 0, + providerLayoutEpoch: 0, + cols: Math.max(140, ...rows.map(row => [...row].length)), + cursor: { x: 0, y: 0 }, + rows: rows.map(text => ({ text, cells: [...text], isWrapped: false })), + } + } -function prepareRecordedProfile(mode = 'recorded-safe') { - return prepareCodex01491PromptInputProfile({ - binary: process.execPath, - cwd: process.cwd(), - baseArgs: [appServerFixturePath], - env: { - ...process.env, - CODEX_PROFILE_FIXTURE_MODE: mode, - }, - }) -} + function issuedEvidence(): PromptInputEvidence { + return new PromptInputEvidence(recordedIssuedProfile) + } -describe('recorded Codex 0.149.1 prompt-input contract', () => { - it('executes the complete sanitized catalog with pinned provenance', () => { - const fixtureIds = corpus.cases.map(recordedCase => recordedCase.id) - const catalogIds = [...catalog.matchAll(/^\| `([^`]+)` \|/gm)] - .map(match => match[1]) - .filter(id => id !== 'capability-6244eac-recorded') - - // WHY fixture files and catalog prose can drift independently. This turns - // the inventory into an executable boundary: adding, omitting, or merely - // documenting a case cannot inflate coverage without a replayed assertion. - expect(fixtureIds).toEqual(recordedCaseIds) - expect(catalogIds).toEqual(recordedCaseIds) - expect(corpus).toMatchObject({ - schemaVersion: 1, - sanitizerVersion: 1, - provider: { - cliVersion: 'codex-cli 0.149.1', - binarySha256: 'f0d8762236594359b60cfbe17f4c7e945a3ce8d1c91e74778838c968d250fb6c', - upstreamTag: 'rust-v0.149.1', - }, - terminal: { cols: 140, rows: 42 }, - source: { - kind: 'real-codex-tui-local-canned-responses', - fixtureSseSha256: '66658b7a1d9b0e3b234de932f552b946b8a005520888f24e001a024fb9a29e5b', + function prepareRecordedProfile(mode = 'recorded-safe') { + return prepareCodex01491PromptInputProfile({ + binary: process.execPath, + cwd: process.cwd(), + baseArgs: [appServerFixturePath], + env: { + ...process.env, + CODEX_PROFILE_FIXTURE_MODE: mode, + CODEX_PROFILE_FIXTURE_RECORDING: `./${spec.configReadRecordingFile}`, }, }) - }) + } - it.each(casesById)('retains independently checkable provenance for %s', ( - _caseId, - recordedCase, - ) => { - // WHY prompt expectations are trustworthy only while they remain tied to - // the private raw PTY, rollout, and request streams that produced them. The - // hashes permit local revalidation without committing those identity-rich - // sources, and the two independent provider outputs must agree exactly. - expect(recordedCase.sourceLabel).toMatch(/^recorded-source-[0-9a-f]{16}$/) - expect(recordedCase.rawPtySha256).toMatch(/^[0-9a-f]{64}$/) - expect(recordedCase.rolloutSha256).toMatch(/^[0-9a-f]{64}$/) - if (recordedCase.rawRequestSha256 === null) { - expect(recordedCase.expectedSubmission).toBe(false) - } else { - expect(recordedCase.rawRequestSha256).toMatch(/^[0-9a-f]{64}$/) - } - expect(recordedCase.requestUserText).toBe(recordedCase.durableUserText) - expect(recordedCase.durableUserText === null) - .toBe(!recordedCase.expectedSubmission) - const lowerLayerConfig = recordedCase.lowerLayerConfig ?? [] - if (recordedCase.configClass === 'recorded-default-01491') { - expect(recordedCase.configOverrides).toEqual([]) - expect(lowerLayerConfig).toEqual([]) - } else if (recordedCase.configClass === 'explicit-cli-override') { - expect(recordedCase.configOverrides.length).toBeGreaterThan(0) - expect(lowerLayerConfig).toEqual([]) - } else { - expect(lowerLayerConfig.length).toBeGreaterThan(0) - } - }) + describe(spec.title, () => { + beforeAll(async () => { + const preparation = await prepareRecordedProfile() + if (!preparation.ok) throw new Error('recorded config/read profile fixture was refused') + // The issued profile must name the version whose recording issued it. + expect(preparation.profile.cliVersion).toBe(spec.provider.cliVersion.replace(/^codex-cli /, '')) + expect(preparation.profile.upstreamTag).toBe(spec.provider.upstreamTag) + recordedIssuedProfile = preparation.profile + }) - it('pins the exact rust-v0.149.1 config precedence and conflict sources', () => { - expect(configSource).toMatchObject({ - schemaVersion: 1, - upstreamTag: 'rust-v0.149.1', - upstreamCommitSha: 'ff29a44391deccde0aba0f8390337d7f3c319ea4', - recordedCases: { - validLowerLayer: 'lower-layer-keymap-valid-control', - issuedOverridesConflict: 'lower-layer-keymap-issued-profile-conflict', - }, + it('executes the complete sanitized catalog with pinned provenance', () => { + const fixtureIds = corpus.cases.map(recordedCase => recordedCase.id) + const catalogIds = [...catalog.matchAll(/^\| `([^`]+)` \|/gm)] + .map(match => match[1]) + .filter(id => id !== 'capability-6244eac-recorded') + + // WHY fixture files and catalog prose can drift independently. This turns + // the inventory into an executable boundary: adding, omitting, or merely + // documenting a case cannot inflate coverage without a replayed assertion. + expect(fixtureIds).toEqual(spec.recordsPopupEnterCases + ? [...recordedCaseIds, ...POPUP_ENTER_CASE_IDS] + : recordedCaseIds) + expect(catalogIds).toEqual([...recordedCaseIds, ...POPUP_ENTER_CASE_IDS]) + expect(corpus).toMatchObject({ + schemaVersion: 1, + sanitizerVersion: 1, + provider: spec.provider, + terminal: { cols: 140, rows: 42 }, + source: { + kind: 'real-codex-tui-local-canned-responses', + fixtureSseSha256: '66658b7a1d9b0e3b234de932f552b946b8a005520888f24e001a024fb9a29e5b', + }, + }) }) - expect(configSource.files.map(file => [file.path, file.sha256])).toEqual([ - ['codex-rs/config/src/config_layer_source.rs', '6816bf7bd44b1f2799aae30331b77a7e8231ccacdc4cd3d44d6485f9e1118364'], - ['codex-rs/config/src/loader/mod.rs', '53d66dce1cd81de3d86610ff2a75aed7f9049609cbefcd3694590c0acfc7c404'], - ['codex-rs/config/src/overrides.rs', 'd10b2c943a709d28395cde201f1b084da4d303afe4ff698f91f59f518c1a9e13'], - ['codex-rs/config/src/merge.rs', 'a7628f0da10f7f7e770fce5160ecbf1ca846b7d9db4b8ed92c6c9cce22c7fb11'], - ['codex-rs/tui/src/keymap.rs', '709feecb708a16b66af8685f0028191efc23b6fac8f87db6a8d45df2440ff604'], - ]) - expect(configSource.files.every(file => file.coordinates.length > 0)).toBe(true) - }) - it.each(priorReplayCasesById)('matches or safely declines recorded input evidence for %s', ( - _caseId, - recordedCase, - ) => { - const submitted = replayRecordedInput(recordedCase) - - if (recordedCase.configClass === 'explicit-cli-override' || - !recordedCase.expectedSubmission) { - // WHY an actual provider submission does not entitle an unproven input - // profile to reconstruct it. A remap, Vim mode, unbound Enter, or popup - // Tab must produce no ownership evidence; a plausible wrong prompt can - // authorize a same-CWD sibling rollout and is worse than a safe miss. - expect(submitted).toEqual([]) - return - } + it.each(casesById)('retains independently checkable provenance for %s', ( + _caseId, + recordedCase, + ) => { + // WHY prompt expectations are trustworthy only while they remain tied to + // the private raw PTY, rollout, and request streams that produced them. The + // hashes permit local revalidation without committing those identity-rich + // sources, and the two independent provider outputs must agree exactly. + expect(recordedCase.sourceLabel).toMatch(/^recorded-source-[0-9a-f]{16}$/) + expect(recordedCase.rawPtySha256).toMatch(/^[0-9a-f]{64}$/) + expect(recordedCase.rolloutSha256).toMatch(/^[0-9a-f]{64}$/) + if (recordedCase.rawRequestSha256 === null) { + expect(recordedCase.expectedSubmission).toBe(false) + } else { + expect(recordedCase.rawRequestSha256).toMatch(/^[0-9a-f]{64}$/) + } + expect(recordedCase.requestUserText).toBe(recordedCase.durableUserText) + expect(recordedCase.durableUserText === null) + .toBe(!recordedCase.expectedSubmission) + const lowerLayerConfig = recordedCase.lowerLayerConfig ?? [] + if (recordedCase.configClass === 'recorded-default-01491') { + expect(recordedCase.configOverrides).toEqual([]) + expect(lowerLayerConfig).toEqual([]) + } else if (recordedCase.configClass === 'explicit-cli-override') { + expect(recordedCase.configOverrides.length).toBeGreaterThan(0) + expect(lowerLayerConfig).toEqual([]) + } else { + expect(lowerLayerConfig.length).toBeGreaterThan(0) + } + }) - const exact = [recordedCase.durableUserText] - if (unicodeBoundaryCases.has(recordedCase.id)) { - // WHY Stage 27 may either implement the exact recorded Unicode boundary - // or fail closed until a version-pinned segmenter exists. It may not emit - // the code-point/ASCII approximation that the provider never submitted. - expect([[], exact]).toContainEqual(submitted) - return - } + it(`pins the exact ${spec.provider.upstreamTag} config precedence and conflict sources`, () => { + expect(configSource).toMatchObject({ + schemaVersion: 1, + upstreamTag: spec.provider.upstreamTag, + upstreamCommitSha: spec.upstreamCommitSha, + recordedCases: { + validLowerLayer: 'lower-layer-keymap-valid-control', + issuedOverridesConflict: 'lower-layer-keymap-issued-profile-conflict', + }, + }) + expect(configSource.files.map(file => [file.path, file.sha256])).toEqual(spec.configSourceFiles) + expect(configSource.files.every(file => file.coordinates.length > 0)).toBe(true) + }) - // WHY default ASCII, multiline navigation, modal restoration, trust - // exclusion, and provider-proven Tab already have fully enumerated recorded - // semantics. Failing closed here would hide independent substrate defects - // behind one broad invalidation switch instead of repairing the boundary. - expect(submitted).toEqual(exact) - }) + it.each(priorReplayCasesById)('matches or safely declines recorded input evidence for %s', ( + _caseId, + recordedCase, + ) => { + const submitted = replayRecordedInput(recordedCase) + + if (recordedCase.configClass === 'explicit-cli-override' || + !recordedCase.expectedSubmission) { + // WHY an actual provider submission does not entitle an unproven input + // profile to reconstruct it. A remap, Vim mode, unbound Enter, or popup + // Tab must produce no ownership evidence; a plausible wrong prompt can + // authorize a same-CWD sibling rollout and is worse than a safe miss. + expect(submitted).toEqual([]) + return + } + + const exact = [recordedCase.durableUserText] + if (unicodeBoundaryCases.has(recordedCase.id)) { + // WHY Stage 27 may either implement the exact recorded Unicode boundary + // or fail closed until a version-pinned segmenter exists. It may not emit + // the code-point/ASCII approximation that the provider never submitted. + expect([[], exact]).toContainEqual(submitted) + return + } + + // WHY default ASCII, multiline navigation, modal restoration, trust + // exclusion, and provider-proven Tab already have fully enumerated recorded + // semantics. Failing closed here would hide independent substrate defects + // behind one broad invalidation switch instead of repairing the boundary. + expect(submitted).toEqual(exact) + }) - it('CH-04 does not treat an unchanged newer provider redraw as edit acknowledgement', () => { - const recordedCase = caseById('unchanged-redraw-after-edit') - const trace = recordedCase.editAcknowledgementTrace - if (!trace) throw new Error('missing recorded CH-04 acknowledgement trace') - - // WHY a generation proves only that some provider bytes were parsed. Here - // the working-status bytes were emitted before the edit reached Codex; the - // recorder schedules the real processes so that tiny interval is visible. - // Draft plus cursor are unchanged, so generation 36 cannot acknowledge the - // `_EDIT` suffix even though it is newer than the pre-write frame. - expect(trace.unchangedAfterEdit.generation) - .toBeGreaterThan(trace.beforeEdit.generation) - expect(trace.unchangedAfterEdit.rows.map(row => row.text)) - .toEqual(trace.beforeEdit.rows.map(row => row.text)) - expect(trace.unchangedAfterEdit.cursor).toEqual(trace.beforeEdit.cursor) - expect(trace.setupDurableUserText).toBe(trace.setupRequestUserText) - - const evidence = issuedEvidence() - evidence.consume(trace.baseDraft, { frame: null }) - evidence.consume(trace.edit, { frame: stableFrame(trace.beforeEdit) }) - expect(evidence.consume('\r', { - frame: stableFrame(trace.unchangedAfterEdit), - })).toEqual([]) - }) + it('CH-04 does not treat an unchanged newer provider redraw as edit acknowledgement', () => { + const recordedCase = caseById('unchanged-redraw-after-edit') + const trace = recordedCase.editAcknowledgementTrace + if (!trace) throw new Error('missing recorded CH-04 acknowledgement trace') + + // WHY a generation proves only that some provider bytes were parsed. Here + // the working-status bytes were emitted before the edit reached Codex; the + // recorder schedules the real processes so that tiny interval is visible. + // Draft plus cursor are unchanged, so generation 36 cannot acknowledge the + // `_EDIT` suffix even though it is newer than the pre-write frame. + expect(trace.unchangedAfterEdit.generation) + .toBeGreaterThan(trace.beforeEdit.generation) + expect(trace.unchangedAfterEdit.rows.map(row => row.text)) + .toEqual(trace.beforeEdit.rows.map(row => row.text)) + expect(trace.unchangedAfterEdit.cursor).toEqual(trace.beforeEdit.cursor) + expect(trace.setupDurableUserText).toBe(trace.setupRequestUserText) + + const evidence = issuedEvidence() + evidence.consume(trace.baseDraft, { frame: null }) + evidence.consume(trace.edit, { frame: stableFrame(trace.beforeEdit) }) + expect(evidence.consume('\r', { + frame: stableFrame(trace.unchangedAfterEdit), + })).toEqual([]) + }) - it('CH-04 accepts the durable value only after the provider paints the edit', () => { - const recordedCase = caseById('unchanged-redraw-after-edit') - const trace = recordedCase.editAcknowledgementTrace - if (!trace) throw new Error('missing recorded CH-04 acknowledgement trace') - - expect(trace.paintedEdit.generation) - .toBeGreaterThan(trace.unchangedAfterEdit.generation) - const evidence = issuedEvidence() - evidence.consume(trace.baseDraft, { frame: null }) - evidence.consume(trace.edit, { frame: stableFrame(trace.beforeEdit) }) - expect(evidence.consume('\r', { - frame: stableFrame(trace.paintedEdit), - })).toEqual([recordedCase.durableUserText]) - }) + it('CH-04 accepts the durable value only after the provider paints the edit', () => { + const recordedCase = caseById('unchanged-redraw-after-edit') + const trace = recordedCase.editAcknowledgementTrace + if (!trace) throw new Error('missing recorded CH-04 acknowledgement trace') + + expect(trace.paintedEdit.generation) + .toBeGreaterThan(trace.unchangedAfterEdit.generation) + const evidence = issuedEvidence() + evidence.consume(trace.baseDraft, { frame: null }) + evidence.consume(trace.edit, { frame: stableFrame(trace.beforeEdit) }) + expect(evidence.consume('\r', { + frame: stableFrame(trace.paintedEdit), + })).toEqual([recordedCase.durableUserText]) + }) - it('CH-09 rejects the recorded resized frame until Codex repaints its layout', () => { - const recordedCase = caseById('narrow-soft-wrap-resize-redraw') - const beforeTyping = recordedCase.beforeTypingFrame - const resize = recordedCase.resizeTrace - if (!beforeTyping || !resize) throw new Error('missing recorded CH-04 frames') - - // WHY the equal generation and byte count are the causal boundary. xterm - // has adopted 92 columns, but Codex has not acknowledged that layout: its - // old 52-column two-row paint remains byte-for-byte on screen. Interpreting - // those rows with the new width manufactures a newline the durable prompt - // and request prove never existed. - expect(resize.beforeProviderRedraw.generation).toBe(resize.narrow.generation) - expect(resize.rawChunkCountBeforeProviderRedraw) - .toBe(resize.rawChunkCountBeforeResize) - expect(resize.beforeProviderRedraw.rows.map(row => row.text)) - .toEqual(resize.narrow.rows.map(row => row.text)) - - const evidence = issuedEvidence() - expect(evidence.consume(recordedCase.inputChunks[0]!, { - frame: stableFrame(beforeTyping), - })).toEqual([]) - expect(evidence.consume('\r', { - // WHY the recorded generation and raw-chunk count stayed unchanged - // across this resize. Epoch 1 is xterm's new geometry; epoch 0 is the - // latest geometry Codex had actually painted at this exact boundary. - frame: stableFrame(resize.beforeProviderRedraw, { - layoutEpoch: 1, - providerLayoutEpoch: 0, - }), - })).toEqual([]) - }) + it('CH-09 rejects the recorded resized frame until Codex repaints its layout', () => { + const recordedCase = caseById('narrow-soft-wrap-resize-redraw') + const beforeTyping = recordedCase.beforeTypingFrame + const resize = recordedCase.resizeTrace + if (!beforeTyping || !resize) throw new Error('missing recorded CH-04 frames') + + // WHY the equal generation and byte count are the causal boundary. xterm + // has adopted 92 columns, but Codex has not acknowledged that layout: its + // old 52-column two-row paint remains byte-for-byte on screen. Interpreting + // those rows with the new width manufactures a newline the durable prompt + // and request prove never existed. + expect(resize.beforeProviderRedraw.generation).toBe(resize.narrow.generation) + expect(resize.rawChunkCountBeforeProviderRedraw) + .toBe(resize.rawChunkCountBeforeResize) + expect(resize.beforeProviderRedraw.rows.map(row => row.text)) + .toEqual(resize.narrow.rows.map(row => row.text)) + + const evidence = issuedEvidence() + expect(evidence.consume(recordedCase.inputChunks[0]!, { + frame: stableFrame(beforeTyping), + })).toEqual([]) + expect(evidence.consume('\r', { + // WHY the recorded generation and raw-chunk count stayed unchanged + // across this resize. Epoch 1 is xterm's new geometry; epoch 0 is the + // latest geometry Codex had actually painted at this exact boundary. + frame: stableFrame(resize.beforeProviderRedraw, { + layoutEpoch: 1, + providerLayoutEpoch: 0, + }), + })).toEqual([]) + }) - it('Stage 38 rejects a post-resize status generation that leaves stale composer geometry', () => { - const resizeCase = caseById('narrow-soft-wrap-resize-redraw') - const statusCase = caseById('unchanged-redraw-after-edit') - const beforeTyping = resizeCase.beforeTypingFrame - const resize = resizeCase.resizeTrace - const status = statusCase.editAcknowledgementTrace - if (!beforeTyping || !resize || !status) { - throw new Error('missing recorded Stage 38 terminal boundaries') - } + it('Stage 38 rejects a post-resize status generation that leaves stale composer geometry', () => { + const resizeCase = caseById('narrow-soft-wrap-resize-redraw') + const statusCase = caseById('unchanged-redraw-after-edit') + const beforeTyping = resizeCase.beforeTypingFrame + const resize = resizeCase.resizeTrace + const status = statusCase.editAcknowledgementTrace + if (!beforeTyping || !resize || !status) { + throw new Error('missing recorded Stage 38 terminal boundaries') + } + + // WHY this composes two real recordings without inventing any composer + // content. The resize fixture supplies the exact stale xterm-reflowed rows + // whose newline disagrees with both its durable role-user item and request. + // The unrelated-redraw fixture supplies the independently observed + // scheduler fact: two later provider chunks/generations can repaint status + // while draft rows and logical cursor remain byte-identical. Replaying only + // that observed generation delta over the recorded stale rows models the + // coarse `providerLayoutEpoch` transition under review; no prompt atom is + // synthesized or changed. + expect(status.unchangedComposerRevision).toBe(true) + expect(status.unchangedAfterEdit.rows.map(row => row.text)) + .toEqual(status.beforeEdit.rows.map(row => row.text)) + expect(status.unchangedAfterEdit.cursor).toEqual(status.beforeEdit.cursor) + const statusGenerationDelta = status.unchangedAfterEdit.generation - + status.beforeEdit.generation + const statusChunkDelta = status.rawChunkCountAtUnchangedRedraw - + status.rawChunkCountBeforeEdit + expect(statusGenerationDelta).toBeGreaterThan(0) + expect(statusChunkDelta).toBe(statusGenerationDelta) + + const staleAfterStatus: RecordedStableFrame = { + ...resize.beforeProviderRedraw, + generation: resize.beforeProviderRedraw.generation + statusGenerationDelta, + } + expect(staleAfterStatus.rows.map(row => row.text)) + .toEqual(resize.beforeProviderRedraw.rows.map(row => row.text)) + + const evidence = issuedEvidence() + evidence.consume(resizeCase.inputChunks[0]!, { + frame: stableFrame(beforeTyping), + }) + expect(evidence.consume('\r', { + // A status-only chunk currently advances providerLayoutEpoch to 1 even + // though no Codex composer row was painted for layout epoch 1. Ownership + // must remain empty until the genuine recorded repaint used by the next + // green control, not accept the stale two-row geometry as a logical LF. + frame: stableFrame(staleAfterStatus, { + layoutEpoch: 1, + providerLayoutEpoch: 1, + // WHY the status chunks are real but their recording did not contain + // HeadlessTerminal's new provider-neutral paint metadata. Projecting + // the observed unchanged draft/cursor revision onto the resize fence + // says exactly what was captured: those physical cells remain owned by + // the layout-start generation even though the coarse epoch advanced. + layoutStartGeneration: resize.beforeProviderRedraw.generation, + rowPaintGeneration: resize.beforeProviderRedraw.generation, + cursorPaintGeneration: resize.beforeProviderRedraw.generation, + }), + })).toEqual([]) + }) - // WHY this composes two real recordings without inventing any composer - // content. The resize fixture supplies the exact stale xterm-reflowed rows - // whose newline disagrees with both its durable role-user item and request. - // The unrelated-redraw fixture supplies the independently observed - // scheduler fact: two later provider chunks/generations can repaint status - // while draft rows and logical cursor remain byte-identical. Replaying only - // that observed generation delta over the recorded stale rows models the - // coarse `providerLayoutEpoch` transition under review; no prompt atom is - // synthesized or changed. - expect(status.unchangedComposerRevision).toBe(true) - expect(status.unchangedAfterEdit.rows.map(row => row.text)) - .toEqual(status.beforeEdit.rows.map(row => row.text)) - expect(status.unchangedAfterEdit.cursor).toEqual(status.beforeEdit.cursor) - const statusGenerationDelta = status.unchangedAfterEdit.generation - - status.beforeEdit.generation - const statusChunkDelta = status.rawChunkCountAtUnchangedRedraw - - status.rawChunkCountBeforeEdit - expect(statusGenerationDelta).toBeGreaterThan(0) - expect(statusChunkDelta).toBe(statusGenerationDelta) - - const staleAfterStatus: RecordedStableFrame = { - ...resize.beforeProviderRedraw, - generation: resize.beforeProviderRedraw.generation + statusGenerationDelta, - } - expect(staleAfterStatus.rows.map(row => row.text)) - .toEqual(resize.beforeProviderRedraw.rows.map(row => row.text)) + it('CH-09 accepts the same durable prompt after the recorded provider redraw', () => { + const recordedCase = caseById('narrow-soft-wrap-resize-redraw') + const beforeTyping = recordedCase.beforeTypingFrame + const resize = recordedCase.resizeTrace + if (!beforeTyping || !resize) throw new Error('missing recorded CH-04 frames') + + expect(resize.afterProviderRedraw.generation) + .toBeGreaterThan(resize.beforeProviderRedraw.generation) + const evidence = issuedEvidence() + evidence.consume(recordedCase.inputChunks[0]!, { + frame: stableFrame(beforeTyping), + }) + expect(evidence.consume('\r', { + frame: stableFrame(resize.afterProviderRedraw, { + layoutEpoch: 1, + providerLayoutEpoch: 1, + // WHY unlike the Stage 38 boundary, this real frame changed both the + // composer geometry and logical cursor after SIGWINCH. The projection + // records only those captured facts; it does not invent draft text. + layoutStartGeneration: resize.beforeProviderRedraw.generation, + rowPaintGeneration: resize.afterProviderRedraw.generation, + cursorPaintGeneration: resize.afterProviderRedraw.generation, + }), + })).toEqual([recordedCase.durableUserText]) + }) - const evidence = issuedEvidence() - evidence.consume(resizeCase.inputChunks[0]!, { - frame: stableFrame(beforeTyping), + it('CH-10 keeps modal sentinel prose inside an ordinary submitted draft', () => { + const recordedCase = caseById('ordinary-modal-sentinel-draft') + if (!recordedCase.screenBeforeFinalWrite) throw new Error('missing CH-05 screen') + const evidence = issuedEvidence() + evidence.consume(recordedCase.inputChunks[0]!, { frame: null }) + expect(evidence.consume('\r', { + frame: frameFromRows(recordedCase.screenBeforeFinalWrite), + })).toEqual([recordedCase.durableUserText]) }) - expect(evidence.consume('\r', { - // A status-only chunk currently advances providerLayoutEpoch to 1 even - // though no Codex composer row was painted for layout epoch 1. Ownership - // must remain empty until the genuine recorded repaint used by the next - // green control, not accept the stale two-row geometry as a logical LF. - frame: stableFrame(staleAfterStatus, { - layoutEpoch: 1, - providerLayoutEpoch: 1, - // WHY the status chunks are real but their recording did not contain - // HeadlessTerminal's new provider-neutral paint metadata. Projecting - // the observed unchanged draft/cursor revision onto the resize fence - // says exactly what was captured: those physical cells remain owned by - // the layout-start generation even though the coarse epoch advanced. - layoutStartGeneration: resize.beforeProviderRedraw.generation, - rowPaintGeneration: resize.beforeProviderRedraw.generation, - cursorPaintGeneration: resize.beforeProviderRedraw.generation, - }), - })).toEqual([]) - }) - it('CH-09 accepts the same durable prompt after the recorded provider redraw', () => { - const recordedCase = caseById('narrow-soft-wrap-resize-redraw') - const beforeTyping = recordedCase.beforeTypingFrame - const resize = recordedCase.resizeTrace - if (!beforeTyping || !resize) throw new Error('missing recorded CH-04 frames') - - expect(resize.afterProviderRedraw.generation) - .toBeGreaterThan(resize.beforeProviderRedraw.generation) - const evidence = issuedEvidence() - evidence.consume(recordedCase.inputChunks[0]!, { - frame: stableFrame(beforeTyping), + it('CH-10 treats Vim: Insert in the recorded cwd footer as ordinary text', () => { + const recordedCase = caseById('ordinary-vim-sentinel-cwd') + if (!recordedCase.screenBeforeFinalWrite) throw new Error('missing CH-09 screen') + const evidence = issuedEvidence() + evidence.consume(recordedCase.inputChunks[0]!, { frame: null }) + expect(evidence.consume('\r', { + frame: frameFromRows(recordedCase.screenBeforeFinalWrite), + })).toEqual([recordedCase.durableUserText]) }) - expect(evidence.consume('\r', { - frame: stableFrame(resize.afterProviderRedraw, { - layoutEpoch: 1, - providerLayoutEpoch: 1, - // WHY unlike the Stage 38 boundary, this real frame changed both the - // composer geometry and logical cursor after SIGWINCH. The projection - // records only those captured facts; it does not invent draft text. - layoutStartGeneration: resize.beforeProviderRedraw.generation, - rowPaintGeneration: resize.afterProviderRedraw.generation, - cursorPaintGeneration: resize.afterProviderRedraw.generation, - }), - })).toEqual([recordedCase.durableUserText]) - }) - it('CH-10 keeps modal sentinel prose inside an ordinary submitted draft', () => { - const recordedCase = caseById('ordinary-modal-sentinel-draft') - if (!recordedCase.screenBeforeFinalWrite) throw new Error('missing CH-05 screen') - const evidence = issuedEvidence() - evidence.consume(recordedCase.inputChunks[0]!, { frame: null }) - expect(evidence.consume('\r', { - frame: frameFromRows(recordedCase.screenBeforeFinalWrite), - })).toEqual([recordedCase.durableUserText]) - }) + it('CH-10 still rejects the genuine recorded trust modal and Vim status atom', () => { + const trust = caseById('trust-action-then-submit') + const vim = caseById('vim-normal-default') + if (!trust.modal || !vim.screenBeforeFinalWrite) { + throw new Error('missing recorded CH-10 negative controls') + } + + expect(classifyCodex01491ComposerSurface(frameFromRows(trust.modal))) + .toEqual({ kind: 'non-composer-modal' }) + expect(classifyCodex01491ComposerSurface( + frameFromRows(vim.screenBeforeFinalWrite), + )).toEqual({ kind: 'unknown' }) + }) - it('CH-10 treats Vim: Insert in the recorded cwd footer as ordinary text', () => { - const recordedCase = caseById('ordinary-vim-sentinel-cwd') - if (!recordedCase.screenBeforeFinalWrite) throw new Error('missing CH-09 screen') - const evidence = issuedEvidence() - evidence.consume(recordedCase.inputChunks[0]!, { frame: null }) - expect(evidence.consume('\r', { - frame: frameFromRows(recordedCase.screenBeforeFinalWrite), - })).toEqual([recordedCase.durableUserText]) - }) + it('classifies the recorded skill popup as a popup, never as a composer', () => { + // #63: 0.157.1 paints this popup ABOVE the composer and no longer uses + // the 0.149.1 footer string. Read as an idle composer, Enter in the popup + // (which inserts a completion) would have produced submission evidence for + // a prompt Codex never sent. The replayed case alone declines either way + // (Tab never submits), so the surface itself is pinned here. + const popupCase = caseById('tab-footer-spoof-skill-popup') + if (!popupCase.popup) throw new Error('missing recorded popup frame') + expect(classifyCodex01491ComposerSurface(frameFromRows(popupCase.popup))) + .toEqual({ kind: 'completion-popup' }) + }) - it('CH-10 still rejects the genuine recorded trust modal and Vim status atom', () => { - const trust = caseById('trust-action-then-submit') - const vim = caseById('vim-normal-default') - if (!trust.modal || !vim.screenBeforeFinalWrite) { - throw new Error('missing recorded CH-10 negative controls') - } + it.runIf(spec.recordsPopupEnterCases).each(POPUP_ENTER_CASE_IDS)('never counts Enter in the recorded %s frame as a submission', caseId => { + // Review a of codex-headless#69: these popups paint above the composer + // with no reliable hint row, and Enter selects the popup item. Codex + // submitted nothing (the recording asserts no rollout user item and no + // request), so the issued evidence must emit nothing either. + const recordedCase = caseById(caseId) + if (!recordedCase.screenBeforeFinalWrite) throw new Error(`missing recorded frame for ${caseId}`) + expect(recordedCase.expectedSubmission).toBe(false) + expect(classifyCodex01491ComposerSurface(frameFromRows(recordedCase.screenBeforeFinalWrite))) + .toEqual({ kind: 'completion-popup' }) + const evidence = issuedEvidence() + evidence.consume(recordedCase.inputChunks[0]!, { frame: null }) + expect(evidence.consume('\r', { frame: frameFromRows(recordedCase.screenBeforeFinalWrite) })).toEqual([]) + }) - expect(classifyCodex01491ComposerSurface(frameFromRows(trust.modal))) - .toEqual({ kind: 'non-composer-modal' }) - expect(classifyCodex01491ComposerSurface( - frameFromRows(vim.screenBeforeFinalWrite), - )).toEqual({ kind: 'unknown' }) - }) + it('reads the recorded idle footer rows as a composer that does not queue with Tab', () => { + // Review a: the fullscreen `? for shortcuts` instructional row was never + // asserted. The active-turn frame of the queue case shows it (fullscreen) + // or the one-row footer (inline); either way it is a composer, and only + // `tab to queue` rows make Tab queue. + const frame = caseById('active-footer-tab-queue').activeTurnFooter + if (!frame) throw new Error('missing recorded active-turn footer') + expect(classifyCodex01491ComposerSurface(frameFromRows(frame))) + .toMatchObject({ kind: 'primary-composer', queueWithTab: false }) + }) - it('CH-05 refuses profile issuance for the recorded lower-layer conflict', async () => { - const control = caseById('lower-layer-keymap-valid-control') - const conflict = caseById('lower-layer-keymap-issued-profile-conflict') - expect(control.startupOutcome).toBe('composer-ready') - expect(control.requestCountDelta).toBe(0) - expect(conflict.startupOutcome).toBe('rejected-before-composer') - expect(conflict.exitOutcome?.exitCode).toBe(1) - expect(conflict.requestCountDelta).toBe(0) - - // WHY the fixture server wraps the recorded config/read projection and - // adds only the exact non-null lower binding proved by the paired live TUI - // outcomes above. The issuer must inspect effective routing and refuse - // before Agent Code appends arguments that make Codex exit at startup. - await expect(prepareRecordedProfile('conflicting-binding')).resolves.toEqual({ - ok: false, - reason: 'effective-config-unverified', + it('CH-05 refuses profile issuance for the recorded lower-layer conflict', async () => { + const control = caseById('lower-layer-keymap-valid-control') + const conflict = caseById('lower-layer-keymap-issued-profile-conflict') + expect(control.startupOutcome).toBe('composer-ready') + expect(control.requestCountDelta).toBe(0) + expect(conflict.startupOutcome).toBe('rejected-before-composer') + expect(conflict.exitOutcome?.exitCode).toBe(1) + expect(conflict.requestCountDelta).toBe(0) + + // WHY the fixture server wraps the recorded config/read projection and + // adds only the exact non-null lower binding proved by the paired live TUI + // outcomes above. The issuer must inspect effective routing and refuse + // before Agent Code appends arguments that make Codex exit at startup. + await expect(prepareRecordedProfile('conflicting-binding')).resolves.toEqual({ + ok: false, + reason: 'effective-config-unverified', + }) }) }) +} + +defineRecordedCorpusSuite({ + title: 'recorded Codex 0.149.1 prompt-input contract', + corpusFile: 'codex-01491-recorded.json', + configSourceFile: 'codex-01491-config-source.json', + configReadRecordingFile: 'codex-01491-config-read-recorded.json', + provider: { + cliVersion: 'codex-cli 0.149.1', + binarySha256: 'f0d8762236594359b60cfbe17f4c7e945a3ce8d1c91e74778838c968d250fb6c', + upstreamTag: 'rust-v0.149.1', + }, + upstreamCommitSha: 'ff29a44391deccde0aba0f8390337d7f3c319ea4', + recordsPopupEnterCases: false, + configSourceFiles: [ + ['codex-rs/config/src/config_layer_source.rs', '6816bf7bd44b1f2799aae30331b77a7e8231ccacdc4cd3d44d6485f9e1118364'], + ['codex-rs/config/src/loader/mod.rs', '53d66dce1cd81de3d86610ff2a75aed7f9049609cbefcd3694590c0acfc7c404'], + ['codex-rs/config/src/overrides.rs', 'd10b2c943a709d28395cde201f1b084da4d303afe4ff698f91f59f518c1a9e13'], + ['codex-rs/config/src/merge.rs', 'a7628f0da10f7f7e770fce5160ecbf1ca846b7d9db4b8ed92c6c9cce22c7fb11'], + ['codex-rs/tui/src/keymap.rs', '709feecb708a16b66af8685f0028191efc23b6fac8f87db6a8d45df2440ff604'], + ], }) + +// Recorded inline (--no-alt-screen, like the 0.149.1 corpus) and again in +// fullscreen, which is 0.157's default and how Agent Code launches Codex. +for (const [layout, corpusFile] of [ + ['inline', 'codex-01571-recorded.json'], + ['fullscreen', 'codex-01571-fullscreen-recorded.json'], +] as const) { + defineRecordedCorpusSuite({ + title: `recorded Codex 0.157.1 prompt-input contract (${layout})`, + corpusFile, + configSourceFile: 'codex-01571-config-source.json', + configReadRecordingFile: 'codex-01571-config-read-recorded.json', + provider: { + cliVersion: 'codex-cli 0.157.1', + binarySha256: '27ceb5f9b957b43a519efe4eaa3816a0bffb0a531a2c89af18840c0a3c016a7d', + upstreamTag: 'rust-v0.157.1', + }, + upstreamCommitSha: '36650394c5b38c2990ccf2a3457165ca3e9d9726', + recordsPopupEnterCases: true, + configSourceFiles: [ + ['codex-rs/config/src/config_layer_source.rs', '6816bf7bd44b1f2799aae30331b77a7e8231ccacdc4cd3d44d6485f9e1118364'], + ['codex-rs/config/src/loader/mod.rs', '0e3131c8186b391ecb425620bee2a8649d10364d062eee21a15d1cbe48dbbb0d'], + ['codex-rs/config/src/overrides.rs', 'dbac7a6f68de31e74c11e9671fe0b1f68f8783c68cff5a9ba26348e0b5f86c0a'], + ['codex-rs/config/src/merge.rs', 'aca71a56a375d83e79009b59b4c1421e70790d625694b4601cd13f182003817f'], + ['codex-rs/tui/src/keymap.rs', '006feab4bc833464cce59c11e0d09dae82b7e8f3f64e2885b3cc9e12b56e02d8'], + ], + }) +} diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.popups.test.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.popups.test.ts new file mode 100644 index 0000000..98e6c67 --- /dev/null +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.popups.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import type { StableTerminalFrame } from '../../terminal/HeadlessTerminal.js' +import { classifyCodex01491ComposerSurface } from './Codex01491ComposerSurface.js' + +// Review b of codex-headless#69. These are the rows of upstream's own insta +// snapshot `chat_composer__status_surface__tests__slash_popup_footer_wide` +// (rust-v0.157.1), verbatim apart from trailing padding: a command popup +// painted ABOVE the composer, with no hint row, over a status line that has +// the accepted footer shape. Enter here dispatches the selected `/memories` +// (slash_input.rs), so the `/m` draft must never read as a composer about to +// submit it. The recorded 0.157.1 equivalents (slash and file popups) are in +// SubmittedPromptInput.recorded.test.ts. +function frame(rows: string[]): StableTerminalFrame { + return { + generation: 1, + layoutEpoch: 0, + providerLayoutEpoch: 0, + cols: 80, + cursor: { x: 4, y: 5 }, + rows: rows.map(text => ({ text, cells: [...text], isWrapped: false })), + } +} + +describe('Codex 0.157 popups above the composer', () => { + it('classifies the upstream slash popup snapshot as a popup, not a composer', () => { + expect(classifyCodex01491ComposerSurface(frame([ + ' /model choose what model and reasoning effort to use', + '› /memories configure memory use and generation', + ' /mention mention a file', + ' /mcp list configured MCP tools; use /mcp verbose for details', + '', + '› /m', + '', + ' model · high · fast', + ]))).toEqual({ kind: 'completion-popup' }) + }) + + it('still reads an ordinary draft over the same status line as a composer', () => { + expect(classifyCodex01491ComposerSurface(frame([ + '› explain the memory settings', + '', + ' model · high · fast', + ]))).toMatchObject({ kind: 'primary-composer', draftText: 'explain the memory settings' }) + }) + + it.each(['@', 'look at @', '$', 'use $', '/'])('declines a draft that ends in a bare popup sigil: %j', draft => { + // The popup opens on the sigil alone, before any character follows it. + expect(classifyCodex01491ComposerSurface(frame([`› ${draft}`, '', ' model · high · fast']))) + .toEqual({ kind: 'completion-popup' }) + }) + // Review c of #69 (F2): the hint-row layer was only ever exercised with a + // draft the draft rule catches too, so deleting it kept every test green. + // Here the draft (`/` has been accepted into a plain word) matches no + // sigil, and only the painted "enter insert · esc close" row proves the + // popup still owns Enter. + it('declines a composer under a painted completion hint even when the draft has no sigil', () => { + expect(classifyCodex01491ComposerSurface(frame([ + ' README.md', + ' enter insert · esc close', + '', + '› look at the readme', + '', + ' model · high · fast', + ]))).toEqual({ kind: 'completion-popup' }) + }) + + // Review c of #69 (F3): the fullscreen pair accepts only the two exact + // instructional rows under the status line. Any other second row is an + // unrecorded bottom pane and must not read as a composer. + it('does not accept an unrecorded second footer row under the status line', () => { + expect(classifyCodex01491ComposerSurface(frame([ + '› explain the memory settings', + '', + ' model · high · fast', + ' press enter to approve', + ]))).toEqual({ kind: 'unknown' }) + }) +}) diff --git a/src/transcript/prompt-input/Codex01491ComposerSurface.ts b/src/transcript/prompt-input/Codex01491ComposerSurface.ts index 46f5e8c..f641aeb 100644 --- a/src/transcript/prompt-input/Codex01491ComposerSurface.ts +++ b/src/transcript/prompt-input/Codex01491ComposerSurface.ts @@ -14,8 +14,39 @@ export type Codex01491ComposerSurface = const PLACEHOLDER = 'Ask Codex to do anything' const HISTORY_FOOTER = /^\s{2}reverse-i-search:/i const COMPLETION_FOOTER = /^\s{2}Press enter to insert or esc to close\s*$/i +// WHY a two-row footer shape (#63). In 0.157.1's fullscreen mode, the default +// and how Agent Code launches Codex, the bottom pane has room for BOTH the +// status line and footer.rs's instructional row under it: "? for shortcuts" +// while idle, "tab to queue message" (or, when narrow, "tab to queue") when +// Tab would queue the draft. Both are recorded in +// codex-01571-fullscreen-recorded.json. Only these exact instructional rows +// are accepted (an optional right-aligned context percentage allowed). +// footer.rs has more variants (agent hints, the collaboration-mode indicator, +// the cycle hint), and any of those makes the pane `unknown`, which declines +// evidence rather than guessing. +const FULLSCREEN_SHORTCUTS_HINT = /^ \? for shortcuts(?: {2,}\d+% context left)?$/u +const FULLSCREEN_QUEUE_HINT = /^ tab to queue(?: message)?(?: {2,}\d+% context left)?$/u + +// WHY a second popup shape (#63): 0.157.1 moved the skill/mention popup ABOVE +// the composer (skill_popup.rs at rust-v0.157.1; recorded in the 0.157.1 +// corpus) and dropped the 0.149.1 footer string entirely: the popup's hint +// row, then a blank row, then the composer. Below the composer the pane +// then looks exactly like an idle composer with a draft, so without this check +// Enter in the popup (which INSERTS a completion) would be recorded as +// submitting the draft, the plausible wrong prompt this parser exists to +// refuse. A transcript line that happens to read like the hint just above the +// composer only makes us decline, which is the safe direction. +const COMPLETION_HINT_ABOVE_COMPOSER = /^\s{2}enter(?:\/tab)? insert · esc close(?: · .*)?$/i const QUEUE_FOOTER = /^ tab to queue(?: message)?\s+\d+% context left\s*$/i const IDLE_FOOTER = /^ \S.*\s·\s.+$/u +// The 0.156+ trust dialog's key hint (#63, codex-headless#65). Codex paints it +// as the dialog's LAST row, where a composer paints its status footer, and it +// also has the IDLE_FOOTER shape (" enter continue · esc quit"). Without this +// check the dialog was read as a composer whose "draft" is +// "1. Trust and continue". It is matched on the bottom row only (one wrap +// allowed: the Windows "… and create sandbox ·" hint wraps below 46 columns), +// so the same words typed into a draft are never mistaken for it. +const TRUST_HINT_FOOTER = /^\s*enter continue(?: and create sandbox)?\s*·\s*esc (?:quit|back)\s*$/i // Codex 0.149.1 renders Vim mode as a distinct right-hand status atom, with a // run of layout padding before the atom and no content after it. A cwd ending // in `/Vim: Insert` is part of the left status value and has neither boundary. @@ -43,8 +74,17 @@ export function classifyCodex01491ComposerSurface( const bottom = rows[lastNonBlank] ?? '' if (HISTORY_FOOTER.test(bottom)) return { kind: 'history-search' } if (COMPLETION_FOOTER.test(bottom)) return { kind: 'completion-popup' } + const aboveBottom = rows[lastNonBlank - 1] ?? '' + if (TRUST_HINT_FOOTER.test(bottom) || + (aboveBottom.trim() !== '' && TRUST_HINT_FOOTER.test(`${aboveBottom.trim()} ${bottom.trim()}`))) { + return { kind: 'non-composer-modal' } + } const composerRow = findComposerRow(rows, lastNonBlank) + if (composerRow >= 2 && (rows[composerRow - 1] ?? '').trim() === '' && + COMPLETION_HINT_ABOVE_COMPOSER.test(rows[composerRow - 2] ?? '')) { + return { kind: 'completion-popup' } + } if (composerRow >= 0) { const separatorRow = rows.findIndex((row, index) => index > composerRow && row.trim() === '', @@ -59,9 +99,17 @@ export function classifyCodex01491ComposerSurface( // owns them. A primary composer has exactly one anchored provider footer; // genuine trust/approval overlays have option rows plus their own footer // and therefore fall through to modal classification below. - if (footerRows.length === 1 && - (IDLE_FOOTER.test(bottom) || QUEUE_FOOTER.test(bottom))) { - if (VIM_STATUS_SUFFIX.test(bottom)) { + // One footer row (0.149.1, and 0.157.1 inline), or the 0.157.1 + // fullscreen pair: status line, then an exact instructional row. + const footer = footerRows.length === 1 && + (IDLE_FOOTER.test(bottom) || QUEUE_FOOTER.test(bottom)) + ? { status: bottom, queueWithTab: QUEUE_FOOTER.test(bottom) } + : footerRows.length === 2 && IDLE_FOOTER.test(footerRows[0]!) && + (FULLSCREEN_SHORTCUTS_HINT.test(footerRows[1]!) || FULLSCREEN_QUEUE_HINT.test(footerRows[1]!)) + ? { status: footerRows[0]!, queueWithTab: FULLSCREEN_QUEUE_HINT.test(footerRows[1]!) } + : null + if (footer) { + if (VIM_STATUS_SUFFIX.test(footer.status)) { // WHY `/vim` can change the live editor after launch even though the // issued profile forces a non-Vim startup. Only the provider's // right-separated footer atom proves that drift; the same words in a @@ -72,11 +120,12 @@ export function classifyCodex01491ComposerSurface( const draftRows = frame.rows.slice(composerRow, separatorRow) const draftText = extractDraftText(draftRows, frame.cols) if (draftText === null) return { kind: 'unknown' } + if (draftMayOpenPopup(draftText)) return { kind: 'completion-popup' } return { kind: 'primary-composer', draftText, - queueWithTab: QUEUE_FOOTER.test(bottom), + queueWithTab: footer.queueWithTab, } } } @@ -154,9 +203,39 @@ function findPreviousNonBlank(rows: readonly string[], from: number): number { } function isKnownNonComposerModal(text: string): boolean { + // The 0.156+ trust dialog is recognised structurally by TRUST_HINT_FOOTER + // before this point, since its hint is always the pane's last row. return /Do you trust the contents of this directory/i.test(text) || /Press enter to continue/i.test(text) || /Would you like to run the following command/i.test(text) || /Yes, and don't ask again/i.test(text) || /customize shortcuts with \/keymap/i.test(text) } + +// WHY decline on the DRAFT, not only on a visible popup (review a of +// codex-headless#69). Codex 0.157.1 paints every popup above the composer: +// the slash-command and file popups carry no hint row at all, and a short +// skill popup omits its hint. A frame with a popup open is therefore +// indistinguishable, from the pane alone, from an idle composer holding the +// same draft. Enter there selects or inserts the popup item (dispatches +// `/status`, inserts a file path) and submits nothing. Recorded in +// codex-01571-*recorded.json: `slash-popup-enter-selects-command` and +// `file-popup-enter-inserts-mention`. +// +// Codex opens these popups from the draft itself: a leading `/` for +// commands, and an `@` or `$` token for file, mention and skill search +// (chat_composer.rs, rust-v0.157.1). So a draft that could have one open never +// yields prompt evidence. This is deliberately fail-closed. A real prompt +// that starts with `/`, or mentions `$HOME` or `a@b`, becomes a safe miss +// (ownership falls back to the proxy path) rather than risking a false +// prompt, which could claim a sibling rollout. +// +// The draft rule, not the hint rows, is the primary guard (review c of #69): a +// popup can be open with ZERO rendered rows (a short pane, an empty query), so +// no row-shape check could ever be the only guard. +function draftMayOpenPopup(draft: string): boolean { + if (draft.trimStart().startsWith('/')) return true + // Any token that STARTS with the sigil, including the bare sigil itself: the + // popup opens on `@` / `$` before a single character follows it. + return /(?:^|\s)[@$]/u.test(draft) +} diff --git a/src/transcript/prompt-input/CodexPromptInputProfile.test.ts b/src/transcript/prompt-input/CodexPromptInputProfile.test.ts index a9ae588..0a99c2c 100644 --- a/src/transcript/prompt-input/CodexPromptInputProfile.test.ts +++ b/src/transcript/prompt-input/CodexPromptInputProfile.test.ts @@ -76,3 +76,22 @@ describe('Codex 0.149.1 prompt-input launch profile', () => { await expect(prepare(mode)).resolves.toEqual({ ok: false, reason }) }) }) + +// Review c of codex-headless#69 (F3): the safety property "a table of exact +// recorded versions, never a range" was enforced only by review; widening the +// table kept every test green. Pin it to exactly the versions that have a +// recorded corpus, each mapped to its own upstream tag. +describe('recorded prompt-input versions', () => { + it('issues profiles for exactly the recorded versions', async () => { + const { RECORDED_PROMPT_INPUT_VERSIONS } = await import('./CodexPromptInputProfile.js') + expect(RECORDED_PROMPT_INPUT_VERSIONS).toEqual({ + '0.149.1': 'rust-v0.149.1', + '0.157.1': 'rust-v0.157.1', + }) + const { existsSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + for (const corpus of ['codex-01491-recorded.json', 'codex-01571-recorded.json', 'codex-01571-fullscreen-recorded.json']) { + expect(existsSync(fileURLToPath(new URL(`../../../testing/fixtures/prompt-input/${corpus}`, import.meta.url)))).toBe(true) + } + }) +}) diff --git a/src/transcript/prompt-input/CodexPromptInputProfile.ts b/src/transcript/prompt-input/CodexPromptInputProfile.ts index 8828f4d..cba8cd3 100644 --- a/src/transcript/prompt-input/CodexPromptInputProfile.ts +++ b/src/transcript/prompt-input/CodexPromptInputProfile.ts @@ -14,6 +14,28 @@ const CODEX_01491_PROMPT_INPUT_ARGS = Object.freeze( override, ]), ) +// WHY a table of EXACT recorded versions, never a range (#63). A profile is +// a claim that Agent Code can reconstruct what Codex will submit from the keys +// it forwards. That claim is only as good as the recorded corpus behind it +// (testing/fixtures/prompt-input): every row here has its own full live +// recording, taken with record-live-prompt-input.mts, plus a config/read +// recording and a per-tag audit of the upstream config precedence code. +// 0.157.1 was re-recorded against the same 16 cases on 2026-09-27. It agrees with +// 0.149.1 on every issued-profile case; the one difference, a Vim-default +// composer opening in Insert rather than Normal, sits outside the profile, +// which forces Vim off. A version that is not in this table has not been +// recorded, and it gets `unsupported-cli`. +export const RECORDED_PROMPT_INPUT_VERSIONS = Object.freeze({ + '0.149.1': 'rust-v0.149.1', + '0.157.1': 'rust-v0.157.1', +} as const) +type RecordedPromptInputVersion = keyof typeof RECORDED_PROMPT_INPUT_VERSIONS + +function isRecordedPromptInputVersion(value: unknown): value is RecordedPromptInputVersion { + return typeof value === 'string' && + Object.prototype.hasOwnProperty.call(RECORDED_PROMPT_INPUT_VERSIONS, value) +} + const PROBE_CLIENT_NAME = 'agent_code_prompt_profile_probe' const INITIALIZE_ID = 'agent-code-prompt-profile-initialize' const CONFIG_READ_ID = 'agent-code-prompt-profile-config-read' @@ -24,8 +46,8 @@ declare const CODEX_PROMPT_INPUT_PROFILE: unique symbol export type CodexPromptInputProfile = Readonly<{ profileVersion: 1 provider: 'codex' - cliVersion: '0.149.1' - upstreamTag: 'rust-v0.149.1' + cliVersion: RecordedPromptInputVersion + upstreamTag: (typeof RECORDED_PROMPT_INPUT_VERSIONS)[RecordedPromptInputVersion] submitKey: 'enter' queueKey: 'tab' vimMode: false @@ -67,7 +89,10 @@ export type CodexPromptInputProfilePreparation = * source of truth. `config/read` executes the same binary, cwd, environment, * and already-assembled global arguments as the imminent PTY launch. We inspect * its response only in memory and issue nothing unless every effective binding - * is exactly the recorded 0.149.1 contract. + * is exactly the recorded contract (identical for every recorded version; see + * RECORDED_PROMPT_INPUT_VERSIONS). The `01491` in the name is historical: it + * is a public export that Agent Code imports, and it now issues a profile for + * each recorded version. */ export async function prepareCodex01491PromptInputProfile( options: Codex01491PromptInputProfileOptions, @@ -83,6 +108,7 @@ export async function prepareCodex01491PromptInputProfile( return { ok: false, reason: 'effective-config-unverified' } } if (!attestation.ok) return attestation + const cliVersion = attestation.cliVersion const configOverrides = Object.freeze([ ...CODEX_01491_PROMPT_INPUT_OVERRIDES, @@ -91,8 +117,8 @@ export async function prepareCodex01491PromptInputProfile( const profile = Object.freeze({ profileVersion: 1 as const, provider: 'codex' as const, - cliVersion: '0.149.1' as const, - upstreamTag: 'rust-v0.149.1' as const, + cliVersion, + upstreamTag: RECORDED_PROMPT_INPUT_VERSIONS[cliVersion], submitKey: 'enter' as const, queueKey: 'tab' as const, vimMode: false as const, @@ -113,8 +139,8 @@ export function isIssuedCodexPromptInputProfile( const profile = value as CodexPromptInputProfile return profile.profileVersion === 1 && profile.provider === 'codex' && - profile.cliVersion === '0.149.1' && - profile.upstreamTag === 'rust-v0.149.1' && + isRecordedPromptInputVersion(profile.cliVersion) && + profile.upstreamTag === RECORDED_PROMPT_INPUT_VERSIONS[profile.cliVersion] && profile.submitKey === 'enter' && profile.queueKey === 'tab' && profile.vimMode === false && @@ -139,7 +165,7 @@ export function assertIssuedCodexPromptInputProfile( } type AttestationResult = - | { ok: true } + | { ok: true; cliVersion: RecordedPromptInputVersion } | Extract async function readEffectiveInputAttestation( @@ -159,7 +185,7 @@ async function readEffectiveInputAttestation( return await new Promise(resolve => { let settled = false let stdout = '' - let initializedVersion: string | null = null + let initializedVersion: RecordedPromptInputVersion | null = null const child = spawn(options.binary, args, { cwd: options.cwd, env: options.env, @@ -226,7 +252,7 @@ async function readEffectiveInputAttestation( ? new RegExp(`^${PROBE_CLIENT_NAME}/(\\d+\\.\\d+\\.\\d+)(?:\\s|$)`) .exec(userAgent)?.[1] ?? null : null - if (version !== '0.149.1') { + if (!isRecordedPromptInputVersion(version)) { finish({ ok: false, reason: 'unsupported-cli' }) return } @@ -245,12 +271,12 @@ async function readEffectiveInputAttestation( if (message.id !== CONFIG_READ_ID) continue if (!isSuccessfulResponse(message, CONFIG_READ_ID) || - initializedVersion !== '0.149.1' || + initializedVersion === null || !effectiveInputIsRecordedContract(message.result)) { refuse() return } - finish({ ok: true }) + finish({ ok: true, cliVersion: initializedVersion }) return } }) diff --git a/testing/fixtures/prompt-input/catalog.md b/testing/fixtures/prompt-input/catalog.md index 12e0a63..cf3892b 100644 --- a/testing/fixtures/prompt-input/catalog.md +++ b/testing/fixtures/prompt-input/catalog.md @@ -56,6 +56,34 @@ npm run build npx tsx testing/record-resume-capability-shape.mts ``` +## Codex 0.157.1 (#63) + +The same 16 cases were re-recorded against codex-cli `0.157.1` on 2026-09-27, twice: +- `codex-01571-recorded.json`: inline (`--no-alt-screen`), comparable row for row with the 0.149.1 corpus. +- `codex-01571-fullscreen-recorded.json`: fullscreen, 0.157's default and how Agent Code launches Codex. + +`codex-01571-config-read-recorded.json` and `codex-01571-config-source.json` are the matching `config/read` projection and the per-tag audit of the config precedence code. + +The recorder needed these 0.156+ adjustments. Each is gated so the 0.149.1 recording conditions are unchanged, and each is explained where it is made: +- `--no-daemon`: the isolated `CODEX_HOME` otherwise exceeds SUN_LEN for the managed daemon's socket. +- A seen `gpt-5.6-sol → gpt-6-sol` model migration. +- YAML frontmatter on the fixture skill. +- The trust case answers `1` then Enter, and asserts that `1` alone leaves the dialog up. +- All held slow-turn requests are released: 0.157 retries a request that has written no bytes. +- Resize frames are windowed on the last painted row. +- The popup wait uses the popup's own key hint. + +Provider facts that differ from 0.149.1: +- **Vim default.** A Vim-default composer now opens in Insert, so `vim-normal-default` submits `iabc`. It is outside the issued profile, which forces Vim off. +- **Skill popup.** The skill popup paints above the composer, with the hint `enter insert · esc close`. +- **Trust dialog.** The trust dialog is the new `Folder access` layout, and its last row has the idle-footer shape. + +The composer-surface classifier recognises the last two. Every issued-profile case agrees with 0.149.1. + +**Known provider flake, not hidden:** 0.157.1 sometimes drops a `?` from a single-chunk typed draft (1 in 5 runs of `ordinary-modal-sentinel-draft`; codex-headless#68). The recorder fails loudly when that happens, because rollout, request and typed text disagree. The committed corpus is from runs where the text arrived intact; the drop is tracked in #68 with its own evidence. + +**Side requests:** 0.157.1 also sends a thread-title side request after the first prompt. The fixture server answers it without holding or recording it (see `serveFixture`). + ## Inventory | Case | Provider fact recorded | @@ -76,6 +104,8 @@ npx tsx testing/record-resume-capability-shape.mts | `ordinary-vim-sentinel-cwd` | A literal `Vim: Insert` cwd suffix is ordinary footer text, not evidence that Vim mode is active. | | `lower-layer-keymap-valid-control` | The lower-layer `queue=[]` plus `toggle_shortcuts="tab"` map reaches a composer with no request or rollout user item. | | `lower-layer-keymap-issued-profile-conflict` | Adding the exact four package-issued CLI overrides makes the otherwise-valid lower map exit 1 before the composer, request, or rollout user item. | +| `slash-popup-enter-selects-command` | 0.156+ only: Enter while the slash-command popup (painted above the composer, no hint row) owns it dispatches the command and submits nothing. | +| `file-popup-enter-inserts-mention` | 0.156+ only: Enter while the file/mention popup owns it inserts the item and submits nothing. | | `capability-6244eac-recorded` | The built pre-repair package is constructible by deep import and exposes/retains raw state. | ## Source boundary diff --git a/testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs b/testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs index 4a5fc71..697f006 100644 --- a/testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs +++ b/testing/fixtures/prompt-input/codex-01491-app-server-fixture.mjs @@ -4,8 +4,11 @@ import { readFileSync } from 'node:fs' import { createInterface } from 'node:readline' import { fileURLToPath } from 'node:url' +// WHY the recording is selectable (#63): each recorded Codex version has its +// own config/read projection, and the profile tests replay each one through +// this same protocol shell. The default stays the original 0.149.1 file. const recorded = JSON.parse(readFileSync(fileURLToPath(new URL( - './codex-01491-config-read-recorded.json', + process.env.CODEX_PROFILE_FIXTURE_RECORDING ?? './codex-01491-config-read-recorded.json', import.meta.url, )), 'utf8')) const mode = process.env.CODEX_PROFILE_FIXTURE_MODE ?? 'recorded-safe' diff --git a/testing/fixtures/prompt-input/codex-01571-config-read-recorded.json b/testing/fixtures/prompt-input/codex-01571-config-read-recorded.json new file mode 100644 index 0000000..e810f43 --- /dev/null +++ b/testing/fixtures/prompt-input/codex-01571-config-read-recorded.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "provider": { + "cliVersion": "0.157.1", + "binarySha256": "27ceb5f9b957b43a519efe4eaa3816a0bffb0a531a2c89af18840c0a3c016a7d", + "upstreamTag": "rust-v0.157.1", + "upstreamCommitSha": "36650394c5b38c2990ccf2a3457165ca3e9d9726" + }, + "protocol": { + "transport": "app-server-stdio-jsonl", + "request": "config/read", + "includeLayers": true, + "clientName": "agent_code_prompt_profile_probe" + }, + "effectiveInputProjection": { + "composerSubmit": "enter", + "composerQueue": "tab", + "globalToggleVimMode": [], + "vimModeDefault": false, + "otherNonNullKeymapBindings": [], + "layerTypes": [ + "sessionFlags", + "user", + "system" + ], + "legacyManagedLayerPresent": false + }, + "sourceEvidenceSha256": "55041ea1fcc77f2240a083cb4795e26424d6ba490ada9a1f26b47eb6766f8576" +} diff --git a/testing/fixtures/prompt-input/codex-01571-config-source.json b/testing/fixtures/prompt-input/codex-01571-config-source.json new file mode 100644 index 0000000..6ea6f62 --- /dev/null +++ b/testing/fixtures/prompt-input/codex-01571-config-source.json @@ -0,0 +1,91 @@ +{ + "schemaVersion": 1, + "upstreamTag": "rust-v0.157.1", + "upstreamCommitSha": "36650394c5b38c2990ccf2a3457165ca3e9d9726", + "recordedCases": { + "validLowerLayer": "lower-layer-keymap-valid-control", + "issuedOverridesConflict": "lower-layer-keymap-issued-profile-conflict" + }, + "files": [ + { + "path": "codex-rs/config/src/config_layer_source.rs", + "sha256": "6816bf7bd44b1f2799aae30331b77a7e8231ccacdc4cd3d44d6485f9e1118364", + "coordinates": [ + { + "startLine": 30, + "endLine": 50, + "claim": "Session flags have precedence 30; legacy managed file and MDM layers have precedence 40 and 50." + } + ] + }, + { + "path": "codex-rs/config/src/loader/mod.rs", + "sha256": "0e3131c8186b391ecb425620bee2a8649d10364d062eee21a15d1cbe48dbbb0d", + "coordinates": [ + { + "startLine": 415, + "endLine": 421, + "claim": "CLI overrides are materialized as the SessionFlags layer." + }, + { + "startLine": 427, + "endLine": 470, + "claim": "Legacy managed file and MDM configuration are loaded above session flags. New in 0.157: thread config layers are inserted by their ConfigLayerSource precedence (lines 423-425, same precedence table as config_layer_source.rs); none appear in the recorded 0.157.1 config/read projection." + } + ] + }, + { + "path": "codex-rs/config/src/overrides.rs", + "sha256": "dbac7a6f68de31e74c11e9671fe0b1f68f8783c68cff5a9ba26348e0b5f86c0a", + "coordinates": [ + { + "startLine": 9, + "endLine": 14, + "claim": "Every dotted CLI override is written into one session-layer TOML value." + } + ] + }, + { + "path": "codex-rs/config/src/merge.rs", + "sha256": "aca71a56a375d83e79009b59b4c1421e70790d625694b4601cd13f182003817f", + "coordinates": [ + { + "startLine": 56, + "endLine": 58, + "claim": "The overlay passed to the TOML merge has precedence over the base." + }, + { + "startLine": 94, + "endLine": 151, + "claim": "Nested tables merge recursively and scalar/array overlays replace lower values (0.157 adds a credentials env de-duplication clause at lines 108-138 that does not touch tui keys)." + } + ] + }, + { + "path": "codex-rs/tui/src/keymap.rs", + "sha256": "006feab4bc833464cce59c11e0d09dae82b7e8f3f64e2885b3cc9e12b56e02d8", + "coordinates": [ + { + "startLine": 626, + "endLine": 636, + "claim": "Runtime keymap construction promises parse and ambiguity errors." + }, + { + "startLine": 1565, + "endLine": 1625, + "claim": "Conflict validation runs on the fully resolved effective keymap before use (resolved, configure_search, validate_conflicts, chord validation)." + }, + { + "startLine": 2005, + "endLine": 2067, + "claim": "Composer submit, queue and toggle-shortcuts participate in the same app-scope uniqueness pass (main_bindings, validate_unique(\"app\"))." + }, + { + "startLine": 2366, + "endLine": 2386, + "claim": "Duplicate effective bindings return an actionable ambiguous-keymap error." + } + ] + } + ] +} diff --git a/testing/fixtures/prompt-input/codex-01571-fullscreen-recorded.json b/testing/fixtures/prompt-input/codex-01571-fullscreen-recorded.json new file mode 100644 index 0000000..129328c --- /dev/null +++ b/testing/fixtures/prompt-input/codex-01571-fullscreen-recorded.json @@ -0,0 +1,1102 @@ +{ + "schemaVersion": 1, + "sanitizerVersion": 1, + "provider": { + "cliVersion": "codex-cli 0.157.1", + "binarySha256": "27ceb5f9b957b43a519efe4eaa3816a0bffb0a531a2c89af18840c0a3c016a7d", + "upstreamTag": "rust-v0.157.1" + }, + "terminal": { + "cols": 140, + "rows": 42 + }, + "source": { + "kind": "real-codex-tui-local-canned-responses", + "fixtureSseSha256": "66658b7a1d9b0e3b234de932f552b946b8a005520888f24e001a024fb9a29e5b" + }, + "cases": [ + { + "id": "trust-action-then-submit", + "sourceLabel": "recorded-source-05d5a85ed82f49f0", + "rawPtySha256": "05d5a85ed82f49f01d484aa095da5d636870c1785af53c84b28018072545940c", + "rolloutSha256": "f0354058eadf00b55b17ec1b179746c2a602bda133d0558b7b3e6b2faea4989b", + "rawRequestSha256": "9ccf1eaaa9572e80b4729c7c69e3276d3465e4272e7c04217de8b908cbc6a062", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "RECORDED_TRUST_PROMPT", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_TRUST_PROMPT", + "requestUserText": "RECORDED_TRUST_PROMPT", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› RECORDED_TRUST_PROMPT", + "", + " GPT-5.6-Sol low · " + ], + "nonComposerWrites": [ + "1", + "\r" + ], + "modal": [ + " ", + " Folder access", + " ", + " ", + " Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code", + " automatically, even without a model request. Continue only if you trust these files. Your trust decision will be saved.", + "", + "› 1. Trust and continue ", + " 2. Quit", + "", + " enter continue · esc quit" + ] + }, + { + "id": "combining-grapheme-backspace", + "sourceLabel": "recorded-source-8940f86a532a74b3", + "rawPtySha256": "8940f86a532a74b398818c2bf3392a9dd47a8923373e22a4dd376bdb7bafb431", + "rolloutSha256": "8e9ba0dde30b5a0ae7a182c74a2a00b68bcfb7774d7963367ee665d7fd0d76a7", + "rawRequestSha256": "36f5c993fcae8cd7bc242c71e2f74139db4e82ed448a3ff2a01dd1e9cc98dba4", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "A é", + "", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "A X", + "requestUserText": "A X", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› A X", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "mixed-cjk-ctrl-w", + "sourceLabel": "recorded-source-5e478fc81de02201", + "rawPtySha256": "5e478fc81de02201fcde92703387ecd6d0c983d4f0165e46f5e944198d83a132", + "rolloutSha256": "a4dcede6283e58db9535d8027e986e22bc7f7d96998440ce623840d41aad5aff", + "rawRequestSha256": "0debb2c8b5c2188537f6df8fd6aaa379ffe0110b40de3624a82f2fa88d83721d", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "abc 世界def", + "\u0017", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "abc 世界X", + "requestUserText": "abc 世界X", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› abc 世界X", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "repeated-line-boundaries", + "sourceLabel": "recorded-source-af95beb7da2bd8fa", + "rawPtySha256": "af95beb7da2bd8faa1cf1b17ca3b5a55bac7c6597755c8455a8683fa382db139", + "rolloutSha256": "9e317f2cd8a085fecea6c0776129fd26e70cb3784decfb1567ea35606a988830", + "rawRequestSha256": "cb785f3c0cf596c8f93f0b0731b81a663a05a5189bc44be799fad50054d75663", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "\u001b[200~one\ntwo\u001b[201~", + "\u0001", + "\u0001", + "X", + "\u0005", + "\u0005", + "Y", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "Xone\ntwoY", + "requestUserText": "Xone\ntwoY", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "› Xone", + " twoY", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "remapped-kill-line-start", + "sourceLabel": "recorded-source-cb1044728ea6b760", + "rawPtySha256": "cb1044728ea6b7602addcfea8e9471dc627ffb71c3f9a727e2eb3632b1cf4f2e", + "rolloutSha256": "b881c167e8e02153d810eb7f0f9e23bfebea3e68e231fbb3280c68f2b6a428a4", + "rawRequestSha256": "16a1ad248f80269848d085c161bde5b78ba88cec7943932f00f4d1661257fcd2", + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.keymap.editor.kill_line_start=\"alt-u\"" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "abc def", + "\u0015", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "abc defX", + "requestUserText": "abc defX", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› abc defX", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "vim-normal-default", + "sourceLabel": "recorded-source-0a537abfb2e6b2f7", + "rawPtySha256": "0a537abfb2e6b2f74ba53bc44d8e97b8b101ccaa12237221044ccbf1d8a47cc1", + "rolloutSha256": "538be00b39044966ba6a9983e36c190177925cf75d663ec72b9041c23a97bf4d", + "rawRequestSha256": "50dfd8190c804db4203b6ab0d4ec1fb37cb47c3154c31228f3bbb0923cb8d2c7", + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.vim_mode_default=true" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "i", + "abc", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "iabc", + "requestUserText": "iabc", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› iabc", + "", + " GPT-5.6-Sol low · Vim: Insert" + ] + }, + { + "id": "unbound-submit-enter", + "sourceLabel": "recorded-source-dbaac1bedf6b4e34", + "rawPtySha256": "dbaac1bedf6b4e346ad2e8b65203c8ac474edaee52963cab508e9993a96089aa", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.keymap.composer.submit=[]" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "UNBOUND_SUBMIT", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› UNBOUND_SUBMIT", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "modal-ctrl-c-preserves-draft", + "sourceLabel": "recorded-source-b83f4484abcbb39a", + "rawPtySha256": "b83f4484abcbb39a50c8085b410d5b455e8a4270fc91947594c4e06c95087be9", + "rolloutSha256": "114c53d2fba85b387c6adf54e6ba4ea78d00b15d6cf3b8a4eaa24eddbb99526b", + "rawRequestSha256": "200c1e58614d8c581deaab5c0db4e850c6057b3b98c0315e8608baef7c5a7d05", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "recorded prefix ", + "\u0012", + "\u0003", + "suffix", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "recorded prefix suffix", + "requestUserText": "recorded prefix suffix", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› recorded prefix suffix", + "", + " GPT-5.6-Sol low · " + ], + "popup": [ + "", + "", + "", + "", + "› recorded prefix", + "", + " GPT-5.6-Sol low · ", + " reverse-i-search: " + ] + }, + { + "id": "tab-footer-spoof-skill-popup", + "sourceLabel": "recorded-source-9a9df303391539d4", + "rawPtySha256": "9a9df303391539d4261a8bbcc2fd137e0f684dbcb438591a27688defd1b088b9", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "literal tab to queue $recorded_evi", + "\t" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + "", + "no matches", + "", + " enter insert · esc close", + "", + "› literal tab to queue $recorded_evi", + "", + " GPT-5.6-Sol low · " + ], + "popup": [ + "", + "no matches", + "", + " enter insert · esc close", + "", + "› literal tab to queue $recorded_evi", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "active-footer-tab-queue", + "sourceLabel": "recorded-source-0b3ac219f6cdac4e", + "rawPtySha256": "0b3ac219f6cdac4e301170b4a51b058177349879c9d038ad90f2e04cca2b8356", + "rolloutSha256": "19d2b19d8c67e40084d2d3d0b8973a4253e2a8d6826d6e976d36bcc48d89ead4", + "rawRequestSha256": "ea03a4f90a0df973bae97da8bbba1c8e33d79c192f04bbf10f1d2395f0909df1", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "RECORDED_QUEUED_PROMPT", + "\t" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_QUEUED_PROMPT", + "requestUserText": "RECORDED_QUEUED_PROMPT", + "screenBeforeFinalWrite": [ + "", + " Working ( • esc to interrupt)", + "", + "", + "› RECORDED_QUEUED_PROMPT", + "", + " GPT-5.6-Sol low · ", + " tab to queue message" + ], + "activeTurnFooter": [ + "", + " Working ( • esc to interrupt)", + "", + "", + "› Ask Codex to do anything", + "", + " GPT-5.6-Sol low · · ⠇", + " ? for shortcuts" + ] + }, + { + "id": "narrow-soft-wrap-resize-redraw", + "sourceLabel": "recorded-source-357b0d3f57bf3138", + "rawPtySha256": "357b0d3f57bf3138f87dc81f9763ea680ec4e86ce473b666e6f33088c86ff98d", + "rolloutSha256": "5ec216aa5a253f01eb89ac2444f473c7b31001f151577cd10ee2bf4e601986f7", + "rawRequestSha256": "cd561f0fee68e05e7b125ed401ce463787d4aaf929d4f1dfdb28cd10df675913", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 52, + "rows": 24 + }, + "inputChunks": [ + "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "requestUserText": "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "", + " GPT-5.6-Sol low · " + ], + "beforeTypingFrame": { + "generation": 30, + "cols": 52, + "cursor": { + "x": 2, + "y": 20 + }, + "rows": [ + { + "viewportRow": 14, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": " Tip: Press f3 to search this conversation.", + "isWrapped": false + }, + { + "viewportRow": 19, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 20, + "text": "› Ask Codex to do anything", + "isWrapped": false + }, + { + "viewportRow": 21, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 22, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + }, + { + "viewportRow": 23, + "text": " ? for shortcuts", + "isWrapped": false + } + ] + }, + "resizeTrace": { + "requested": { + "cols": 92, + "rows": 24 + }, + "rawChunkCountBeforeResize": 59, + "rawChunkCountBeforeProviderRedraw": 59, + "rawChunkCountAfterProviderRedraw": 63, + "preRedrawGenerationUnchanged": true, + "postRedrawGenerationAdvanced": true, + "narrow": { + "generation": 59, + "cols": 52, + "cursor": { + "x": 41, + "y": 20 + }, + "rows": [ + { + "viewportRow": 13, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 19, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta", + "isWrapped": false + }, + { + "viewportRow": 20, + "text": " epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 21, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 22, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + }, + "beforeProviderRedraw": { + "generation": 59, + "cols": 92, + "cursor": { + "x": 41, + "y": 20 + }, + "rows": [ + { + "viewportRow": 13, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 19, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta", + "isWrapped": false + }, + { + "viewportRow": 20, + "text": " epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 21, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 22, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + }, + "afterProviderRedraw": { + "generation": 63, + "cols": 92, + "cursor": { + "x": 85, + "y": 20 + }, + "rows": [ + { + "viewportRow": 13, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 19, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 20, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 21, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 22, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + } + } + }, + { + "id": "unchanged-redraw-after-edit", + "sourceLabel": "recorded-source-193f0c963269d1dc", + "rawPtySha256": "193f0c963269d1dc8cda4a85dc1dd7e401172bda2d08c90a043b594ce2db3887", + "rolloutSha256": "5a1293ecbac4bdce1f73b5dc66405ae7080bef8aefc9536b9ffc58afb52b0798", + "rawRequestSha256": "2f2694c1f481a85dfc6828f7cb9ed14e8fea5d18284dff44c8cb650a6030e19a", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_UNCHANGED_BASE_EDIT", + "requestUserText": "RECORDED_UNCHANGED_BASE_EDIT", + "screenBeforeFinalWrite": [ + "", + "", + " ", + "", + "", + "› RECORDED_UNCHANGED_BASE_EDIT", + "", + " GPT-5.6-Sol low · " + ], + "editAcknowledgementTrace": { + "baseDraft": "RECORDED_UNCHANGED_BASE", + "edit": "_EDIT", + "setupDurableUserText": "RECORDED_SLOW_TURN", + "setupRequestUserText": "RECORDED_SLOW_TURN", + "rawChunkCountBeforeEdit": 60, + "rawChunkCountAtUnchangedRedraw": 61, + "schedulingControl": "SIGSTOP_AFTER_EDIT_WRITE_BEFORE_PROVIDER_PARSE", + "beforeEdit": { + "generation": 60, + "cols": 140, + "cursor": { + "x": 25, + "y": 38 + }, + "rows": [ + { + "viewportRow": 32, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 33, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 34, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 35, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 36, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 37, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 38, + "text": "› RECORDED_UNCHANGED_BASE", + "isWrapped": false + }, + { + "viewportRow": 39, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 40, + "text": " GPT-5.6-Sol low · · ⠇", + "isWrapped": false + }, + { + "viewportRow": 41, + "text": " tab to queue message", + "isWrapped": false + } + ] + }, + "unchangedAfterEdit": { + "generation": 61, + "cols": 140, + "cursor": { + "x": 25, + "y": 38 + }, + "rows": [ + { + "viewportRow": 32, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 33, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 34, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 35, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 36, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 37, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 38, + "text": "› RECORDED_UNCHANGED_BASE", + "isWrapped": false + }, + { + "viewportRow": 39, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 40, + "text": " GPT-5.6-Sol low · · ⠇", + "isWrapped": false + }, + { + "viewportRow": 41, + "text": " tab to queue message", + "isWrapped": false + } + ] + }, + "paintedEdit": { + "generation": 69, + "cols": 140, + "cursor": { + "x": 30, + "y": 38 + }, + "rows": [ + { + "viewportRow": 32, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 33, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 34, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 35, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 36, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 37, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 38, + "text": "› RECORDED_UNCHANGED_BASE_EDIT", + "isWrapped": false + }, + { + "viewportRow": 39, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 40, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + }, + { + "viewportRow": 41, + "text": " tab to queue message", + "isWrapped": false + } + ] + }, + "unchangedGenerationAdvanced": true, + "unchangedComposerRevision": true, + "paintedGenerationAdvanced": true + } + }, + { + "id": "ordinary-modal-sentinel-draft", + "sourceLabel": "recorded-source-41631ba2345605f4", + "rawPtySha256": "41631ba2345605f47ab3957275756a8c37d6b0c1b83e69b86357fb9a1933c724", + "rolloutSha256": "ed82f654e56005e1d15356b9660867d55328a146f41e5b0ba669c00ea240f052", + "rawRequestSha256": "ed2197accee6ef0bb8fbd52ed4b7029998d9b61e1e1fc74de74fadf169e2f50e", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "requestUserText": "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "› ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "ordinary-vim-sentinel-cwd", + "sourceLabel": "recorded-source-0a0f6c2adc0b1aa8", + "rawPtySha256": "0a0f6c2adc0b1aa819253b26ab68f472bdc9d7e4304657af63aac64459726e55", + "rolloutSha256": "51da2be25d83796322b22e55d3affdb43398746bc2f1fa868dce0a407fbf4759", + "rawRequestSha256": "51c7e1e14f21040b904eed306a50ef41f7ae81b080fc30b72f4d31589bd57292", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "ORDINARY_VIM_SENTINEL_CWD", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "ORDINARY_VIM_SENTINEL_CWD", + "requestUserText": "ORDINARY_VIM_SENTINEL_CWD", + "screenBeforeFinalWrite": [ + "", + "", + "", + "", + "", + "› ORDINARY_VIM_SENTINEL_CWD", + "", + " GPT-5.6-Sol default · /Vim: Insert" + ] + }, + { + "id": "lower-layer-keymap-valid-control", + "sourceLabel": "recorded-source-fc609b9307612614", + "rawPtySha256": "fc609b9307612614bfca816db79d728bfa5e82359eee49fa335ae6b53c9b258b", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "lower-layer-config", + "lowerLayerConfig": [ + "[tui.keymap.composer]", + "queue = []", + "toggle_shortcuts = \"tab\"" + ], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "requestCountDelta": 0, + "startupOutcome": "composer-ready", + "exitOutcome": null, + "startupScreen": [ + "", + "", + "", + "", + "› Ask Codex to do anything", + "", + "", + " tab for shortcuts" + ] + }, + { + "id": "lower-layer-keymap-issued-profile-conflict", + "sourceLabel": "recorded-source-7671e97cacea97b1", + "rawPtySha256": "7671e97cacea97b17c5d26a33e4f3492dcb8147b924cc333df00b880efe5fe59", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "lower-layer-plus-issued-cli-override", + "lowerLayerConfig": [ + "[tui.keymap.composer]", + "queue = []", + "toggle_shortcuts = \"tab\"" + ], + "configOverrides": [ + "tui.keymap.composer.submit=\"enter\"", + "tui.keymap.composer.queue=\"tab\"", + "tui.vim_mode_default=false", + "tui.keymap.global.toggle_vim_mode=[]" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "requestCountDelta": 0, + "startupOutcome": "rejected-before-composer", + "exitOutcome": { + "exitCode": 1, + "signal": 0 + }, + "startupScreen": [ + "Error: Ambiguous `tui.keymap.app` bindings: `composer.queue` and `composer.toggle_shortcuts` use the same key. Set unique keys in `~/.codex/", + "config.toml` and retry. See the Codex keymap documentation for supported actions and examples." + ] + }, + { + "id": "slash-popup-enter-selects-command", + "sourceLabel": "recorded-source-b8630e6656578466", + "rawPtySha256": "b8630e6656578466c248584fca14e9eb2cde9f8f88e3f37a4f682d30f5e9080d", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "/stat", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + "", + "", + "› /status show current session configuration and token usage ", + " /statusline configure which items appear in the status line", + "", + "› /stat", + "", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "file-popup-enter-inserts-mention", + "sourceLabel": "recorded-source-dd0f4a9d621939bb", + "rawPtySha256": "dd0f4a9d621939bb544474eb08d41c8ffd9a1b5ed10dadaad3d8d126b5780605", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "@READ", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + "", + "", + "", + " enter/tab insert · esc close · ↑/↓ select · ←/→ filter", + "", + "› @READ", + "", + " GPT-5.6-Sol low · " + ] + } + ] +} diff --git a/testing/fixtures/prompt-input/codex-01571-recorded.json b/testing/fixtures/prompt-input/codex-01571-recorded.json new file mode 100644 index 0000000..971a50f --- /dev/null +++ b/testing/fixtures/prompt-input/codex-01571-recorded.json @@ -0,0 +1,1102 @@ +{ + "schemaVersion": 1, + "sanitizerVersion": 1, + "provider": { + "cliVersion": "codex-cli 0.157.1", + "binarySha256": "27ceb5f9b957b43a519efe4eaa3816a0bffb0a531a2c89af18840c0a3c016a7d", + "upstreamTag": "rust-v0.157.1" + }, + "terminal": { + "cols": 140, + "rows": 42 + }, + "source": { + "kind": "real-codex-tui-local-canned-responses", + "fixtureSseSha256": "66658b7a1d9b0e3b234de932f552b946b8a005520888f24e001a024fb9a29e5b" + }, + "cases": [ + { + "id": "trust-action-then-submit", + "sourceLabel": "recorded-source-bb6fcdefc3edc4e6", + "rawPtySha256": "bb6fcdefc3edc4e636b6082e070903744dbace4491b8253bccc4632b762824d6", + "rolloutSha256": "bd63699b0d2dfd66ca6c46ac3c863d0557af1e9be5fc69b308a996787e930589", + "rawRequestSha256": "ce82e2129feec9d52e26ba5939386a3b2dfefdc5a4fd07b6bec79a11ce8ccd72", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "RECORDED_TRUST_PROMPT", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_TRUST_PROMPT", + "requestUserText": "RECORDED_TRUST_PROMPT", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› RECORDED_TRUST_PROMPT", + " ", + " GPT-5.6-Sol low · " + ], + "nonComposerWrites": [ + "1", + "\r" + ], + "modal": [ + "", + " Folder access", + " ", + "", + " Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code", + " automatically, even without a model request. Continue only if you trust these files. Your trust decision will be saved.", + "", + "› 1. Trust and continue ", + " 2. Quit", + "", + " enter continue · esc quit" + ] + }, + { + "id": "combining-grapheme-backspace", + "sourceLabel": "recorded-source-57671a2aa17a9638", + "rawPtySha256": "57671a2aa17a9638da28a9bc84dd93e38a6127f19d5f36bf82e32fee29990555", + "rolloutSha256": "3756c060bf4b56489fb70438c4ae5cf5ad9673e5bd6f8671a050d5fcc6df910a", + "rawRequestSha256": "80d28b79c2c2ebd61f762ea52adbdf95771d2a7de24cf4042f8af25f64287a6d", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "A é", + "", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "A X", + "requestUserText": "A X", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› A X", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "mixed-cjk-ctrl-w", + "sourceLabel": "recorded-source-e32aecee00f41bad", + "rawPtySha256": "e32aecee00f41bad3a9b9299891b3c966d554c0436f131735db16425cce19cd7", + "rolloutSha256": "838eb2bc211e5f5c9a17144935eab64ca95d06a6d17c90d5a634fff02b15b749", + "rawRequestSha256": "1d21d268b6ac4243bcd778f7a865b7d3ffc5521a3219eac8ff9df95015bfa1c0", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "abc 世界def", + "\u0017", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "abc 世界X", + "requestUserText": "abc 世界X", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› abc 世界X", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "repeated-line-boundaries", + "sourceLabel": "recorded-source-0da2d21741620169", + "rawPtySha256": "0da2d21741620169d57b13a45af7bcf7866bdff2e74bc7d0c088b981fb2b4943", + "rolloutSha256": "46651882ebbffc9c422d9f3b5b5b5852c57a5920eada29f80bfe90820b6897eb", + "rawRequestSha256": "a7d87d3fe5caea3c2b4c774cff5b7dc657b7a5c0eb527f991ae103bd28b41605", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "\u001b[200~one\ntwo\u001b[201~", + "\u0001", + "\u0001", + "X", + "\u0005", + "\u0005", + "Y", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "Xone\ntwoY", + "requestUserText": "Xone\ntwoY", + "screenBeforeFinalWrite": [ + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› Xone", + " twoY", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "remapped-kill-line-start", + "sourceLabel": "recorded-source-929cc1f4f95842ea", + "rawPtySha256": "929cc1f4f95842ea1eea1797aaa8b46110aad80c121d14da4b662eb8af2315ad", + "rolloutSha256": "24ff98b8ea08c903e580d3dee98477dcf57e2b4b8081fde24bfbc76f8ce5dd68", + "rawRequestSha256": "cd504d26c0d82c2fcd743d4fa065ecdd48517b8bafd2391ab91fcdab33d25a21", + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.keymap.editor.kill_line_start=\"alt-u\"" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "abc def", + "\u0015", + "X", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "abc defX", + "requestUserText": "abc defX", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› abc defX", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "vim-normal-default", + "sourceLabel": "recorded-source-c3cbd1a723c869d0", + "rawPtySha256": "c3cbd1a723c869d096c22c9792cec2d2d497df39aae83d89efd930dd83ecaf49", + "rolloutSha256": "34a1cff8942fdf1caa1485a0f917aed8a8a5e95f6ec8e1ead1a5a91f3bf50140", + "rawRequestSha256": "b587dccede9b7932a524f97116cfc47b1b0f8cb73d48291743563bc4eb348eff", + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.vim_mode_default=true" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "i", + "abc", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "iabc", + "requestUserText": "iabc", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› iabc", + " ", + " GPT-5.6-Sol low · Vim: Insert" + ] + }, + { + "id": "unbound-submit-enter", + "sourceLabel": "recorded-source-8c539d37df20c818", + "rawPtySha256": "8c539d37df20c8185fbdd79f234d8b22a498b349fa68e3392926a38bd29413a2", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "explicit-cli-override", + "lowerLayerConfig": [], + "configOverrides": [ + "tui.keymap.composer.submit=[]" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "UNBOUND_SUBMIT", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: Use /hooks to view and manage lifecycle hooks.", + " ", + " ", + "› UNBOUND_SUBMIT", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "modal-ctrl-c-preserves-draft", + "sourceLabel": "recorded-source-0b6766e35802cf98", + "rawPtySha256": "0b6766e35802cf9805ce223db7856c5ddf3cc1be18fd3d0744c63f08dce6fff6", + "rolloutSha256": "b4fca4dba1d766971f56761ef661bf22ef1acbb6d333087fb0f49c07262940ae", + "rawRequestSha256": "eaf4896051e4152b43e160b5263a1db2330b64d6b676622502b130814015d3ae", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "recorded prefix ", + "\u0012", + "\u0003", + "suffix", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "recorded prefix suffix", + "requestUserText": "recorded prefix suffix", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› recorded prefix suffix", + " ", + " GPT-5.6-Sol low · " + ], + "popup": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› recorded prefix", + " ", + " reverse-i-search: " + ] + }, + { + "id": "tab-footer-spoof-skill-popup", + "sourceLabel": "recorded-source-60e4b6a07edddcb2", + "rawPtySha256": "60e4b6a07edddcb2d52f4e536b1d5d88b7f6deeea5ed4c2adb6e8c5ddc385691", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "literal tab to queue $recorded_evi", + "\t" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + " ", + "no matches", + " ", + " enter insert · esc close", + " ", + "› literal tab to queue $recorded_evi", + " ", + " GPT-5.6-Sol low · " + ], + "popup": [ + " ", + "no matches", + " ", + " enter insert · esc close", + " ", + "› literal tab to queue $recorded_evi", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "active-footer-tab-queue", + "sourceLabel": "recorded-source-31af2a8155d082a8", + "rawPtySha256": "31af2a8155d082a849a54f3bc9db001ebc8cee98f2f047e2165423640141cb84", + "rolloutSha256": "08aada9e8a184ebf7fcca9175bedfc3769a054cd1446b1c7ccb2662b2bb908e8", + "rawRequestSha256": "95feeff62c62b03acba64e4f7bfa4d9043419581ce3203798c20cf87263732a4", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "RECORDED_QUEUED_PROMPT", + "\t" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_QUEUED_PROMPT", + "requestUserText": "RECORDED_QUEUED_PROMPT", + "screenBeforeFinalWrite": [ + "", + " ", + " Working ( • esc to interrupt)", + " ", + " ", + "› RECORDED_QUEUED_PROMPT", + " ", + " tab to queue message 100% context left" + ], + "activeTurnFooter": [ + "", + " ", + " Working ( • esc to interrupt)", + " ", + " ", + "› Ask Codex to do anything", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "narrow-soft-wrap-resize-redraw", + "sourceLabel": "recorded-source-fa5da550e6e40faf", + "rawPtySha256": "fa5da550e6e40faf04144e1c4e2bd0ddc3825220b3196ace083a1edbb200f577", + "rolloutSha256": "708e13c9b804e79cb9c17487f61e5ba1f21b777e064d369bb0367c4b537408fb", + "rawRequestSha256": "644c05a6e1abecd6f7b50a70b77dbed62d60c492353de8ee8a79479c2cf32d26", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 52, + "rows": 24 + }, + "inputChunks": [ + "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "requestUserText": "RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "screenBeforeFinalWrite": [ + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit", + " https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + " ", + " GPT-5.6-Sol low · " + ], + "beforeTypingFrame": { + "generation": 32, + "cols": 52, + "cursor": { + "x": 2, + "y": 12 + }, + "rows": [ + { + "viewportRow": 5, + "text": "╰──────────────────────────────────────────────────╯", + "isWrapped": false + }, + { + "viewportRow": 6, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 7, + "text": " Tip: New Build faster with the Desktop app. Run", + "isWrapped": false + }, + { + "viewportRow": 8, + "text": " 'codex app' or visit", + "isWrapped": false + }, + { + "viewportRow": 9, + "text": " https://chatgpt.com/codex?app-landing-page=true", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": "› Ask Codex to do anything", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + }, + "resizeTrace": { + "requested": { + "cols": 92, + "rows": 24 + }, + "rawChunkCountBeforeResize": 33, + "rawChunkCountBeforeProviderRedraw": 33, + "rawChunkCountAfterProviderRedraw": 38, + "preRedrawGenerationUnchanged": true, + "postRedrawGenerationAdvanced": true, + "narrow": { + "generation": 33, + "cols": 52, + "cursor": { + "x": 41, + "y": 13 + }, + "rows": [ + { + "viewportRow": 6, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 7, + "text": " Tip: New Build faster with the Desktop app. Run", + "isWrapped": false + }, + { + "viewportRow": 8, + "text": " 'codex app' or visit", + "isWrapped": false + }, + { + "viewportRow": 9, + "text": " https://chatgpt.com/codex?app-landing-page=true", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + }, + "beforeProviderRedraw": { + "generation": 33, + "cols": 92, + "cursor": { + "x": 41, + "y": 13 + }, + "rows": [ + { + "viewportRow": 6, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 7, + "text": " Tip: New Build faster with the Desktop app. Run", + "isWrapped": false + }, + { + "viewportRow": 8, + "text": " 'codex app' or visit", + "isWrapped": false + }, + { + "viewportRow": 9, + "text": " https://chatgpt.com/codex?app-landing-page=true", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + }, + "afterProviderRedraw": { + "generation": 38, + "cols": 92, + "cursor": { + "x": 85, + "y": 11 + }, + "rows": [ + { + "viewportRow": 4, + "text": "│ directory: │", + "isWrapped": false + }, + { + "viewportRow": 5, + "text": "╰──────────────────────────────────────────────────────────╯", + "isWrapped": false + }, + { + "viewportRow": 6, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 7, + "text": " Tip: New Build faster with the Desktop app. Run 'codex app' or visit", + "isWrapped": false + }, + { + "viewportRow": 8, + "text": " https://chatgpt.com/codex?app-landing-page=true", + "isWrapped": false + }, + { + "viewportRow": 9, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": "› RECORDED_NARROW_WRAP alpha beta gamma delta epsilon zeta eta theta iota kappa omega", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " GPT-5.6-Sol low · ", + "isWrapped": false + } + ] + } + } + }, + { + "id": "unchanged-redraw-after-edit", + "sourceLabel": "recorded-source-eb8b434b3854c8cd", + "rawPtySha256": "eb8b434b3854c8cd1acb7064f1922c70a62f1458abf9ab9953e2dffda9b562c6", + "rolloutSha256": "9c057ab816c22dbf1576aee52cbbfa1ff5793536ea123a38adf221c39158e974", + "rawRequestSha256": "2d1d8e08304f9fc0f28c8ca3b93bb19c4a6769094f53ca21fee16fda780e18c3", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "\r" + ], + "expectedSubmission": true, + "durableUserText": "RECORDED_UNCHANGED_BASE_EDIT", + "requestUserText": "RECORDED_UNCHANGED_BASE_EDIT", + "screenBeforeFinalWrite": [ + "", + "", + " 01:42", + " ", + " ", + "› RECORDED_UNCHANGED_BASE_EDIT", + " ", + " GPT-5.6-Sol low · " + ], + "editAcknowledgementTrace": { + "baseDraft": "RECORDED_UNCHANGED_BASE", + "edit": "_EDIT", + "setupDurableUserText": "RECORDED_SLOW_TURN", + "setupRequestUserText": "RECORDED_SLOW_TURN", + "rawChunkCountBeforeEdit": 62, + "rawChunkCountAtUnchangedRedraw": 63, + "schedulingControl": "SIGSTOP_AFTER_EDIT_WRITE_BEFORE_PROVIDER_PARSE", + "beforeEdit": { + "generation": 62, + "cols": 140, + "cursor": { + "x": 25, + "y": 16 + }, + "rows": [ + { + "viewportRow": 9, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": "› RECORDED_SLOW_TURN", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "› RECORDED_UNCHANGED_BASE", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": " tab to queue message 100% context left", + "isWrapped": false + } + ] + }, + "unchangedAfterEdit": { + "generation": 63, + "cols": 140, + "cursor": { + "x": 25, + "y": 16 + }, + "rows": [ + { + "viewportRow": 9, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": "› RECORDED_SLOW_TURN", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "› RECORDED_UNCHANGED_BASE", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": " tab to queue message 100% context left", + "isWrapped": false + } + ] + }, + "paintedEdit": { + "generation": 66, + "cols": 140, + "cursor": { + "x": 30, + "y": 16 + }, + "rows": [ + { + "viewportRow": 9, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 10, + "text": "› RECORDED_SLOW_TURN", + "isWrapped": false + }, + { + "viewportRow": 11, + "text": "", + "isWrapped": false + }, + { + "viewportRow": 12, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 13, + "text": " Working ( • esc to interrupt)", + "isWrapped": false + }, + { + "viewportRow": 14, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 15, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 16, + "text": "› RECORDED_UNCHANGED_BASE_EDIT", + "isWrapped": false + }, + { + "viewportRow": 17, + "text": " ", + "isWrapped": false + }, + { + "viewportRow": 18, + "text": " tab to queue message 100% context left", + "isWrapped": false + } + ] + }, + "unchangedGenerationAdvanced": true, + "unchangedComposerRevision": true, + "paintedGenerationAdvanced": true + } + }, + { + "id": "ordinary-modal-sentinel-draft", + "sourceLabel": "recorded-source-d2806a249c852d29", + "rawPtySha256": "d2806a249c852d29b0bb0d4a5959b568b7e9ea5e8b38d437ac1f1b7cc0116c53", + "rolloutSha256": "0ba9cab7da82bb3aed33c23f43fd3fbd0c439021715f2e862f384f75771f9b09", + "rawRequestSha256": "f1fe707287a3c067e0a6f2597e4664d59ba8db6ed873eed8bd45754fcb9ccf82", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "requestUserText": "ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + "screenBeforeFinalWrite": [ + "│ >_ OpenAI Codex (v0.157.1) │", + "│ │", + "│ model: GPT-5.6-Sol low /model to change │", + "│ directory: │", + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› ORDINARY_DRAFT Do you trust the contents of this directory? Press enter to continue END", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "ordinary-vim-sentinel-cwd", + "sourceLabel": "recorded-source-c8143ea8bbffa37a", + "rawPtySha256": "c8143ea8bbffa37ad98e3e682eb848d9f8bf2b4335e92abdc740c4fc230dc90e", + "rolloutSha256": "e7b4fdab9667b9bd92b9329b3eca040ff88555ad222ebd73600c443c62f07283", + "rawRequestSha256": "1c6391ac1a03f12bb6588d9c62e8a05afd514da8867fb4740f8d11fc41658f69", + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "ORDINARY_VIM_SENTINEL_CWD", + "\r" + ], + "expectedSubmission": true, + "durableUserText": "ORDINARY_VIM_SENTINEL_CWD", + "requestUserText": "ORDINARY_VIM_SENTINEL_CWD", + "screenBeforeFinalWrite": [ + "╰──────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› ORDINARY_VIM_SENTINEL_CWD", + " ", + " GPT-5.6-Sol low · /Vim: Insert" + ] + }, + { + "id": "lower-layer-keymap-valid-control", + "sourceLabel": "recorded-source-f8fb8b7d929ddc0f", + "rawPtySha256": "f8fb8b7d929ddc0f2efb828426b803b78822e0a2cb40b48239f895df4f4ba9a8", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "lower-layer-config", + "lowerLayerConfig": [ + "[tui.keymap.composer]", + "queue = []", + "toggle_shortcuts = \"tab\"" + ], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "requestCountDelta": 0, + "startupOutcome": "composer-ready", + "exitOutcome": null, + "startupScreen": [ + "╰──────────────────────────────────────────────────────────╯", + "", + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + " ", + "› Ask Codex to do anything", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "lower-layer-keymap-issued-profile-conflict", + "sourceLabel": "recorded-source-7671e97cacea97b1", + "rawPtySha256": "7671e97cacea97b17c5d26a33e4f3492dcb8147b924cc333df00b880efe5fe59", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "lower-layer-plus-issued-cli-override", + "lowerLayerConfig": [ + "[tui.keymap.composer]", + "queue = []", + "toggle_shortcuts = \"tab\"" + ], + "configOverrides": [ + "tui.keymap.composer.submit=\"enter\"", + "tui.keymap.composer.queue=\"tab\"", + "tui.vim_mode_default=false", + "tui.keymap.global.toggle_vim_mode=[]" + ], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "requestCountDelta": 0, + "startupOutcome": "rejected-before-composer", + "exitOutcome": { + "exitCode": 1, + "signal": 0 + }, + "startupScreen": [ + "Error: Ambiguous `tui.keymap.app` bindings: `composer.queue` and `composer.toggle_shortcuts` use the same key. Set unique keys in `~/.codex/", + "config.toml` and retry. See the Codex keymap documentation for supported actions and examples." + ] + }, + { + "id": "slash-popup-enter-selects-command", + "sourceLabel": "recorded-source-30f3183257dca4bc", + "rawPtySha256": "30f3183257dca4bc58607ba9833048e542f5bea18dcebff6eebfd4bb17ac8cb6", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "/stat", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + " Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true", + " ", + "› /status show current session configuration and token usage ", + " /statusline configure which items appear in the status line", + " ", + "› /stat", + " ", + " GPT-5.6-Sol low · " + ] + }, + { + "id": "file-popup-enter-inserts-mention", + "sourceLabel": "recorded-source-7c017f846c733ea9", + "rawPtySha256": "7c017f846c733ea9390ccb673e50cd78a0b7b121d5e76581f5cf5c9a463d5861", + "rolloutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "rawRequestSha256": null, + "configClass": "recorded-default-01491", + "lowerLayerConfig": [], + "configOverrides": [], + "terminal": { + "cols": 140, + "rows": 42 + }, + "inputChunks": [ + "@READ", + "\r" + ], + "expectedSubmission": false, + "durableUserText": null, + "requestUserText": null, + "screenBeforeFinalWrite": [ + " ", + " ", + " ", + " enter/tab insert · esc close · ↑/↓ select · ←/→ filter", + " ", + "› @READ", + " ", + " GPT-5.6-Sol low · " + ] + } + ] +} diff --git a/testing/record-live-config-read.mts b/testing/record-live-config-read.mts index 37d2a89..9c32d22 100644 --- a/testing/record-live-config-read.mts +++ b/testing/record-live-config-read.mts @@ -16,10 +16,13 @@ const INITIALIZE_ID = 'agent-code-config-initialize' const CONFIG_READ_ID = 'agent-code-config-read' const binary = process.env.CODEX_BINARY ?? 'codex' const cwd = process.env.CODEX_INPUT_RECORD_CWD ?? process.cwd() -const sourceEvidencePath = fileURLToPath(new URL( - './fixtures/prompt-input/codex-01491-config-source.json', - import.meta.url, -)) +// WHY a table of exact recorded versions (#63): the upstream commit is not +// derivable from the binary, and the source evidence is a per-tag audit of the +// config precedence code. A version with no row here has no audited source. +const RECORDED_UPSTREAM: Record = { + '0.149.1': { commit: 'ff29a44391deccde0aba0f8390337d7f3c319ea4', sourceEvidence: 'codex-01491-config-source.json' }, + '0.157.1': { commit: '36650394c5b38c2990ccf2a3457165ca3e9d9726', sourceEvidence: 'codex-01571-config-source.json' }, +} // WHY this recorder projects only key routing, version, and layer types. A // config/read response may contain private paths, MCP declarations, and future @@ -27,13 +30,19 @@ const sourceEvidencePath = fileURLToPath(new URL( // fixture into a secret/configuration archive. The production attestor must // likewise inspect in memory and return only a capability or a generic refusal. const projection = await recordProjection() +const upstream = RECORDED_UPSTREAM[projection.cliVersion] +if (!upstream) throw new Error(`no audited upstream source for Codex ${projection.cliVersion}`) +const sourceEvidencePath = fileURLToPath(new URL( + `./fixtures/prompt-input/${upstream.sourceEvidence}`, + import.meta.url, +)) process.stdout.write(`${JSON.stringify({ schemaVersion: 1, provider: { cliVersion: projection.cliVersion, binarySha256: sha256File(binary), - upstreamTag: 'rust-v0.149.1', - upstreamCommitSha: 'ff29a44391deccde0aba0f8390337d7f3c319ea4', + upstreamTag: `rust-v${projection.cliVersion}`, + upstreamCommitSha: upstream.commit, }, protocol: { transport: 'app-server-stdio-jsonl', diff --git a/testing/record-live-prompt-input.mts b/testing/record-live-prompt-input.mts index 122f2b3..807b45a 100644 --- a/testing/record-live-prompt-input.mts +++ b/testing/record-live-prompt-input.mts @@ -30,6 +30,10 @@ type InputCase = { waitBeforeFinal?: RegExp setup?: (session: LiveSession) => Promise> afterDraft?: (session: LiveSession) => Promise> + /** Files created in the workspace before launch (e.g. for `@` file search). */ + workspaceFiles?: Record + /** Record only on CLIs that support the 0.156+ adjustments (see cliSupportsNoDaemon). */ + only0156Plus?: boolean } type CapturedRequest = { @@ -78,11 +82,18 @@ const fixtureSse = [ '', '', ].join('\n') +const TITLE_REQUEST_PREFIX = 'Generate a concise, single-line task title' const binary = await readFile(CODEX_BINARY) const binarySha256 = sha256(binary) const cliVersion = await binaryVersion() const requests: CapturedRequest[] = [] -let releaseSlowResponse: (() => void) | null = null +// WHY a list, not one slot (#63): the first 0.157.1 diagnosis was a second +// held slow-turn request overwriting this slot. That second request turned out +// to be the thread-title side request (now answered unheld and unrecorded in +// serveFixture). A list still releases every held request, so no future +// side request can strand the turn the same way. +const slowResponseReleasers: Array<() => void> = [] +const releaseSlowResponses = (): void => { for (const release of slowResponseReleasers.splice(0)) release() } const server = createServer(async (req, res) => { await serveFixture(req, res) @@ -109,13 +120,28 @@ const allCases: InputCase[] = [ expectedDurableText: 'RECORDED_TRUST_PROMPT', setup: async session => { await waitForScreen(session, screen => - screen.includes('Do you trust the contents of this directory'), + screen.includes('Do you trust the contents of this directory') || + screen.includes('1. Trust and continue'), ) const modal = structuralScreen(session) await delay(300) + if (!session.mirror.snapshotPlain().includes('1. Trust and continue')) { + session.terminal.write('1') + await waitForComposer(session) + return { nonComposerWrites: ['1'], modal } + } + // WHY 0.156+ takes '1' then Enter (#63, codex-headless#65): upstream + // changed `1` to move the highlight only; Enter confirms. Recorded here + // in the isolated CODEX_HOME rather than assumed: after '1' alone the + // dialog must still be up, and only the Enter reaches the composer. session.terminal.write('1') + await delay(1000) + if (!session.mirror.snapshotPlain().includes('1. Trust and continue')) { + throw new Error("0.156+ trust dialog closed on '1' alone; the recorded key contract changed") + } + session.terminal.write('\r') await waitForComposer(session) - return { nonComposerWrites: ['1'], modal } + return { nonComposerWrites: ['1', '\r'], modal } }, }, { @@ -157,7 +183,12 @@ const allCases: InputCase[] = [ configOverrides: ['tui.vim_mode_default=true'], inputChunks: ['i', 'abc', '\r'], expectedSubmission: true, - expectedDurableText: 'abc', + // WHY version-specific (#63): 0.149.1 opened a Vim-default composer in + // Normal mode, so the leading `i` only entered Insert. rust-v0.157.1's + // chatwidget/constructor.rs calls `enable_vim_in_insert_mode()` instead, + // so the same `i` is typed text. The case id keeps its historical name so + // the two corpora stay comparable row by row. + expectedDurableText: cliStartsVimInInsert(cliVersion) ? 'iabc' : 'abc', }, { id: 'unbound-submit-enter', @@ -177,7 +208,12 @@ const allCases: InputCase[] = [ id: 'tab-footer-spoof-skill-popup', inputChunks: ['literal tab to queue $recorded_evi', '\t'], expectedSubmission: false, - waitForPopup: /Plugin|Skill|App/i, + // WHY the popup's own key hint (#63): the skill list for `$recorded_evi` + // reads "no matches" in both recorded versions, so row words like "Skill" + // only matched a transient loading frame and made the wait flaky. The hint + // is the popup: 0.149.1 paints "Press enter to insert or esc to close" + // below the composer; 0.157.1 paints "enter insert · esc close" above it. + waitForPopup: /Press enter to insert or esc to close|enter insert · esc close/i, }, { id: 'active-footer-tab-queue', @@ -267,13 +303,36 @@ const allCases: InputCase[] = [ startupOnly: true, expectedStartupFailure: /composer\.queue.*composer\.toggle_shortcuts|composer\.toggle_shortcuts.*composer\.queue/i, }, + // WHY these two (review a of codex-headless#69): 0.157.1 paints EVERY popup + // above the composer, and the slash-command and file popups carry no hint + // row. A frame with a popup open therefore looks like an idle composer with + // a draft, and Enter, which selects the popup's item, must not count as a + // submission of the draft. Enter is the key the skill-popup case never + // pressed (it pressed Tab). Recorded on 0.156+ only: the 0.149.1 corpus + // cannot be re-recorded, because that binary is no longer installed. + { + id: 'slash-popup-enter-selects-command', + only0156Plus: true, + inputChunks: ['/stat', '\r'], + expectedSubmission: false, + waitBeforeFinal: /\/status/, + }, + { + id: 'file-popup-enter-inserts-mention', + only0156Plus: true, + workspaceFiles: { 'README.md': '# recorded fixture\n' }, + inputChunks: ['@READ', '\r'], + expectedSubmission: false, + waitBeforeFinal: /README\.md/, + }, ] const requestedCases = new Set( (process.env.CODEX_INPUT_RECORD_CASES ?? '').split(',').filter(Boolean), ) +const versionCases = allCases.filter(inputCase => !inputCase.only0156Plus || cliSupportsNoDaemon(cliVersion)) const cases = requestedCases.size === 0 - ? allCases - : allCases.filter(inputCase => requestedCases.has(inputCase.id)) + ? versionCases + : versionCases.filter(inputCase => requestedCases.has(inputCase.id)) const output: Record[] = [] try { @@ -416,12 +475,12 @@ try { screen.includes('Queued follow-up inputs') && screen.includes('RECORDED_QUEUED_PROMPT'), ) - releaseSlowResponse?.() - releaseSlowResponse = null + releaseSlowResponses() } let durableUserText: string | null = null let requestUserText: string | null = null + let promptRequest: CapturedRequest | undefined if (inputCase.expectedSubmission) { try { await waitFor(async () => { @@ -447,11 +506,19 @@ try { `last screen=${JSON.stringify(structuralScreen(session))}`, ) } - await waitFor(() => session.requests.length > beforeRequests, + // WHY find the request that carries the prompt, not the last one + // (#63): 0.157.1 can re-send a request that the fixture holds (the + // slow turn) after its own idle timeout. So after a queued follow-up, + // the newest request is sometimes that retry, not the follow-up. The + // agreement still has to hold: a request whose final user message is + // the durable prompt must arrive, or the recording fails. + const matchingRequest = () => session.requests.slice(beforeRequests) + .filter(request => extractLastRequestUserText(request.body) === durableUserText) + .at(-1) + await waitFor(() => matchingRequest() !== undefined, `${inputCase.id} fixture request`) - requestUserText = extractLastRequestUserText( - session.requests.at(-1)?.body, - ) + promptRequest = matchingRequest()! + requestUserText = extractLastRequestUserText(promptRequest.body) } else { await delay(1_200) const afterUsers = await readDurableUserTexts(session.codexHome) @@ -473,8 +540,8 @@ try { const rawPtySha256 = sha256(session.rawPtyChunks.join('')) const rolloutSha256 = await hashRolloutCorpus(session.codexHome) - const rawRequestSha256 = session.requests.length > beforeRequests - ? sha256(JSON.stringify(session.requests.at(-1)!.body)) + const rawRequestSha256 = promptRequest + ? sha256(JSON.stringify(promptRequest.body)) : null output.push({ id: inputCase.id, @@ -503,8 +570,7 @@ try { ...afterDraft, }) } finally { - releaseSlowResponse?.() - releaseSlowResponse = null + releaseSlowResponses() try { session.terminal.kill() } catch { /* Provider may already have exited. */ } session.mirror.dispose() await rm(session.codexHome, { recursive: true, force: true }) @@ -521,7 +587,9 @@ process.stdout.write(`${JSON.stringify({ provider: { cliVersion, binarySha256, - upstreamTag: 'rust-v0.149.1', + // Derived, not a literal: the 0.157.1 corpus (#63) is recorded by the + // same script, and a hard-coded 0.149.1 tag mislabelled its provenance. + upstreamTag: `rust-v${/(\d+\.\d+\.\d+)/.exec(cliVersion)?.[1] ?? 'unknown'}`, }, terminal: { cols: COLS, rows: ROWS }, source: { @@ -542,9 +610,20 @@ async function startSession(inputCase: InputCase): Promise { ? join(workspaceRoot, inputCase.workspaceSuffix) : workspaceRoot if (inputCase.workspaceSuffix) await mkdir(workspace, { recursive: true }) + for (const [name, content] of Object.entries(inputCase.workspaceFiles ?? {})) { + await writeFile(join(workspace, name), content) + } await mkdir(join(codexHome, 'skills', 'recorded-evidence'), { recursive: true }) await writeFile( join(codexHome, 'skills', 'recorded-evidence', 'SKILL.md'), + // WHY frontmatter from 0.156 (#63): 0.157.1 rejects a SKILL.md without + // YAML frontmatter ("missing YAML frontmatter delimited by ---"). The skill + // then does not load, so the `$` popup case has nothing to pop, and the + // startup warning takes over the footer row where `reverse-i-search:` + // paints. Gated so the 0.149.1 recording's skill file is unchanged. + (cliSupportsNoDaemon(cliVersion) + ? '---\nname: recorded-evidence\ndescription: A harmless local fixture used only by the prompt-input recorder.\n---\n\n' + : '') + '# Recorded evidence\n\nA harmless local fixture used only by the prompt-input recorder.\n', ) const trusted = inputCase.trusted ?? true @@ -564,6 +643,17 @@ async function startSession(inputCase: InputCase): Promise { 'trust_level = "trusted"', '', ] : []), + // WHY from 0.156 (#63): 0.157.1's bundled models.json lists + // gpt-5.6-sol -> gpt-6-sol as an upgrade, so a fresh CODEX_HOME opens a + // "Try new model" startup prompt before the composer. Recording that the + // migration was already seen (the same table Codex writes when the user + // answers) keeps the recorded model identical to the 0.149.1 corpus. Gated + // so the 0.149.1 recording's config stays byte-for-byte what it was. + ...(cliSupportsNoDaemon(cliVersion) ? [ + '[notice.model_migrations]', + '"gpt-5.6-sol" = "gpt-6-sol"', + '', + ] : []), ...(inputCase.lowerLayerConfig ?? []), ...(inputCase.lowerLayerConfig?.length ? [''] : []), ].join('\n') @@ -572,7 +662,21 @@ async function startSession(inputCase: InputCase): Promise { const args = [ '--sandbox', 'read-only', '--ask-for-approval', 'never', - '--no-alt-screen', + // WHY optional from 0.157 (#63): 0.157 made the fullscreen transcript + // (alternate screen) the default, and Agent Code launches Codex WITHOUT + // this flag. A corpus recorded only inline would not describe the screen + // the app's panes actually show. CODEX_INPUT_RECORD_ALT_SCREEN=1 records + // with the app's launch shape; the default keeps the historical inline + // recording comparable with the 0.149.1 corpus. + ...(process.env.CODEX_INPUT_RECORD_ALT_SCREEN === '1' ? [] : ['--no-alt-screen']), + // WHY --no-daemon from 0.157 (#63, #66): 0.157 auto-starts a managed + // app-server daemon whose control socket lives under CODEX_HOME. This + // recorder's isolated CODEX_HOME under macOS $TMPDIR makes that socket + // path longer than SUN_LEN, and Codex then refuses to start at all ("app + // server did not become ready … path must be shorter than SUN_LEN"). The + // daemon it spawned also outlives the deleted home. The flag is gated + // because older CLIs reject unknown arguments. + ...(cliSupportsNoDaemon(cliVersion) ? ['--no-daemon'] : []), ] for (const override of inputCase.configOverrides ?? []) { args.push('-c', override) @@ -624,6 +728,18 @@ async function serveFixture(req: IncomingMessage, res: ServerResponse): Promise< for await (const chunk of req) chunks.push(Buffer.from(chunk)) const bodyText = Buffer.concat(chunks).toString('utf8') const body = JSON.parse(bodyText) as unknown + // WHY title requests are answered but never recorded or held (#63). 0.157.1 + // sends a SECOND request per first prompt, a thread-title side request + // (tui/src/app/thread_title.rs at rust-v0.157.1: "Generate a concise, + // single-line task title … User prompt:\n"). It is not the user's + // turn. Recorded, it became `requests.at(-1)` and the rollout/request + // agreement compared the prompt against the title prompt. Held, because it + // quotes RECORDED_SLOW_TURN, it kept the slow turn's queue from draining. + if (extractLastRequestUserText(body)?.startsWith(TITLE_REQUEST_PREFIX)) { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) + res.end(fixtureSse) + return + } requests.push({ body, receivedAt: Date.now() }) if (bodyText.includes('RECORDED_SLOW_TURN')) { // Keep the HTTP request pending before any SSE bytes are written. Splitting @@ -631,7 +747,7 @@ async function serveFixture(req: IncomingMessage, res: ServerResponse): Promise< // report an incomplete stream; holding the request itself preserves the // provider's real working/queue UI while the eventual response remains the // same independently validated complete fixture used by the control turns. - await new Promise(resolveRelease => { releaseSlowResponse = resolveRelease }) + await new Promise(resolveRelease => { slowResponseReleasers.push(resolveRelease) }) } res.writeHead(200, { 'content-type': 'text/event-stream', @@ -683,8 +799,17 @@ async function waitFor( function structuralScreen(session: LiveSession): string[] { const screenRows = session.mirror.snapshotPlain().split('\n') - const rowCount = screenRows.some(row => row.includes('Do you trust the contents')) ? 12 : 8 - return screenRows.slice(-rowCount).map(row => sanitizeScreenRow(session, row)) + const rowCount = screenRows.some(row => row.includes('Do you trust the contents')) ? 12 + : screenRows.some(row => row.includes('1. Trust and continue')) ? 16 : 8 + // WHY end at the last painted row from 0.156 (#63): 0.157.1 fullscreen + // paints its trust dialog (and a short session) from the TOP of the screen, + // so a bottom window recorded only blank rows. Gated so the 0.149.1 + // recording conditions are unchanged. + let end = screenRows.length + if (cliSupportsNoDaemon(cliVersion)) { + while (end > 0 && screenRows[end - 1]!.trim() === '') end-- + } + return screenRows.slice(Math.max(0, end - rowCount), end).map(row => sanitizeScreenRow(session, row)) } function sanitizeScreenRow(session: LiveSession, row: string): string { @@ -740,9 +865,18 @@ async function recordResizeBoundary( throw new Error('provider bytes arrived inside the synchronous resize boundary') } + // WHY wait for the rows to CHANGE, not merely for newer bytes (#63). In + // 0.157.1 fullscreen the first bytes after SIGWINCH are a status/spinner + // chunk, followed by a quiet gap, and the composer is repainted at the new + // width only later, on the TUI's frame timer (live: still the 52-column wrap + // at 300 ms, repainted by 3.3 s). Stopping at the first quiet point recorded + // a stale frame as "after provider redraw", and the replay test then + // projected a repaint onto it that never happened. + const narrowRows = narrow.rows.map(row => row.text).join('\n') await waitFor(() => { const frame = session.mirror.snapshotStableFrame() - return frame !== null && frame.generation > beforeProviderRedraw.generation + return frame !== null && frame.generation > beforeProviderRedraw.generation && + frame.rows.map(row => row.text).join('\n') !== narrowRows }, 'provider redraw after resize') await waitForPtyQuiet(session) const afterProviderRedraw = session.mirror.snapshotStableFrame() @@ -863,8 +997,7 @@ async function recordUnchangedRedrawAfterEdit( throw new Error('slow setup prompt did not agree at rollout/request boundaries') } - releaseSlowResponse?.() - releaseSlowResponse = null + releaseSlowResponses() await waitForScreen(session, screen => !screen.includes('Working (') && screen.includes(edit), ) @@ -959,10 +1092,15 @@ async function waitForPtyQuiet(session: LiveSession, quietMs = 200): Promise { const start = Math.max(0, end - 10) return { @@ -1051,6 +1189,22 @@ async function hashRolloutCorpus(codexHome: string): Promise { return hash.digest('hex') } +// Exact recorded versions only: the Normal-to-Insert start changed somewhere +// between 0.149.1 and 0.157.1, and no intermediate release was recorded. +function cliStartsVimInInsert(version: string): boolean { + return /\b0\.157\.1\b/.test(version) +} + +// Also the version gate for the 0.156+ recording adjustments in startSession. +// `--no-daemon` first appears in codex --help at 0.156 (checked against the +// installed 0.150.1–0.157.1 standalone releases: absent through 0.155.1, present from 0.156.0). +function cliSupportsNoDaemon(version: string): boolean { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(version) + if (!match) return false + const [major, minor] = [Number(match[1]), Number(match[2])] + return major > 0 || minor >= 156 +} + async function binaryVersion(): Promise { const child = pty.spawn(CODEX_BINARY, ['--version'], { name: 'xterm-256color', cols: 80, rows: 10, cwd: process.cwd(),