From 9ec05694016b60475b74672b0bcafc9258065ca0 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 25 Sep 2026 16:02:39 +0000 Subject: [PATCH] fix(issue-30): review ad3d8206 - a prompt glyph inside a draft is draft text; the R4 regression test tells the defect apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - input_region: in the prompt-line shape the input box starts only at a column-0 prompt (codex indents a draft's continuation lines), and the prompt and the (whole-line) placeholder are stripped from the box's first non-blank line only, so `› Explain this terminal prompt:\n ›` is not empty. - test_r4 runs with S=40 again: the defective code nudged ~30 s after a missed transition, which S=10 could not tell from the fixed ~10 s. Both new checks fail against the defective versions (stall_watch.py at 47e4274 -> 30.24 s < 40; input_region.py at bf0f5c1 -> nudge sent) and pass now, each with its passing twin. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/agents/input_region.py | 28 +++++++----- tests/test_issue30_stall_watch.py | 46 +++++++++++++++++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.agents/skills/lib_py/agents/input_region.py b/.agents/skills/lib_py/agents/input_region.py index 55e16ce..b44b723 100644 --- a/.agents/skills/lib_py/agents/input_region.py +++ b/.agents/skills/lib_py/agents/input_region.py @@ -11,12 +11,13 @@ def extract_input_region(pane_text: str, agent_name: str) -> str: 1. an `input_block_prefix` -> the last contiguous block of lines starting with it, prefix removed, cut at the first `input_stop_pattern` line; 2. two or more `input_rule_pattern` rules -> the lines between the last two; - 3. an `input_prompt` -> from the last line starting with the prompt to + 3. an `input_prompt` -> from the last line starting (column 0) with the prompt to the end of the pane, minus a last non-blank line matching `input_stop_pattern` (the status footer). Every other line below the prompt line is input: a multi-line draft is never cut to its first line. - Then `input_frame_chars`, the prompt and the placeholder are stripped. There + Then `input_frame_chars` are stripped, and the prompt and the placeholder + (whole-line) are stripped from the box's first non-blank line only. There is no whole-pane fallback: a pane with none of these shapes has no input region and yields a non-empty "unknown" result rather than a guess. Raises ValueError for agents without input area facts (like hermes' peers). @@ -52,7 +53,10 @@ def extract_input_region(pane_text: str, agent_name: str) -> str: region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]] if region_lines is None: - starts = [i for i, line in enumerate(lines) if line.lstrip().startswith(prompt)] + # The composer's prompt sits at column 0; a draft's continuation lines + # are indented, so an indented prompt character is draft text (review + # ad3d8206), never the start of the input box. + starts = [i for i, line in enumerate(lines) if line.startswith(prompt)] if starts: region_lines = [line for line in lines[starts[-1]:]] while region_lines and not region_lines[-1].strip(): @@ -68,11 +72,15 @@ def extract_input_region(pane_text: str, agent_name: str) -> str: if frame: region_lines = [line.strip().strip(frame) for line in region_lines] - region_text = "\n".join(region_lines) - region_text = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_text, flags=re.MULTILINE) + # The prompt and the placeholder belong to the box's first line only: the + # same characters further down are what the user typed. + region_lines = list(region_lines) + first = next((i for i, line in enumerate(region_lines) if line.strip()), None) + if first is not None: + head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1) + placeholder = adapter.input_placeholder + if placeholder and head.strip() == placeholder: + head = '' + region_lines[first] = head - placeholder = adapter.input_placeholder - if placeholder: - region_text = region_text.replace(placeholder, '') - - return region_text.strip() + return "\n".join(region_lines).strip() diff --git a/tests/test_issue30_stall_watch.py b/tests/test_issue30_stall_watch.py index 0072e3a..59c7251 100644 --- a/tests/test_issue30_stall_watch.py +++ b/tests/test_issue30_stall_watch.py @@ -676,7 +676,7 @@ def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr wait mock. process-info retains its measured nested result shape. """ reg = mam_sandbox / ".mam" / "jobs" - env = _env(mam_sandbox, MAM_STALL_IDLE_S=10) + env = _env(mam_sandbox, MAM_STALL_IDLE_S=40) jid = _job(env, reg) agent = _proc("exec sleep 300") observed = mam_sandbox / "observed.json" @@ -729,7 +729,10 @@ def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr w.terminate() w.communicate(timeout=10) idle_time = times['prompted'] - times.get('changed', times['started']) - assert idle_time >= 10, {'continuous_idle_seconds': idle_time, 'threshold': 10} + # S must exceed the 30 s re-judgement interval: with the old code the nudge came + # ~30 s after the missed transition, which only fails the assertion when S > 30 + # (review ad3d8206; S=10 passed on the defective code too). + assert idle_time >= 40, {'continuous_idle_seconds': idle_time, 'threshold': 40} finally: if w is not None and w.poll() is None: w.terminate() @@ -745,3 +748,42 @@ def test_r1_the_codex_extractor_keeps_every_line_under_the_prompt(): assert extract_input_region(draft, "codex") == "draft on the second input line" no_footer = "\n".join(idle.splitlines()[:-1]).replace("› Ask Codex to do anything", "›\n a · b · c") assert extract_input_region(no_footer, "codex") != "", "only the last line may be the footer" + + + +# Review ad3d8206 (planner-reviewer-codex-01): a prompt glyph inside the draft +# is draft text, not the start of the input box (the reviewer's reproduction). +@pytest.mark.parametrize("draft", [False, True]) +def test_r1b_a_draft_containing_the_prompt_glyph_is_not_empty(mam_sandbox, mock_herdr, draft): + reg = mam_sandbox / ".mam" / "jobs" + env = _env(mam_sandbox, MAM_STALL_IDLE_S=1) + jid = _job(env, reg) + agent = _proc("exec sleep 300") + w = None + try: + pane = _pane("codex") + if draft: + pane = pane.replace("› Ask Codex to do anything", "› Explain this terminal prompt:\n ›") + _agent(mock_herdr, "reviewer-codex-t", "codex", agent.pid, "idle", pane) + w = _watch(env, reg, jid, "reviewer-codex-t", "codex") + if draft: + time.sleep(5) + else: + assert _until(lambda: _prompts(mock_herdr)) + prompts = _prompts(mock_herdr) + _finish(env, reg, jid, w) + assert len(prompts) == (0 if draft else 1), prompts + finally: + if w is not None and w.poll() is None: + w.terminate() + w.communicate(timeout=10) + _kill(agent) + + +def test_r1b_the_extractor_strips_the_prompt_from_the_first_line_only(): + from lib_py.agents.input_region import extract_input_region + idle = _pane("codex") + for draft in ("› Explain this terminal prompt:\n ›", "›\n ›", "› Ask Codex to do anything\n more"): + assert extract_input_region(idle.replace("› Ask Codex to do anything", draft), "codex") != "", draft + typed = re.sub(r"^❯\s*$", "❯ x\n ❯", _pane("claude"), count=1, flags=re.M) + assert typed != _pane("claude") and extract_input_region(typed, "claude") != ""