Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/plans/2026-09-25-codex-composer-occupancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Codex: see a native draft before writing into it (#800, #1313)

## Evidence
codex-headless `testing/fixtures/composer-0157/idle-draft-ctrlc.json` is a raw PTY recording of codex-cli 0.157.0 (100×30; 112 output chunks, 65 synchronized-output opens and 65 closes).
- **Empty composer:** `› Ask Codex to do anything`, with the placeholder painted dim, then a blank row, the status row and a hint row (`← for agents · ? for shortcuts`).
- **Typed draft:** `› please review the draft`, in plain cells, with the hint gone. Codex paints it over 27 chunks within ~24 ms of the keystrokes. One redraw opens a synchronized update in one chunk and closes it in the next.

Before this PR:
- **#1313:** `isCodexNativeComposerEmpty` (text-only) cannot call the empty 0.157 composer empty: the placeholder is text, and the hint row sits below the status row. So every browser-pocket restart (`requireEmptyNativeComposer`) is refused.
- **#800:** nothing reads a native draft. `awaitReadyForPrompt` is ready whenever `›` and ` · ` are on screen, so a normal delivery pastes after the human's draft and submits both. Readiness latches after startup, so `nativeDraft` stays `unknown`.

## Change
**Package.** `packages/codex-headless` moves from `ae0e935` to the merge of codex-headless#55. That includes:
- #53: proxy chunks.
- #54: `CodexHeadless.getComposerState()` returns `empty`, `drafted` or `unknown`, from the live buffer's cell attributes and Codex's own empty hint, never from the cwd.
- #55: `CodexHeadless.getSettledScreen()` returns the plain viewport only for a settled frame, and `null` otherwise. Settled means no chunk is queued in the parser, no synchronized update is open, and Codex has painted since the last resize. The cell reading uses the same rule.

**The rule everywhere.** `drafted` means occupied. A proof of empty means ready. Anything else is neither (steering q40): it never consents, and it never latches occupied.

A proof of empty is either the package's `empty`, or the bare-`›` text proof read from the **settled** screen. The text proof is the only one 0.149.1 and narrow 0.157 panes can give, because they have no hint row. `getScreen()` is never used as a proof: it can show the paint from before the human's keystrokes.

- **Write gate (`awaitReadyForPrompt`):**
- `drafted` returns `occupied` (`human-draft`). Delivery refuses with `retry-after-resolve` and writes nothing.
- Text that nothing proves empty is refused at once, as a human's to resolve.
- A text-only proof must hold on two consecutive settled polls 50 ms apart (#1319 review A). That covers the keystroke Codex has read but not yet painted, which no terminal can see.
- A null settled screen resets the count.
- **Restart (`requireEmptyNativeComposer`):** the final check needs the same proof of empty, and it refuses on its own even after readiness said ready.
- **Readiness publication (`publishNativeComposer`, on each screen event after the startup latch):**

| Frame | Published |
|---|---|
| `drafted` | `composer-occupied` |
| proof of empty | `ready` |
| anything else | `provider-not-ready`, without latching occupied |

`nativeDraft` then reports `occupied`, and the pane shows "draft in agent composer — clear it to send", as it does for Claude.
- **Agent Code's own composer (`useComposerKeybinds`), while `composer-occupied`, for every provider:**
- Enter and Send refuse, keep the draft, and toast how to clear the agent's draft (review C).
- Ctrl+C reaches the agent, because that is how 0.157 clears a draft, and it keeps the user's Agent Code draft (review round 2 B).
- **Tool text:** the orchestration hold message and `nativeDraft` now mention a native draft.

## Tests
All red on main where they cover new behaviour. Integration tests drive the recorded 0.157 bytes through a real `CodexHeadless`.

`codexSession.nativeComposer.test.ts`:
- A restart into the recorded idle composer is delivered (#1313).
- A normal delivery into the recorded draft is refused with no write (#800).
- The draft publishes `composer-occupied`, and `ready` returns after Ctrl+C.
- A 13-row draft the reading cannot classify is never written into, and publishes `provider-not-ready` without latching.
- One stale bare-marker read does not consent.
- A bare marker with no settled frame never consents, for either the gate or the restart.
- A hintless pane returns to `ready` from a settled bare marker, and not while unsettled.
- The restart's final check refuses a draft after readiness said ready.

`codexSession.launchOrdering.test.ts`: publication through the real screen listener.

`useComposerKeybinds.tab.renderer.test.tsx`:
- Occupied Enter refuses for a text draft, for an image-only draft, and for a non-Codex provider.
- Ctrl+C passes through and keeps our draft.

The package tests are listed in codex-headless#54 and #55.

## Residuals
- **#1327:** a native draft the pane cannot classify publishes `provider-not-ready`, and Enter from Agent Code's composer still writes into it. Refusing every `provider-not-ready` would also block queueing a prompt during a turn. The fix is a separate "unreadable draft" readiness reason. Orchestration delivery already refuses this case.
- Readiness can blink `ready` → `provider-not-ready` while Codex streams on a hintless pane, because a flush can land mid-parse. This is display-only: keystrokes are not gated, and identical states are deduped.
- Under the known synchronized-output callback stall, the settled read stays null. Delivery then times out and refuses. That is the fail-closed direction.
2 changes: 1 addition & 1 deletion src/mcp/runtime/createBuiltInMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,7 +1353,7 @@ function orchestrationCreateAgentCallKey(
// mid-turn answers not-ready too, and for it the prompt lands after
// the current turn, not after a startup — a parent reading
// "starting" would misjudge how long it waits.
message: `The agent cannot take a prompt yet (${delivery.message}), so the prompt is waiting: it will be delivered as soon as the agent can accept it — after it finishes starting, after a dialog is answered, or after its current turn if it is busy. Do not send it again. orchestration_wait_agents treats the agent as working until the prompt lands and it answers; orchestration_read_agent shows promptSubmitted once it lands. Sending another prompt to this agent before then REPLACES this one.`,
message: `The agent cannot take a prompt yet (${delivery.message}), so the prompt is waiting: it will be delivered as soon as the agent can accept it — after it finishes starting, after a dialog is answered, after its current turn if it is busy, or after someone clears a draft left in the agent's own composer (that one waits for a human). Do not send it again. orchestration_wait_agents treats the agent as working until the prompt lands and it answers; orchestration_read_agent shows promptSubmitted once it lands. Sending another prompt to this agent before then REPLACES this one.`,
})
}
if (!delivery.ok) {
Expand Down
44 changes: 43 additions & 1 deletion src/providers/codex/runtime/codexSession.launchOrdering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const launch = vi.hoisted(() => ({
// event ladder via `recordTrustInEvents`.
trustCalls: [] as string[],
recordTrustInEvents: false,
// #1319 review B: the fake keeps its listeners and a composer reading, so
// a test can drive a real 'screen' event through a started session.
listeners: new Map<string, (payload: unknown) => void>(),
composer: 'unknown' as 'empty' | 'drafted' | 'unknown',
screen: '',
}))

vi.mock('node-pty', () => ({
Expand All @@ -45,7 +50,14 @@ vi.mock('codex-headless', () => {
launch.headlessOptions = options
}

on(): this { return this }
on(event: string, listener: (payload: unknown) => void): this {
launch.listeners.set(event, listener)
return this
}
getComposerState() { return launch.composer }
getScreen() { return launch.screen }
getSettledScreen() { return launch.screen }
getConditionSnapshot() { return { provider: 'codex', conditions: {}, ts: 0 } }
async start() {
launch.events.push('headless:start')
return { sessionsDir: '/recorded/codex/sessions' }
Expand Down Expand Up @@ -151,6 +163,9 @@ beforeEach(() => {
})
launch.profileBarrier = Promise.resolve()
launch.releaseProfile = () => undefined
launch.listeners = new Map()
launch.composer = 'unknown'
launch.screen = ''
})

describe('CodexSession resume launch attestation ordering', () => {
Expand Down Expand Up @@ -473,3 +488,30 @@ describe('CodexSession resume launch attestation ordering', () => {
}
})
})

// #1319 review B: publication must run from the headless 'screen' listener
// itself; a test calling publishNativeComposer directly passed with the
// listener's call deleted.
describe('CodexSession native composer publication', () => {
it('publishes a native draft as composer-occupied from the screen event, and ready once cleared', async () => {
const session = new CodexSession({ binary: 'recorded-codex', cwd: '/recorded/worktree', useProxy: false })
const readiness: Array<{ ready: boolean; reason?: string }> = []
session.on('input-readiness', state => readiness.push(state))
await session.start()
const screen = launch.listeners.get('screen')!
expect(screen).toBeDefined()
launch.composer = 'empty'
launch.screen = '› Ask Codex to do anything\n\n gpt · /recorded/worktree\n ? for shortcuts'
screen({ plain: launch.screen })
expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' })
launch.composer = 'drafted'
launch.screen = '› please review the draft\n\n gpt · /recorded/worktree'
screen({ plain: launch.screen })
expect(readiness.at(-1)).toEqual({ ready: false, reason: 'composer-occupied' })
launch.composer = 'empty'
launch.screen = '› Ask Codex to do anything\n\n gpt · /recorded/worktree\n ? for shortcuts'
screen({ plain: launch.screen })
expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' })
await session.stop()
})
})
Loading
Loading