From fae1280d839e3e278629d54f4c4c8c378ceb4f05 Mon Sep 17 00:00:00 2001 From: Derek Marshall Date: Wed, 14 Oct 2026 10:24:32 +0200 Subject: [PATCH] fix(codex): show attention for visible async questions --- AGENTS.md | 2 +- ARCHITECTURE.md | 11 + FEATURES.md | 6 +- docs/AGENT_ORCHESTRATION.md | 31 +- docs/HARNESS.md | 53 +++- docs/drafts/CODEX_SUPPORT_PLAN.md | 25 +- scripts/codex-async-question-smoke.py | 163 ++++++++++ src/agent/codex_approval.rs | 17 +- src/agent/codex_questions.rs | 215 +++++++++++++ src/agent/detect_rules.rs | 3 + src/agent/mod.rs | 1 + src/app/agent_status.rs | 8 +- src/app/harness_tests/codex_questions.rs | 289 ++++++++++++++++++ src/app/harness_tests/mod.rs | 1 + tests/fixtures/codex-questions/README.md | 24 ++ tests/fixtures/codex-questions/collapsed.txt | 3 + .../codex-questions/expanded-clipped.txt | 7 + .../codex-questions/expanded-freeform.txt | 9 + .../codex-questions/expanded-options.txt | 11 + .../codex-questions/expanded-wrapped.txt | 12 + 20 files changed, 858 insertions(+), 33 deletions(-) create mode 100644 scripts/codex-async-question-smoke.py create mode 100644 src/agent/codex_questions.rs create mode 100644 src/app/harness_tests/codex_questions.rs create mode 100644 tests/fixtures/codex-questions/README.md create mode 100644 tests/fixtures/codex-questions/collapsed.txt create mode 100644 tests/fixtures/codex-questions/expanded-clipped.txt create mode 100644 tests/fixtures/codex-questions/expanded-freeform.txt create mode 100644 tests/fixtures/codex-questions/expanded-options.txt create mode 100644 tests/fixtures/codex-questions/expanded-wrapped.txt diff --git a/AGENTS.md b/AGENTS.md index 6ff41e9c..427d4827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ Deep design decisions live in [`ARCHITECTURE.md`](ARCHITECTURE.md) (sync-only, M **Other crates:** -- **`src/agent/`** — agent profile registry; one `AgentProfile` impl per hosted agent (claude/codex/agy/zot). Adding an agent = one impl + one `REGISTRY` entry. Per-agent **P1-2 scrape-fallback** rules (for a state an agent's hooks can't report) live in `detect_rules.rs` (`Region`/`Matcher`/`DetectionRule`), returned by `AgentProfile::detection_rules()` — agy's approval prompt and Codex's complete command/file-edit dialogues. `codex_approval.rs` holds the verified Codex rule and its temporary precedence over non-blocked reports. `chrome.rs` and `chrome/codex.rs` verify native input/footer boundaries (`AgentProfile::output_len`), so `gf`/`J` read the agent's output; Codex also applies that boundary to live Quick Select. `codex_home.rs` resolves the home captured from each pane's launch environment, shared by rollout lookup and fork ancestry. `codex_records.rs` normalizes current and legacy rollout messages/tool records without OS or App dependencies; transcript rendering deduplicates identified records within each session. Its `metrics` child projects bounded telemetry through the same record identities. `codex_metrics/` folds pure usage/mode/tool snapshots and follows one exact rollout through `tail/`, the agent-neutral half: a bounded incremental reader of one session file (`Tail`, behind a `Follow` that names the file's containment, identity and line meaning) and the consumer-owned watch that wakes it (kqueue on macOS, `notify` elsewhere, no idle polling). The Codex HUD subscribes through `app::agent_hud`; reading starts only for a visible or explicit consumer. `claude_metrics/` is its Claude Code counterpart over one session transcript, with its own token type because Claude's `input` excludes the cache reads and writes Codex's includes: a pure projection (`records`) keeping only labels, counts and bounded ids, and a reducer that counts a message once however many content blocks repeat it, forgets the context's size at a compaction while keeping lifetime totals, and accumulates a plan from `TodoWrite`'s full-list reports or `TaskCreate`/`TaskUpdate`'s own `from`/`to` transitions, whichever a session uses. Its `stream`/`watch` follow one exact transcript through `tail/`; a transcript has no header, so its identity is its name, `/projects/…/.jsonl`, with every record's `sessionId` checked too. The agent HUD subscribes through `app::agent_hud`, and only spyc's own MCP server keeps its name in a tool label; `examples/claude_metrics.rs` observes one named transcript. `claude_statusline.rs` is the bounded projection of Claude Code's statusline input (the context window and the plan's rate-limit windows) that `spyc --statusline` (`mcp::statusline`) forwards over the internal `spyc/statusline` method, never an MCP tool. `codex_recovery.rs` folds bounded question lifecycle metadata in the Model, requiring pane/session/turn/call correlation before completion can restore working. `status_hook.rs` bounds and sanitizes lifecycle metadata for diagnostics, excluding hook arguments and response content; of Claude's `Stop` lists of pending background work it keeps only their entry counts. `waiting.rs` is the pure precedence of a live `waiting` report over the hook `done`s that follow it, plus the turn a hook opens and ends, inside which an agent's own `waiting` is held until the turn ends. +- **`src/agent/`** — agent profile registry; one `AgentProfile` impl per hosted agent (claude/codex/agy/zot). Adding an agent = one impl + one `REGISTRY` entry. Per-agent **P1-2 scrape-fallback** rules (for a state an agent's hooks can't report) live in `detect_rules.rs` (`Region`/`Matcher`/`DetectionRule`), returned by `AgentProfile::detection_rules()` — agy's approval prompt and Codex's complete command/file-edit dialogues. `codex_approval.rs` holds verified visible waits and their temporary precedence over non-blocked reports; `codex_questions.rs` recognizes the default async-question editor and collapsed summary without storing question content. `chrome.rs` and `chrome/codex.rs` verify native input/footer boundaries (`AgentProfile::output_len`), so `gf`/`J` read the agent's output; Codex also applies that boundary to live Quick Select. `codex_home.rs` resolves the home captured from each pane's launch environment, shared by rollout lookup and fork ancestry. `codex_records.rs` normalizes current and legacy rollout messages/tool records without OS or App dependencies; transcript rendering deduplicates identified records within each session. Its `metrics` child projects bounded telemetry through the same record identities. `codex_metrics/` folds pure usage/mode/tool snapshots and follows one exact rollout through `tail/`, the agent-neutral half: a bounded incremental reader of one session file (`Tail`, behind a `Follow` that names the file's containment, identity and line meaning) and the consumer-owned watch that wakes it (kqueue on macOS, `notify` elsewhere, no idle polling). The Codex HUD subscribes through `app::agent_hud`; reading starts only for a visible or explicit consumer. `claude_metrics/` is its Claude Code counterpart over one session transcript, with its own token type because Claude's `input` excludes the cache reads and writes Codex's includes: a pure projection (`records`) keeping only labels, counts and bounded ids, and a reducer that counts a message once however many content blocks repeat it, forgets the context's size at a compaction while keeping lifetime totals, and accumulates a plan from `TodoWrite`'s full-list reports or `TaskCreate`/`TaskUpdate`'s own `from`/`to` transitions, whichever a session uses. Its `stream`/`watch` follow one exact transcript through `tail/`; a transcript has no header, so its identity is its name, `/projects/…/.jsonl`, with every record's `sessionId` checked too. The agent HUD subscribes through `app::agent_hud`, and only spyc's own MCP server keeps its name in a tool label; `examples/claude_metrics.rs` observes one named transcript. `claude_statusline.rs` is the bounded projection of Claude Code's statusline input (the context window and the plan's rate-limit windows) that `spyc --statusline` (`mcp::statusline`) forwards over the internal `spyc/statusline` method, never an MCP tool. `codex_recovery.rs` folds bounded question lifecycle metadata in the Model, requiring pane/session/turn/call correlation before completion can restore working. `status_hook.rs` bounds and sanitizes lifecycle metadata for diagnostics, excluding hook arguments and response content; of Claude's `Stop` lists of pending background work it keeps only their entry counts. `waiting.rs` is the pure precedence of a live `waiting` report over the hook `done`s that follow it, plus the turn a hook opens and ends, inside which an agent's own `waiting` is held until the turn ends. - **`src/archive/`** — archive browsing: a mount is an **index, not a directory**, so entering a large zip writes zero bytes. `mod` (format detect), `index` (the entry table + the name normalization that makes a zip-slip *name* structurally impossible), `scan` (oddities → `Capability`), `budget` (mount/confirm/refuse), `journal` (pending changes), `listing` (index + journal → an `fs::Listing`), `read` (index a seekable container, stream a compressed tar into staging, materialize one member — **extraction never traverses a symlink, and a link target is judged the same way**), `write` (the repack: temp file, verified by reading it back, then an atomic rename). Containment rules, the permissions split, and the streaming trade: ARCHITECTURE.md → "Archive mounts". Design history in [`docs/archive/ARCHIVE_BROWSING_PLAN.md`](docs/archive/ARCHIVE_BROWSING_PLAN.md). - **`crates/spyc-vt-sys/`** — the FFI to libghostty-vt, at a spyc-owned pinned ghostty commit, with prebuilt static archives vendored per target. Everything C has one home here: the pin (`pin.rs`, plus the bump policy — a pin bump owes a full harness re-run), the bindgen-generated bindings (`bindings.rs`, checked in so neither bindgen nor libclang is a build dependency; ~371 compile-time layout assertions catch ABI drift), the row-budget→limits derivation (`scrollback.rs` — **rows are the UX contract, bytes are a safety valve**, and neither may be left at its default), and `build.rs`, which verifies each archive's SHA-256 before it links. Five targets, not four: CI's test host (`x86_64-unknown-linux-gnu`) is not a release artifact but still needs an archive. Rationale in the crate's README; posture in SECURITY.md and deny.toml. - **`src/keymap/`** — `Action` enum (`action.rs` — the full vocabulary of behaviours, each tagged with its `tier()`), resolver, DSL parser, default bindings. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b9e4782e..75df812a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -157,6 +157,17 @@ with the first pending repaint rather than moving with every output event; continuous modal redraws therefore cannot postpone the scan indefinitely. Other agents retain the trailing quiet-window debounce. +Codex's complete default async-question editor or collapsed positive question +summary supplies the same temporary attention, through `codex_questions`. +Its posting tool returns immediately, so neither that completion nor Enter alone +establishes an answer. The current native view is the signal: answering, skipping, +queuing, follow-up input or native resolution can clear it. A semantic block keeps +precedence, but the visible wait stays cached behind it because a later working or +done report need not repaint the child. Clearing the view retires only this fallback; +the latest live report resumes, and expiry still applies. Unknown/customized or +clipped footers produce no guess. No question content or answer ids are retained, +and no new hook, subscription or idle timer is introduced. + ## Update model: Elm-architecture (MVU) spyc follows the Elm/Model-View-Update pattern. The structural migration diff --git a/FEATURES.md b/FEATURES.md index 48c28089..157616af 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -300,7 +300,11 @@ protocol, no image protocols, no OSC 52 clipboard. Known command, file-edit, MCP-tool and network forms require their default choices and complete footer at the viewport bottom; diagnostics identify this `scrape-fallback`. Missing required text and other forms produce no guess. Codex's `Interrupt` hook - reports `idle` after cancellation. Its `request_user_input` hooks report + reports `idle` after cancellation. Complete default async-question editors + and collapsed question summaries also show `blocked` while the tab needs an + answer, even if Codex keeps working. Clearing the native view retires that + fallback and resumes the latest live report; clipped/customized views are + not guessed. Its `request_user_input` hooks report `blocked` on start and recover `working` only for the same pending pane/session/turn/call; other waits stay blocked. Project trust and `/hooks` review are separate from spyc's consent; spyc never auto-approves hooks. **agy** (Antigravity) diff --git a/docs/AGENT_ORCHESTRATION.md b/docs/AGENT_ORCHESTRATION.md index 39b2dbad..6559f754 100644 --- a/docs/AGENT_ORCHESTRATION.md +++ b/docs/AGENT_ORCHESTRATION.md @@ -219,15 +219,28 @@ Real-CLI native question and quiet-after-answer checks passed through the automated TUI harness with already trusted hooks. Fresh hook trust onboarding remains a separate acceptance case; see `docs/HARNESS.md` for the driver. -`request_user_input_async` is a separate question path. Its immediate completion -acknowledges posting a question; it does not establish that the user answered. -These native question hooks do not instrument that path. The -[`0.160.1` async handler](https://github.com/openai/codex/blob/rust-v0.160.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs) -posts an async message and immediately returns `accepted`; the -[TUI reply parser](https://github.com/openai/codex/blob/rust-v0.160.1/codex-rs/tui/src/async_question_reply.rs) -handles the answer as a later user message. A blocked latch tied to the tool's -completion would misrepresent an agent that continues working. Async-answer -lifecycle coverage remains open in A3. +`request_user_input_async` posts a question and returns immediately; its tool +completion does not establish that the user answered. The +[`0.162.1` async handler](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs) +and [question editor](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/async_questions/state.rs) +keep posting, local answer delivery, skipping and native resolution separate. + +spyc recognizes the default native question editor's complete submit/skip/navigation +footer, or its collapsed positive question count and answer hint, at the current +viewport bottom. The dot reads `blocked` with `awaiting async question answer`; +this means the tab needs you, even while Codex continues working. `:why-status` +and `:activity dump` label the source `scrape-fallback`. The latest semantic report +remains stored and resumes when the native view clears. An independent semantic +block keeps precedence and is not retired by a disappearing async view. Expired +reports are not revived, and another pane's view cannot supply attention. + +This follows visible attention, not semantic proof of an answer. Native skipping, +queued answers, follow-up input, turn completion or auto-resolution can clear the +view. Quoted history above the composer, clipped views and customized shortcut +labels produce no guess. The existing dirty-viewport scan supplies the bounded +delay; no new hook, question-content store or idle polling is added. Exact-version +[fixture provenance](../tests/fixtures/codex-questions/README.md) and controlled +PTY tests are separate from native CLI acceptance. ### Duplicate Codex hook sources diff --git a/docs/HARNESS.md b/docs/HARNESS.md index d42231b8..753a640c 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -376,15 +376,50 @@ Real-CLI native question and quiet-after-answer checks passed through the automated TUI harness with already trusted hooks. Fresh hook trust onboarding remains a separate acceptance case. -`request_user_input_async` is a separate question path. Its immediate completion -acknowledges posting a question; it does not establish that the user answered. -These native question hooks do not instrument that path. The -[`0.160.1` async handler](https://github.com/openai/codex/blob/rust-v0.160.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs) -posts an async message and immediately returns `accepted`; the -[TUI reply parser](https://github.com/openai/codex/blob/rust-v0.160.1/codex-rs/tui/src/async_question_reply.rs) -handles the answer as a later user message. A blocked latch tied to the tool's -completion would misrepresent an agent that continues working. Async-answer -lifecycle coverage remains open in A3. +`request_user_input_async` posts a question and returns immediately; its tool +completion does not establish that the user answered. The +[`0.162.1` async handler](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs) +and [question editor](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/async_questions/state.rs) +keep posting, local answer delivery, skipping and native resolution separate. + +spyc recognizes the default native question editor's complete submit/skip/navigation +footer, or its collapsed positive question count and answer hint, at the current +viewport bottom. The dot reads `blocked` with `awaiting async question answer`; +this means the tab needs you, even while Codex continues working. `:why-status` +and `:activity dump` label the source `scrape-fallback`. The latest semantic report +remains stored and resumes when the native view clears. An independent semantic +block keeps precedence and is not retired by a disappearing async view. Expired +reports are not revived, and another pane's view cannot supply attention. + +This follows visible attention, not semantic proof of an answer. Native skipping, +queued answers, follow-up input, turn completion or auto-resolution can clear the +view. Quoted history above the composer, clipped views and customized shortcut +labels produce no guess. The existing dirty-viewport scan supplies the bounded +delay; no new hook, question-content store or idle polling is added. Exact-version +[fixture provenance](../tests/fixtures/codex-questions/README.md) and controlled +PTY tests are separate from native CLI acceptance. + +For an async-attention test, stay in Default mode and ask Codex to post two +`request_user_input_async` questions, then continue with a quiet `sleep 30`. +While the complete question view is visible, check the red dot and +`awaiting async question answer` in `:why-status`. Collapse and reopen the default +question editor; answer or skip one question and check the remaining one still +needs attention. After the last native view clears, the latest live semantic +status resumes. Repeat in two tabs: clearing one leaves the other waiting. +A customized/clipped editor or native auto-resolution is a separate case; no +semantic answer claim is made from a disappearing view. + +The controlled terminal replay uses exact-version fixtures and pane-bound MCP +reports, not native Codex or hook trust: + +```sh +python3 scripts/codex-async-question-smoke.py --binary /absolute/path/to/spyc --output /tmp/spyc-async-question-run +``` + +It checks expanded/collapsed views, a semantic report transition without a child +repaint, two-pane isolation, restoration of working/done and zero idle draws. +Every run uses a new named Ghostty session and private home/state, closes only +that session, and saves the binary hash, captures and result. For a native-hook test, restart Codex after any restart-needed diagnostic and review the new hooks in `/hooks`. Enter `/plan` as an actual CLI slash command diff --git a/docs/drafts/CODEX_SUPPORT_PLAN.md b/docs/drafts/CODEX_SUPPORT_PLAN.md index 1cac3812..cb91ddff 100644 --- a/docs/drafts/CODEX_SUPPORT_PLAN.md +++ b/docs/drafts/CODEX_SUPPORT_PLAN.md @@ -1,9 +1,10 @@ # Codex Support and Session HUD Plan -**Status:** Track A implementation in progress, delivered through local-build -acceptance before integration. The new HUD is proposed follow-up scope; its -design review and telemetry spike remain open. This document does not change -the accepted release gates. +**Status:** The core A1–A6 changes and B1–B3 HUD delivery have shipped. Deferred +native acceptance and remaining A3 onboarding/attention cases stay open. +Async-question visible attention is implemented with native acceptance pending; +semantic async-answer events and additional approval forms remain separate gaps. +Historical assessments below preserve their original evidence and CLI versions. **Evidence date:** 2026-10-05. Assessment of the release-candidate code and current `main`, against the locally installed Codex CLI 0.160.0. These are historical @@ -636,6 +637,22 @@ Two tracks keep the RC manageable: against the old implementation; final artifact acceptance is recorded with the PR. Native hook execution remains separate from these file-edit tests. +### Async-question visible attention + +The visible-attention follow-up recognizes Codex `0.162.1`'s complete default async-question +editor and collapsed summary through the existing bounded dirty-viewport scan. +An unanswered visible view requires attention while preserving the latest semantic +working/done report. Answering, skipping, queuing or native resolution retires +only the fallback when the view clears; independent semantic blocks and report +expiry remain intact. No new hook or content-bearing lifecycle store is added. + +Exact-version upstream snapshots supply the fixtures. Pure scanner and real-PTY +App regressions reproduce missing attention and a semantic transition discarding +an unanswered view. Native CLI capture was attempted twice, but its filesystem +sandbox helper failed during startup before a question could be shown. Native +acceptance remains distinct from fixture/replay coverage. Fresh trust onboarding, +semantic async-answer events and other approval forms remain A3 follow-ups. + ### Baseline The shared foundation already works: MCP context/search/git tools are attributed diff --git a/scripts/codex-async-question-smoke.py b/scripts/codex-async-question-smoke.py new file mode 100644 index 00000000..ae0ff259 --- /dev/null +++ b/scripts/codex-async-question-smoke.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Replay exact-version Codex question views through real spyc panes and MCP. + +The children are controlled PTY fixtures, not native Codex or trusted hooks. +Every run uses a new named Ghostty session, private home and private state. +""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shlex +import socket +import subprocess +import sys +import time +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parent.parent + +def report(metadata, status): + """Report only to a pane belonging to this run's isolated spyc.""" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(10) + connection.connect(metadata["socket"]) + stream = connection.makefile("rb") + def rpc(method, params, ident): + body = json.dumps({"jsonrpc":"2.0","id":ident,"method":method,"params":params}).encode() + connection.sendall(f"Content-Length: {len(body)}\r\n\r\n".encode() + body) + while True: + headers = {} + while True: + line = stream.readline() + if not line: + raise AssertionError("isolated spyc closed its MCP connection") + if line in (b"\r\n", b"\n"): + break + key, value = line.decode().split(":",1) + headers[key.lower()] = value.strip() + answer = json.loads(stream.read(int(headers["content-length"]))) + if answer.get("id") == ident: + return answer + rpc("initialize", {"protocolVersion":"2024-11-05","capabilities":{}, + "clientInfo":{"name":"async-question replay","version":"1"}, + "_meta":{"spyc/paneId":metadata["pane_id"]}}, 1) + response = rpc("tools/call", {"name":"report_status","arguments":{ + "status":status,"pane_id":metadata["pane_id"],"ttl_ms":120000}}, 2) + assert not response.get("error") and not response.get("result",{}).get("isError"), response + +def run(smoke, binary): + output = smoke.output + # macOS Unix socket paths must fit even when evidence is saved in a deep path. + smoke.runtime = tempfile.TemporaryDirectory(prefix="spyc-aq-",dir="/tmp") + runtime = Path(smoke.runtime.name) + home = runtime/"home"; home.mkdir() + project = runtime/"project"; project.mkdir() + fakebin = runtime/"bin"; fakebin.mkdir() + (home/".spycrc.toml").write_text('[pane]\nagent_hud="off"\ncodex_mcp=false\n[mouse]\ncapture=false\n') + fixtures = ROOT/"tests/fixtures/codex-questions" + agent = runtime/"fixture.py" + agent.write_text( + "import sys,os,json,termios\nfrom pathlib import Path\n" + "output=Path(sys.argv[1]);fixtures=Path(sys.argv[2]);number=next(arg for arg in sys.argv[3:] if arg in ['1','2'])\n" + "attrs=termios.tcgetattr(0);attrs[3]&=~termios.ECHO;termios.tcsetattr(0,termios.TCSANOW,attrs)\n" + "(output/('pane-'+number+'.json')).write_text(json.dumps({'socket':os.environ['SPYC_MCP_SOCK'],'pane_id':os.environ['SPYC_PANE_ID']}))\n" + "print('READY_'+number,flush=True)\n" + "for line in sys.stdin:\n" + " command=line.strip()\n" + " if command in ['OPEN','COLLAPSE']:\n" + " text=(fixtures/('expanded-options.txt' if command=='OPEN' else 'collapsed.txt')).read_text()\n" + " elif command=='ANSWER':text='ANSWERED_'+number+'\\n\\n› Ask Codex to do anything\\n\\n100% context left | Vim: Insert\\n'\n" + " else:continue\n" + " print('\\x1b[2J\\x1b[H'+text.replace('\\n','\\r\\n'),end='',flush=True)\n") + launcher = fakebin/"codex" + launcher.write_text("#!/bin/sh\nexec "+shlex.join([sys.executable,str(agent),str(output),str(fixtures)])+' "$@"\n') + launcher.chmod(0o700) + sessions = json.loads(subprocess.check_output(["tui-test","--json","sessions"],text=True))["sessions"] + assert smoke.args.session not in sessions + smoke.cli("run","--backend","ghostty","--cols","200","--rows","60","--cwd",str(project), + "--env","HOME="+str(home),"--env","CODEX_HOME="+str(home/".codex"), + "--env","XDG_STATE_HOME="+str(runtime/"state"), + "--env","SHELL=/bin/sh","--env","PATH="+str(binary.parent)+os.pathsep+os.environ["PATH"], + "--env","SPYC_MCP_SOCK=","--env","SPYC_PANE_ID=",str(binary),"--no-lua") + smoke.started = True + smoke.cli("record","start",str(output/"session.cast"),"--format","cast") + smoke.wait("🌶️",15000) + def open_pane(number): + smoke.key("Ctrl+a","c");smoke.wait("pane command:",5000);smoke.key("Ctrl+u") + smoke.cli("type",shlex.join([str(launcher),str(number)]));smoke.key("Enter") + smoke.wait("pane cwd:",5000);smoke.key("Enter");smoke.wait("READY_"+str(number),10000) + if "spyc — agent status" in smoke.capture("startup-"+str(number)): + smoke.key("n") + return json.loads((output/("pane-"+str(number)+".json")).read_text()) + def send(command, expected): + smoke.cli("type",command);smoke.key("Enter");smoke.wait(expected,5000) + def attention(name, pane=1): + dump = smoke.wait_dump(name,"blocked",pane=pane) + view = smoke.pane(dump,pane) + assert "source: SCRAPE-FALLBACK status=blocked (awaiting async question answer)" in view, dump + return dump + first = open_pane(1);report(first,"working") + send("OPEN","⌃] skip");attention("expanded") + report(first,"blocked") + semantic = smoke.wait_dump("semantic-block","blocked",pane=1) + assert "source: SELF-REPORT status=blocked" in smoke.pane(semantic,1) + report(first,"working");attention("working-without-repaint") + send("COLLAPSE","to answer");attention("collapsed") + second = open_pane(2);report(second,"working") + send("OPEN","⌃] skip");both = attention("both-open",2) + assert "dot=blocked" in smoke.pane(both,1) + assert first["pane_id"] != second["pane_id"] + send("ANSWER","ANSWERED_2") + returned = smoke.wait_dump("second-answered","working",pane=2) + assert "source: SELF-REPORT status=working" in smoke.pane(returned,2) + assert "dot=blocked" in smoke.pane(returned,1) + report(second,"done") + smoke.key("Ctrl+a","1");attention("first-still-open") + # The native view owns retirement; a newer semantic done stays underneath. + report(first,"done");attention("done-under-question") + smoke.key("Ctrl+a","k");smoke.cli("type",":activity transparent");smoke.key("Enter") + smoke.wait(" dps ",5000);time.sleep(7) + idle = smoke.capture("idle") + assert re.search(r"\b0 dps\b",idle),idle + smoke.cli("type",":activity");smoke.key("Enter");smoke.key("Ctrl+a","j") + send("ANSWER","ANSWERED_1") + done = smoke.wait_dump("first-answered","done",pane=1) + assert "source: SELF-REPORT status=done" in smoke.pane(done,1) + return {"idle_draws_per_second":0,"panes":2} + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary",type=Path,required=True) + parser.add_argument("--output",type=Path,required=True) + parser.add_argument("--session",default="spyc-async-"+uuid.uuid4().hex[:10]) + args = parser.parse_args() + spec = importlib.util.spec_from_file_location("smoke",ROOT/"scripts/codex-tui-smoke.py") + module = importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + smoke = module.Smoke(args) + binary = args.binary.resolve() + result = {"passed":False,"coverage":"real spyc/Ghostty, controlled PTY views and pane-bound MCP; not native CLI/hooks", + "binary":str(binary),"binary_sha256":hashlib.sha256(binary.read_bytes()).hexdigest(), + "version":subprocess.check_output([str(binary),"--version"],text=True).strip()} + try: + result.update(run(smoke,binary));result["passed"]=True + except Exception as error: + result["error"]=str(error) + if smoke.started: + smoke.capture("failure") + print(str(error),file=sys.stderr,flush=True) + finally: + if smoke.started: + smoke.cli("record","stop",check=False);smoke.cli("close",check=False) + if getattr(smoke,"runtime",None): + smoke.runtime.cleanup() + (smoke.output/"result.json").write_text(json.dumps(result,indent=2)+"\n") + print(f"{'PASS' if result['passed'] else 'FAIL'}: {smoke.output}",flush=True) + return 0 if result["passed"] else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agent/codex_approval.rs b/src/agent/codex_approval.rs index f9e1f59c..34d18303 100644 --- a/src/agent/codex_approval.rs +++ b/src/agent/codex_approval.rs @@ -1,4 +1,4 @@ -//! Human-approval fallback for Codex's uncorrelated permission hook. +//! Visible human waits in Codex: approval modals and async-question views. use super::detect_rules::{DetectionRule, Matcher, Region}; use crate::pane::{AgentActivity, ReportedStatus}; @@ -7,7 +7,8 @@ use crate::state::sessions::AgentKind; /// Recorded Codex command, file-edit, MCP-tool and network approval forms: /// required choices and footer end the viewport. Permission hooks also run /// before automatic review, so only the visible form establishes a human wait. -/// Missing required text or changed forms produce no guess. +/// Async questions use their own complete native view. Missing required text or +/// changed forms produce no guess. pub const RULES: &[DetectionRule] = &[ DetectionRule { // Input is the current viewport, already bounded by the terminal geometry. @@ -65,11 +66,17 @@ pub const RULES: &[DetectionRule] = &[ state: AgentActivity::Blocked, visible_blocker: Some("awaiting network approval"), }, + DetectionRule { + region: Region::BottomNonEmptyLines(usize::MAX), + matcher: Matcher::CodexAsyncQuestions, + state: AgentActivity::Blocked, + visible_blocker: Some("awaiting async question answer"), + }, ]; -/// The modal takes precedence over a non-blocked report without discarding it: -/// after approval the silent-work report resumes. Identified questions and -/// explicit agent blocks remain authoritative until their own recovery. +/// A visible wait takes precedence over a non-blocked report without discarding +/// it: after the view clears the latest live report resumes. Identified questions +/// and explicit agent blocks remain authoritative until their own recovery. pub fn overrides_report( reported: Option<&ReportedStatus>, scrape: Option, diff --git a/src/agent/codex_questions.rs b/src/agent/codex_questions.rs new file mode 100644 index 00000000..6c7c38da --- /dev/null +++ b/src/agent/codex_questions.rs @@ -0,0 +1,215 @@ +//! Recognize Codex's unanswered async-question views at the viewport bottom. + +/// Default-keymap collapsed summaries and complete question-editor footers. +/// A count or a quoted footer alone is insufficient. Clipped/customized forms +/// fail closed; the caller supplies only the current, geometry-bounded viewport. +pub(super) fn matches(text: &str) -> bool { + let rows: Vec<_> = text + .lines() + .map(str::trim) + .filter(|row| !row.is_empty()) + .collect(); + if rows.last() == Some(&"⇧← to answer") { + return rows.len() >= 3 + && rows[..rows.len() - 2].contains(&"• Queued follow-up inputs") + && summary(rows[rows.len() - 2]); + } + let mut suffix = String::new(); + for start in (0..rows.len()).rev().take(5) { + suffix = if suffix.is_empty() { + rows[start].to_owned() + } else { + format!("{} {suffix}", rows[start]) + }; + let normalized = suffix.split_whitespace().collect::>().join(" "); + if start > 0 && footer(&normalized) { + return true; + } + } + false +} + +fn summary(text: &str) -> bool { + let (count, countdown) = text + .split_once(" · ") + .map_or((text, None), |(a, b)| (a, Some(b))); + if countdown.is_some_and(|value| { + value + .strip_suffix('s') + .and_then(positive) + .is_none_or(|seconds| seconds > 20) + }) { + return false; + } + let Some(count) = count.strip_prefix("? ") else { + return false; + }; + let Some((number, noun)) = count.split_once(' ') else { + return false; + }; + positive(number).is_some_and(|n| noun == if n == 1 { "question" } else { "questions" }) +} + +fn positive(text: &str) -> Option { + (!text.starts_with('0') && !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| text.parse().ok()) + .flatten() +} + +fn footer(text: &str) -> bool { + let Some(mut tail) = text.strip_prefix("enter submit ⌃] skip ") else { + return false; + }; + if let Some(options) = tail.strip_prefix("option ") { + let Some((position, rest)) = options.split_once(' ') else { + return false; + }; + let Some((current, total)) = position.split_once('/') else { + return false; + }; + if positive(current) + .zip(positive(total)) + .is_none_or(|(n, total)| n > total) + { + return false; + } + tail = rest; + } + ["⇧→ main prompt", "⇧→ prev question"] + .iter() + .any(|navigation| { + tail.strip_prefix(navigation).is_some_and(|remaining| { + matches!(remaining, "" | " ⇧← next question" | " ⇧← queued messages") + }) + }) +} + +#[cfg(test)] +mod tests { + use crate::agent::{detect_rules, profile_for}; + use crate::pane::AgentActivity; + use crate::state::sessions::AgentKind; + + fn scan(text: &str) -> Option<(AgentActivity, Option<&'static str>)> { + detect_rules::scan( + &text.lines().map(String::from).collect::>(), + profile_for(AgentKind::Codex).detection_rules(), + ) + } + + #[test] + fn codex_questions_recorded_expanded_and_collapsed_views_need_attention() { + for text in [ + include_str!("../../tests/fixtures/codex-questions/expanded-options.txt"), + include_str!("../../tests/fixtures/codex-questions/expanded-freeform.txt"), + include_str!("../../tests/fixtures/codex-questions/expanded-wrapped.txt"), + include_str!("../../tests/fixtures/codex-questions/collapsed.txt"), + ] { + assert_eq!( + scan(text), + Some(( + AgentActivity::Blocked, + Some("awaiting async question answer") + )), + "{text}" + ); + } + } + + #[test] + fn codex_questions_quoted_clipped_and_customized_footers_are_not_current_waits() { + let expanded = include_str!("../../tests/fixtures/codex-questions/expanded-options.txt"); + let collapsed = include_str!("../../tests/fixtures/codex-questions/collapsed.txt"); + for text in [ + format!("{expanded}\n› Ask Codex to do anything\n100% context left | Vim: Insert"), + format!("{collapsed}\n› An unfinished prompt\n? for shortcuts"), + expanded.replace("⌃] skip", ""), + expanded.replace("enter submit", "enter submi…"), + expanded.replace("⇧→ prev question", "shift-right back"), + expanded.replace("enter submit", "Quoted: enter submit"), + collapsed.replace("? 2 questions", "? 0 questions"), + collapsed.replace("? 2 questions", "? 2 question"), + collapsed.replace("? 2 questions", "Questions: ? 2 questions"), + collapsed.replace("⇧← to answer", "⇧← to answe…"), + collapsed.replace("⇧← to answer", "Other footer: ⇧← to answer"), + "enter submit ⌃] skip ⇧→ main prompt".into(), + include_str!("../../tests/fixtures/codex-questions/expanded-clipped.txt").into(), + ] { + assert_eq!(scan(&text), None, "{text}"); + } + } + + #[test] + fn codex_questions_collapsed_counts_and_native_countdown_have_a_complete_shape() { + for count in [1, 2, 64] { + let noun = if count == 1 { "question" } else { "questions" }; + for countdown in ["", " · 20s", " · 1s"] { + let text = format!( + "• Queued follow-up inputs\n ? {count} {noun}{countdown}\n ⇧← to answer\n" + ); + assert_eq!( + scan(&text), + Some(( + AgentActivity::Blocked, + Some("awaiting async question answer") + )) + ); + } + } + for summary in [ + "? 00 questions", + "? -1 questions", + "? 2 questions · 0s", + "? 2 questions · soon", + "? 2 questions unrelated", + "? 99999999999999999999 questions", + ] { + assert_eq!( + scan(&format!( + "• Queued follow-up inputs\n{summary}\n⇧← to answer" + )), + None, + "{summary}" + ); + } + } + + #[test] + fn codex_questions_option_scroll_hints_follow_the_native_footer_order() { + for footer in [ + "enter submit ⌃] skip option 2/4 ⇧→ main prompt", + "enter submit ⌃] skip\noption 2/4 ⇧→ prev question\n⇧← next question", + "enter submit ⌃] skip ⇧→ main prompt ⇧← queued messages", + ] { + assert_eq!( + scan(&format!("Which colour?\n› 2. Blue\n{footer}")), + Some(( + AgentActivity::Blocked, + Some("awaiting async question answer") + )) + ); + } + for footer in [ + "⌃] skip enter submit ⇧→ main prompt", + "enter submit ⌃] skip option 0/4 ⇧→ main prompt", + "enter submit ⌃] skip option 5/4 ⇧→ main prompt", + "enter submit ⌃] skip ⇧→ main prompt extra", + ] { + assert_eq!(scan(&format!("Which colour?\n{footer}")), None, "{footer}"); + } + } + + #[test] + fn codex_questions_other_agents_do_not_inherit_the_fallback() { + let lines = include_str!("../../tests/fixtures/codex-questions/expanded-options.txt") + .lines() + .map(String::from) + .collect::>(); + for kind in [AgentKind::Claude, AgentKind::Agy, AgentKind::Other] { + assert_eq!( + detect_rules::scan(&lines, profile_for(kind).detection_rules()), + None + ); + } + } +} diff --git a/src/agent/detect_rules.rs b/src/agent/detect_rules.rs index fd48f47a..00e9fe74 100644 --- a/src/agent/detect_rules.rs +++ b/src/agent/detect_rules.rs @@ -36,6 +36,8 @@ pub enum Matcher { /// discussing permissions, and a false red "needs me" square is worse than /// no detection at all. All(&'static [&'static str]), + /// Codex's verified collapsed summary or complete async-question footer. + CodexAsyncQuestions, /// Require the full conjunction and an exact footer at the viewport bottom. /// Native word wrapping may split phrases or the footer across rows. A /// quoted dialogue above the normal composer is not an open modal. @@ -49,6 +51,7 @@ impl Matcher { fn matches(self, haystack: &str) -> bool { match self { Self::All(needles) => needles.iter().all(|n| haystack.contains(n)), + Self::CodexAsyncQuestions => super::codex_questions::matches(haystack), Self::AllAtBottom { needles, last_line } => { if !footer_matches(haystack, last_line) { return false; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 155195ed..bc246152 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -19,6 +19,7 @@ pub mod codex_approval; pub mod codex_command; pub mod codex_home; pub mod codex_metrics; +pub mod codex_questions; pub mod codex_records; pub mod codex_recovery; pub mod detect_rules; diff --git a/src/app/agent_status.rs b/src/app/agent_status.rs index d464011d..182bc84e 100644 --- a/src/app/agent_status.rs +++ b/src/app/agent_status.rs @@ -625,12 +625,12 @@ impl App { { entry.info.reported = None; } - // Keep Codex's visible approval while preserving its silent-work - // report. Other live reports discard guesses so they cannot - // resurface stale after expiry. + // Retain Codex's current visible wait even behind a semantic block: + // a later working/done report need not repaint the unanswered view. + // Other agents discard guesses behind live reports. if entry.info.reported.is_some() && !crate::agent::codex_approval::overrides_report( - entry.info.reported.as_ref(), + None, entry.info.scrape_status.map(|(status, _)| status), kind, ) diff --git a/src/app/harness_tests/codex_questions.rs b/src/app/harness_tests/codex_questions.rs new file mode 100644 index 00000000..abeac166 --- /dev/null +++ b/src/app/harness_tests/codex_questions.rs @@ -0,0 +1,289 @@ +use super::agent_readiness::{dump, why_status}; +use super::*; +use crate::mcp_cmd::{McpCommand, McpResponse}; +use crate::pane::AgentActivity; +use std::time::{Duration, Instant}; + +const OPEN: &str = include_str!("../../../tests/fixtures/codex-questions/expanded-options.txt"); +const COLLAPSED: &str = include_str!("../../../tests/fixtures/codex-questions/collapsed.txt"); +const CLEAR: &str = "ANSWERED\n\n› Ask Codex to do anything\n\n100% context left | Vim: Insert"; + +fn add_pane(app: &mut App, dir: &std::path::Path) { + assert!(app.open_pane_tab_in("sh -c 'stty raw -echo; printf READY; exec cat'", dir)); + wait_screen(app, "READY"); + let entry = app + .runtime + .pane_tabs + .as_mut() + .unwrap() + .tabs_mut() + .last_mut() + .unwrap(); + entry.info.command = "codex".into(); + entry.pane.resize(30, 100).unwrap(); +} + +fn wait_screen(app: &mut App, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let pane = app.runtime.pane_tabs.as_mut().unwrap().active_mut(); + pane.drain_output(); + let visible = pane.visible_lines().join("\n"); + if visible.contains(expected) { + return; + } + assert!(Instant::now() < deadline, "missing {expected}: {visible}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn paint(app: &mut App, text: &str, expected: &str) { + app.runtime + .pane_tabs + .as_mut() + .unwrap() + .active_mut() + .send_bytes(format!("\x1b[2J\x1b[H{}", text.replace('\n', "\r\n")).as_bytes()) + .unwrap(); + wait_screen(app, expected); +} + +fn report(app: &mut App, status: &str) { + let result = app.execute_mcp_command(McpCommand::ReportStatus { + pane_id: None, + pane: None, + status: status.into(), + ttl_ms: Some(120_000), + note: None, + session_id: None, + hook_event: None, + }); + assert!(matches!(result, McpResponse::Ok { .. }), "{result:?}"); +} + +fn settle(app: &mut App, base: Instant) -> Vec { + let mut ctx = RunCtx::for_test(); + for elapsed in [0, 100, 200, 300] { + let now = base + Duration::from_millis(elapsed); + let info = app.runtime.pane_tabs.as_mut().unwrap().active_info_mut(); + info.last_output_at = Some(now); + info.scrape_dirty = true; + app.settle_scrape_quiet(now, &mut ctx); + } + app.settle_agent_activity(base + Duration::from_millis(300), &mut ctx) + .1 +} + +#[test] +fn codex_questions_native_view_requires_attention_during_work_and_retains_the_report() { + let tmp = tempfile::tempdir().unwrap(); + crate::state::with_state_root(tmp.path(), || { + let mut app = App::test_app(tmp.path().to_path_buf()); + add_pane(&mut app, tmp.path()); + report(&mut app, "working"); + let original = app + .runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .reported + .clone() + .unwrap(); + app.state.focus = state::Focus::FileList; + paint(&mut app, OPEN, "enter submit"); + let effects = settle(&mut app, Instant::now()); + let info = app.runtime.pane_tabs.as_ref().unwrap().active_info(); + assert_eq!( + info.activity, + AgentActivity::Blocked, + "continued redraws must not hide a question" + ); + assert_eq!(info.reported.as_ref().unwrap().at, original.at); + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Notify { .. })) + ); + assert!( + dump(&mut app).contains( + "source: SCRAPE-FALLBACK status=blocked (awaiting async question answer)" + ) + ); + assert!(why_status(&mut app).contains("awaiting async question answer")); + paint(&mut app, COLLAPSED, "to answer"); + settle(&mut app, Instant::now()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Blocked + ); + paint(&mut app, CLEAR, "ANSWERED"); + settle(&mut app, Instant::now()); + let info = app.runtime.pane_tabs.as_ref().unwrap().active_info(); + assert_eq!(info.activity, AgentActivity::Working); + assert_eq!(info.reported.as_ref().unwrap().at, original.at); + }); +} + +#[test] +fn codex_questions_latest_done_report_resumes_after_the_native_view_clears() { + let tmp = tempfile::tempdir().unwrap(); + crate::state::with_state_root(tmp.path(), || { + let mut app = App::test_app(tmp.path().to_path_buf()); + add_pane(&mut app, tmp.path()); + paint(&mut app, OPEN, "enter submit"); + report(&mut app, "working"); + settle(&mut app, Instant::now()); + report(&mut app, "done"); + settle(&mut app, Instant::now()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Blocked + ); + paint(&mut app, CLEAR, "ANSWERED"); + settle(&mut app, Instant::now()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Done + ); + }); +} + +#[test] +fn codex_questions_each_pane_keeps_its_own_unanswered_view() { + let tmp = tempfile::tempdir().unwrap(); + crate::state::with_state_root(tmp.path(), || { + let mut app = App::test_app(tmp.path().to_path_buf()); + add_pane(&mut app, tmp.path()); + report(&mut app, "working"); + paint(&mut app, OPEN, "enter submit"); + settle(&mut app, Instant::now()); + add_pane(&mut app, tmp.path()); + report(&mut app, "working"); + paint(&mut app, OPEN, "enter submit"); + settle(&mut app, Instant::now()); + assert!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .tabs() + .iter() + .all(|tab| tab.info.activity == AgentActivity::Blocked) + ); + paint(&mut app, CLEAR, "ANSWERED"); + settle(&mut app, Instant::now()); + let tabs = app.runtime.pane_tabs.as_ref().unwrap().tabs(); + assert_eq!(tabs[0].info.activity, AgentActivity::Blocked); + assert_eq!(tabs[1].info.activity, AgentActivity::Working); + assert_ne!(tabs[0].info.id, tabs[1].info.id); + }); +} + +#[test] +fn codex_questions_a_semantic_block_cannot_erase_the_unanswered_view() { + let tmp = tempfile::tempdir().unwrap(); + crate::state::with_state_root(tmp.path(), || { + let mut app = App::test_app(tmp.path().to_path_buf()); + add_pane(&mut app, tmp.path()); + report(&mut app, "working"); + paint(&mut app, OPEN, "enter submit"); + settle(&mut app, Instant::now()); + report(&mut app, "blocked"); + app.settle_agent_activity(Instant::now(), &mut RunCtx::for_test()); + assert!(dump(&mut app).contains("source: SELF-REPORT status=blocked")); + report(&mut app, "working"); + app.settle_agent_activity(Instant::now(), &mut RunCtx::for_test()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Blocked, + "the same unanswered question must remain visible without another child repaint" + ); + assert!(dump(&mut app).contains("awaiting async question answer")); + report(&mut app, "blocked"); + paint(&mut app, CLEAR, "ANSWERED"); + settle(&mut app, Instant::now()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Blocked, + "clearing the view must not retire an independent semantic block" + ); + }); +} + +#[test] +fn codex_questions_expired_reports_do_not_become_working_after_the_view_clears() { + let tmp = tempfile::tempdir().unwrap(); + crate::state::with_state_root(tmp.path(), || { + let mut app = App::test_app(tmp.path().to_path_buf()); + add_pane(&mut app, tmp.path()); + let now = Instant::now(); + app.runtime + .pane_tabs + .as_mut() + .unwrap() + .active_info_mut() + .reported = Some(crate::pane::ReportedStatus::new( + AgentActivity::Working, + now, + now + Duration::from_secs(30), + )); + paint(&mut app, OPEN, "enter submit"); + settle(&mut app, now + Duration::from_secs(90)); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Blocked + ); + assert!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .reported + .is_none() + ); + paint(&mut app, CLEAR, "ANSWERED"); + settle(&mut app, now + Duration::from_secs(90)); + app.settle_agent_activity(now + Duration::from_secs(100), &mut RunCtx::for_test()); + assert_eq!( + app.runtime + .pane_tabs + .as_ref() + .unwrap() + .active_info() + .activity, + AgentActivity::Idle + ); + }); +} diff --git a/src/app/harness_tests/mod.rs b/src/app/harness_tests/mod.rs index f8f3e45e..8e98f36c 100644 --- a/src/app/harness_tests/mod.rs +++ b/src/app/harness_tests/mod.rs @@ -469,6 +469,7 @@ mod agent_waiting; mod archive; mod codex_home; mod codex_images; +mod codex_questions; mod focused_column; mod history_editor; mod jump_default; diff --git a/tests/fixtures/codex-questions/README.md b/tests/fixtures/codex-questions/README.md new file mode 100644 index 00000000..35d1c163 --- /dev/null +++ b/tests/fixtures/codex-questions/README.md @@ -0,0 +1,24 @@ +# Codex async-question fixtures + +These small text fixtures are the rendered body of snapshots from the upstream +`rust-v0.162.1` tag. Their prompts are upstream test data, not conversation +captures. Snapshot metadata headings are omitted; `collapsed.txt` keeps the +`ONLY QUESTIONS` section. No real account, home, session or answer is recorded. + +| Fixture | Upstream snapshot | +|---|---| +| `expanded-options.txt` | [last question with queue](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__last_question_with_queue.snap) | +| `expanded-freeform.txt` | [single question while working](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__single_question_working_spacing.snap) | +| `collapsed.txt` | [questions with status and queue](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__questions_with_status_and_queue.snap) | +| `expanded-wrapped.txt` | [wrapped other option](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap) | +| `expanded-clipped.txt` | [clipped narrow view](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap) | + +[Summary rendering](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/questions.rs) +supplies the positive count, optional countdown and answer hint. +[Editor rendering](https://github.com/openai/codex/blob/rust-v0.162.1/codex-rs/tui/src/bottom_pane/async_questions/render.rs) +supplies the ordered submit/skip/option/navigation footer. + +Countdown, invalid-count, quoted-history and customized-key variants in the Rust +tests are explicitly synthetic derivatives. The scanner accepts only the current +complete default-keymap view. A cancelled or cleared native view is not proof of +an answered question, and no tool-post completion is used as answer evidence. diff --git a/tests/fixtures/codex-questions/collapsed.txt b/tests/fixtures/codex-questions/collapsed.txt new file mode 100644 index 00000000..edbbed2c --- /dev/null +++ b/tests/fixtures/codex-questions/collapsed.txt @@ -0,0 +1,3 @@ +• Queued follow-up inputs + ? 2 questions + ⇧← to answer diff --git a/tests/fixtures/codex-questions/expanded-clipped.txt b/tests/fixtures/codex-questions/expanded-clipped.txt new file mode 100644 index 00000000..35cd416b --- /dev/null +++ b/tests/fixtures/codex-questions/expanded-clipped.txt @@ -0,0 +1,7 @@ + + 2 of + Secon + d + › x + ente… + ⌃]… diff --git a/tests/fixtures/codex-questions/expanded-freeform.txt b/tests/fixtures/codex-questions/expanded-freeform.txt new file mode 100644 index 00000000..2a2b83cf --- /dev/null +++ b/tests/fixtures/codex-questions/expanded-freeform.txt @@ -0,0 +1,9 @@ +• Working (0s • esc to interrupt) + +• Queued follow-up inputs + + Only question? + + A typed answer + + enter submit ⌃] skip ⇧→ main prompt diff --git a/tests/fixtures/codex-questions/expanded-options.txt b/tests/fixtures/codex-questions/expanded-options.txt new file mode 100644 index 00000000..9479200a --- /dev/null +++ b/tests/fixtures/codex-questions/expanded-options.txt @@ -0,0 +1,11 @@ +• Queued follow-up inputs + ↳ older + ↳ newer + + 2 of 2 + Second? + + › 1. Next + 2. Other + + enter submit ⌃] skip ⇧→ prev question ⇧← queued messages diff --git a/tests/fixtures/codex-questions/expanded-wrapped.txt b/tests/fixtures/codex-questions/expanded-wrapped.txt new file mode 100644 index 00000000..4b7d0273 --- /dev/null +++ b/tests/fixtures/codex-questions/expanded-wrapped.txt @@ -0,0 +1,12 @@ + + Second + + 1. A suggested answer that is + long enough to wrap across + multiple rows + › 2. An alternative answer that + wraps across several option + rows + + enter submit ⌃] skip + ⇧→ main prompt