Skip to content

Answer provider conditions without the composer's readiness gate - #598

Merged
Juliusolsson05 merged 6 commits into
mainfrom
fix/tui-condition-answering
Jul 22, 2026
Merged

Juliusolsson05 merged 6 commits into
mainfrom
fix/tui-condition-answering

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Fixes the Codex trust dialog being unanswerable on a new folder, and three routes by which a valid Claude AskUserQuestion answer failed closed.

Plan and full evidence trail: docs/decomposition/provider-condition-answering.md

The root cause — and why two previous PRs missed it

TileLeaf.send, the callback wired to every condition view's onSend, opens with a readiness gate:

if (!runtime.inputReady || runtime.processStatus !== 'started' || isSessionExited(runtime)) {
  await workspace.ensureSessionLive(sessionId)
}

A live provider condition is precisely the state that clears inputReady. Codex reports blocked whenever a modal owns the screen (blockingCondition, added by #574); Claude reports not-ready whenever a permission prompt is up.

So every click on the trust modal took the wake path instead of writing a keystroke — and before #597, that wake adopted the live backend, waited 30s for a readiness that could not arrive while the unanswered modal held the screen, then killed the process.

That is the reported symptom exactly: accept does nothing, cancel does nothing, the modal never closes — because the one keystroke that would dismiss it was never written.

#574 and #597 both looked below sendInput. The write was never attempted.

Condition keystrokes now use a dedicated path that does not consult readiness and does not wake — a visible condition is itself proof the backend is alive and painting — and surfaces a refused write as a toast instead of discarding the boolean.

Found by experiment, not by reading

A throwaway node-pty probe drove real codex-cli 0.145.0 in a fresh temp dir at 120/80/60/50 columns. It killed two hypotheses I would otherwise have shipped as fixes:

Hypothesis Result
Wrapped markers break detection at narrow widths Disproven — detection held at every width to 50 cols; the paragraph wraps after the opening phrase
The keystrokes are wrong, so nothing happens Disproven as the cause — \r, 1, 2 and ESC all did exactly what upstream documents against a real dialog

Both looked obviously right on paper. Neither was the bug.

Corpus evidence for the Codex false positive

detectCodexTrustDialog tested screen.includes(marker) for three phrases anywhere on screen. Scanning 52 real session recordings for all three markers found 14 full matches — and every one is a screen of an assistant discussing the trust dialog (a code review, a pasted parser, a plan document). The approval titles share the flaw: 28 frames matched three different titles simultaneously, because the frame held a list literal containing all of them. A real overlay can never do that.

This is not cosmetic: codex.trust-dialog is in actionKinds, so a phantom detection blocks keystroke routing and paints an unanswerable modal over a session asking nothing — re-firing every frame, so it never closes.

Detection now requires structure: the > You are in <path> anchor, then both numbered option rows, below it and in order.

Submodule changes

codex-headless (fix/trust-dialog-anchoring) — structural anchoring, plus deterministic keystrokes:

  • accept '\r' → '1'. Enter confirms whatever is highlighted (trust_directory.rs KeyboardHandler), so any stray arrow key turned "trust directory" into "quit".
  • decline '2\r' → '2'. Upstream acts on the digit immediately; the trailing Enter leaked into the next screen.

Also records upstream drift: 0.145 appends a sentence the vendored 0.130 source lacks, so anchoring on the full paragraph would already be broken.

claude-code-headless (fix/auq-answer-matching) — three independent routes that all surface identically as a dead row:

  1. Option number demoted to a hint. The row sends q.options.indexOf(option) + 1 — a semantic index — and the driver resolved it against the screen's numbering. On divergence it returned null outright; it now falls through to label matching, with the duplicate-label guard still failing closed.
  2. Wrapped labels rejoined. OPTION_RE captures one physical line, so a wrapped label kept only its first line and could never match. Continuations are distinguished from descriptions by indentation.
  3. sameQuestion option-set fallback. Both question and header can legitimately be null (multi-chip nav bars deliberately emit header: null), which returned false unconditionally and died at wait-question:<q> — the timeout chased through several revisions of that file.

Verification

  • Live, end-to-end at 120/80/60/50 columns: detect → accept → dialog gone, workspace path parsed, at every width.
  • Recorded false-positive prose → visible: false. Anchor-without-options → visible: false.
  • tsc --noEmit clean on both projects; both submodules type-check.
  • Suites: agent-code 1209/1209, claude-code-headless 51/51, codex-headless 11/11.
  • Probe scripts were temporary and are not committed.

Known gaps, stated rather than buried

  • No genuine trust dialog appears in any of the 52 recordings. The root cause explains the symptom without it, but that observation gap is real and would hide the next bug of this shape. The write-log instrumentation from the plan was not built — the cause was found by reading the send path once the keystrokes were exonerated — and remains the obvious follow-up.
  • The Claude AUQ fixes are verified by construction, the existing suite, and a corpus capture used as the reference shape — not against a live multi-question picker. A live AUQ probe is the natural next step.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits July 22, 2026 19:49
Fixes the Codex trust dialog being unanswerable on a new folder, and three
routes by which a valid Claude AskUserQuestion answer failed closed.

THE ROOT CAUSE, and why two previous PRs missed it. TileLeaf.send — the
callback wired to every condition view's onSend — opens with a readiness
gate that wakes the session when `!runtime.inputReady`. A live provider
condition is precisely the state that CLEARS inputReady: Codex reports
blocked whenever a modal owns the screen (#574), Claude reports not-ready
whenever a permission prompt is up. So every click on the trust modal took
the wake path instead of writing a keystroke, and before #597 that wake
adopted the live backend, waited 30s for a readiness that could not arrive
while the unanswered modal held the screen, then killed the process.

That is the reported symptom exactly: accept does nothing, cancel does
nothing, the modal never closes — because the keystroke that would dismiss
it was never written. #574 and #597 both looked below sendInput; the write
was never attempted.

Condition keystrokes now use a dedicated path that does not consult
readiness and does not wake — a visible condition is itself proof the
backend is alive and painting — and surfaces a refused write as a toast
instead of discarding the boolean.

Found by experiment, not by reading. A throwaway node-pty probe drove real
codex-cli 0.145.0 in a fresh temp dir at 120/80/60/50 columns. It killed two
hypotheses I would otherwise have shipped as fixes: wrapped markers do NOT
break detection at any width, and the keystrokes DID work against a real
dialog. Both were wrong theories that looked obviously right on paper.

Submodules carry the provider-side fixes:
  codex-headless      structural anchoring for trust detection (the old
                      substring scan fired on 14 recorded frames, every one
                      an assistant discussing the dialog), plus accept '1'
                      instead of '\r' and decline '2' instead of '2\r'
  claude-code-headless option number demoted to a hint, wrapped labels
                      rejoined, sameQuestion given an option-set fallback

Plan and full evidence trail:
docs/decomposition/provider-condition-answering.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Juliusolsson05 and others added 3 commits July 22, 2026 20:30
The phone client bundles this modal and has no codex-headless alias, so
importing the keystroke constants broke `client:build`. That break is
useful: the renderer should not have had keystroke constants at all.

The condition snapshot already carries the provider's actions with their
bytes, and every view already receives `actions`. Dispatching by id keeps
one source of truth in the parser, keeps the phone bundle free of a
headless package, and means a future upstream key change is a
provider-side edit only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewer A found the fix was half-applied and one comment was wrong about
its own mechanism.

The keyboard route was still readiness-gated. useComposerKeybinds routes
ArrowUp/Down to the PTY while a condition is live, then hard-blocked on
!backendReady with blockBackendWrite() — so the arrow keys that move a
Claude AskUserQuestion picker's selection did nothing, and toasted "Agent
is still starting", for exactly as long as the picker was up. Same root
cause, same fix, a route I missed: condition keystrokes now take
sendConditionKey there too.

Added a live-snapshot fence. The readiness gate happened to close one hole
this path would otherwise open: a quarantined pane (ownership conflict)
has its feed channels fenced INCLUDING session:exit, the sole caller of
clearConditionRuntimeState, so its last condition snapshot stays frozen on
screen while main's entry under that id may belong to someone else.
Writing raw keystrokes there is what the quarantine exists to prevent.
Requiring a live snapshot mirrors RemoteServer.applyPermissionReply.

Corrected the comment's account of Codex. It claimed blockingCondition
clears inputReady; it does not — that feeds only the prompt-delivery poll.
Codex readiness latches true at markComposerReady and clears only on exit.
The trust dialog reaches the broken state because it paints BEFORE the
composer ever appears, so readiness was never true. A mid-session approval
modal does not clear it and was never gated. The premise holds, the stated
mechanism did not, and the doc repeated it.

The toast no longer promises a retry that may never work: main returns one
bit for two disjoint refusals (delivery reservation vs no backend) and the
renderer cannot tell them apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second reviewer drove a live claude v2.1.217 picker, which I never did,
and all four AUQ changes failed against real output — one of them a
regression that killed multi-select answering outright. The submodule PR is
reverted and closed; the gitlink returns to main.

The label/description distinction does not exist in plain text at all: in
single-select the continuation and the description share a column, in
multi-select the description sits further left. The parser holds the real
xterm grid, and cell attributes (descriptions render dim) are the only
signal that separates them. That is a different design, not a tweak, so
this ships nothing for it rather than shipping a guess.

Recorded plainly in the decomposition that the failure was the exact one
the document warned about, committed by its author: the AUQ changes were
built from a corpus capture plus reasoning, with no live picker, and the
51-test suite passed the entire time.

What ships is what was verified against a live dialog: the renderer's
condition-send path (mouse and keyboard routes), and the Codex trust-dialog
anchoring and keystrokes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round: two orchestrated reviewers. One PR reverted, five defects fixed.

Reviewer A (root cause + renderer) confirmed the premise but found the fix half-applied. Reviewer B (provider parsers) drove a live claude v2.1.217 picker — which I never did — and proved every AskUserQuestion change wrong.

The AskUserQuestion PR is reverted and closed

All four changes failed against real output. This is the important part of the round, so it is stated plainly rather than buried:

Change What live output showed
Wrapped-label rejoining REGRESSION. Descriptions render at indent 2, left of the label column — exactly what the new branch treated as a continuation. Every option absorbed its description; multi-select answering worked before and was dead after.
Same rule, intended case Never fired. Continuations render at exactly labelCol; the test was strict <. And <= cannot work either — in single-select the continuation and description share a column, in multi-select the description is further left.
sameQuestion option-set fallback Answers the wrong sibling question. Two Yes/No questions in one call is the common shape; the fallback matched and wrote a keystroke to whichever was on screen. The comment I deleted named this exact hazard.
Number-hint fallthrough Reopened the stale-picker hole it documents. Number+label pairing was the guard.

Plain text carries no discriminator between a label continuation and a description. The parser holds the real xterm grid, and cell attributes (descriptions render dim) are the only signal that separates them. That is a different design, not a tweak, so this ships nothing for it rather than shipping a guess.

Worth recording: those changes were built from a corpus capture plus reasoning, with no live picker — precisely the failure mode the decomposition document warned about, committed by its author. The 51-test suite passed the entire time. claude-code-headless#46 is closed; the gitlink is back on main.

Fixed in agent-code

  1. Bundle break (reviewer A, confirmed by executing electron-vite build). The modal imported constants from codex-headless, the only renderer-graph import of a headless package in the repo — electron.vite.config.ts states that rule verbatim. Neither tsc nor vitest catches it; the phone client build failed in CI too. Fixed properly: the modal now dispatches the condition's own actions from the snapshot and has no keystroke constants at all.
  2. The fix was half-applied. useComposerKeybinds routes ArrowUp/Down to the PTY while a condition is live, then hard-blocked on !backendReady — so the arrow keys that move a Claude picker's selection did nothing and toasted "Agent is still starting", for as long as the picker was up. Same root cause, route I missed.
  3. Quarantine fence. The readiness gate happened to close one hole this path opens: a quarantined pane has its feed channels fenced including session:exit, the sole caller of clearConditionRuntimeState, so its condition snapshot stays frozen while main's entry may belong to someone else. sendConditionKey now requires a live snapshot, mirroring RemoteServer.applyPermissionReply.
  4. My comment was wrong about Codex. It claimed blockingCondition clears inputReady; it does not — that feeds only the prompt-delivery poll. Codex readiness latches true at markComposerReady and clears only on exit. The trust dialog reaches the broken state because it paints before the composer ever appears. The premise holds; the stated mechanism did not, and the doc repeated it. Both corrected.
  5. Toast honesty. Main returns one bit for two disjoint refusals (delivery reservation vs no backend); the message no longer promises a retry that may never work.

Fixed in codex-headless

  • Measured detection floor, replacing an implied claim: holds to 46 columns, fails at 44 (the question phrase itself wraps) and at rows ≤ 7 (option rows clip). Both inherited from the old marker list, not introduced — but implying width-safety was worse than saying nothing.
  • Removed the contradictory "byte-for-byte frozen" paragraph that sat fifteen lines above a commit changing those bytes. Ids/labels/order remain a contract; the keystrokes were simply wrong.

Confirmed clean by reviewer B, against the live 0.145.0 TUI

  • 1 accepts from a fresh dialog and after ↓ moved the highlight to "No, quit"; 2 quits with no stray Enter.
  • The › marker moves between rows and both regexes accept it. Codex repaints from the top on resize — no anchor-scrolled-off-but-options-visible frame could be constructed.
  • Wire-contract blast radius is effectively nil: the remote path matches against the live snapshot it just broadcast, and the phone echoes back the action object it received rather than composing bytes.

Verification after all fixes

tsc clean on both projects · electron-vite build succeeds · client:build succeeds · agent-code 1209/1209 · codex-headless 19/19 with new captured-fixture tests · claude-code-headless back on main, 51/51.

The trust-dialog fix is merged upstream; the pointer moves off the feature
branch so main never references a branch commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant