From 225fbfe3b168b51fde66111f18fa21457871fc69 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 9 Oct 2026 23:15:09 +0000 Subject: [PATCH 1/2] Fix Claude background-task wait when tasks opens agent details --- tests/README.md | 2 ++ tests/integration/README.md | 3 +++ tests/integration/utils/terminal.py | 18 +++++++++++++- tests/test_e2e_cuj_helpers.py | 37 +++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tests/README.md b/tests/README.md index e4d8d86da..74d0ff30c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,6 +30,8 @@ Offline PTY checks verify that the Claude background-task wait observes "No task running" or a native task menu containing only completed rows before sending `/exit`, without stopping tasks or confirming an exit dialog. Running/scheduled sections, incomplete row counts, and completed-task text outside the native menu cannot satisfy the wait. +If `/tasks` opens an agent's detail panel, the helper presses Left to return to the full +task list. Offline PTY cases require this navigation and still reject other running tasks. The live Claude subagent skill-toggle journey uses this wait after its final calculation. CUJ4's Claude preset sessions also use it after verifying delegated file tasks: a completed answer does not prove that a resumed child has stopped running. diff --git a/tests/integration/README.md b/tests/integration/README.md index 85e63db89..5985f685f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -62,6 +62,9 @@ completed task rows before submitting `/exit`. Claude 2.1.290 retains finished c checkmarked `done` rows and no running or scheduled section. The helper observes menu dismissal after Escape before typing `/exit`. Offline PTY checks reject active/scheduled tasks, partial lists, and completed-task text outside the native menu. +When `/tasks` opens a single agent's detail panel, the helper presses Left to return to +the full task list before checking completion. Offline PTY cases cover completed details, +a child that resumes, and another task that remains running. It neither stops tasks nor confirms an exit dialog. Process exit retains its 30-second timeout. Offline PTY checks cover this ordering. The shared wait also runs before CUJ4's Claude preset sessions exit, after delegated-task diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 299561298..711f8946e 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -429,8 +429,24 @@ def wait_for_background_tasks(self, timeout=180): """Wait in Claude's native task view without stopping or detaching work.""" assert self.agent == "claude", self.agent self.submit("/tasks") + navigating_back = False + + def completed(text): + nonlocal navigating_back + if re.search( + r"(?m)^\s*← to go back · Esc/Enter/Space to close · f to foreground\s*\Z", + text, + ): + # /tasks can open one agent's detail panel while updates are pending. + if not navigating_back: + self.send("\x1b[D", "return from agent details to the full task list") + navigating_back = True + return False + navigating_back = False + return _claude_background_tasks_complete(text) + self.wait_for( - _claude_background_tasks_complete, + completed, "Claude's task view reporting no running tasks", timeout=timeout, ) diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py index cf32874a1..0ccf9d5b4 100644 --- a/tests/test_e2e_cuj_helpers.py +++ b/tests/test_e2e_cuj_helpers.py @@ -213,7 +213,19 @@ def test_wait_until_acknowledges_ready_billing_notices_and_observes_dismissal(tm @requires_pty @pytest.mark.parametrize( "view", - ["empty", "completed", "resumed", "running", "mixed", "scheduled", "partial", "transcript"], + [ + "empty", + "completed", + "resumed", + "detail_completed", + "detail_resumed", + "detail_mixed", + "running", + "mixed", + "scheduled", + "partial", + "transcript", + ], ) def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view): """An offline terminal fixture requires /tasks, completion, Escape, then /exit.""" @@ -246,7 +258,17 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) "transcript": ("Background\n\n Completed (1)\n✔ ug_subagent_one done · Sonnet 5\n\n❯"), } views["resumed"] = views["mixed"] - complete = view in ("empty", "completed", "resumed") + detail = view.startswith("detail_") + detail_view = ( + "Background task update waiting while this panel is open\n" + "agent › Read file contents\n" + "✔ Completed · 8s · 12.6k tokens · 2 tools · Haiku 4.5\n" + "← to go back · Esc/Enter/Space to close · f to foreground" + ) + views["detail_completed"] = views["completed"] + views["detail_resumed"] = views["mixed"] + views["detail_mixed"] = views["mixed"] + complete = view in ("empty", "completed", "resumed", "detail_completed", "detail_resumed") script = tmp_path / "ug" script.write_text( f"""#!{sys.executable} @@ -262,8 +284,13 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) time.sleep(0.8) previous = termios.tcgetattr(sys.stdin.fileno()) tty.setraw(sys.stdin.fileno()) -print("\\x1b[2J\\x1b[H" + {views[view]!r}.replace("\\n", "\\r\\n"), flush=True) -if {view == "resumed"!r}: +print("\\x1b[2J\\x1b[H" + {(detail_view if detail else views[view])!r}.replace("\\n", "\\r\\n"), flush=True) +if {detail!r}: + assert sys.stdin.read(3) == "\\x1b[D", "Must return to the full task list" + time.sleep(0.4) + assert not select.select([sys.stdin], [], [], 0)[0], "Repeated navigation before redraw" + print("\\x1b[2J\\x1b[H" + {views[view]!r}.replace("\\n", "\\r\\n"), flush=True) +if {view in ("resumed", "detail_resumed")!r}: time.sleep(0.8) assert not select.select([sys.stdin], [], [], 0)[0], "Exited with a resumed child running" print("\\x1b[2J\\x1b[H" + {views["completed"]!r}.replace("\\n", "\\r\\n"), flush=True) @@ -286,7 +313,7 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) if action["reason"] == "close the completed background-task view" ) assert views[view].splitlines()[0] in close["screen_before"] - if view == "resumed": + if view in ("resumed", "detail_resumed"): assert "Running (1)" not in close["screen_before"] assert "Completed (2)" in close["screen_before"] else: From e6baead53df0fdb0f172c5485ba9285b64f6963d Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 9 Oct 2026 23:24:45 +0000 Subject: [PATCH 2/2] Verify completed delegation instead of Claude task-panel layout --- tests/README.md | 8 ++--- tests/e2e_cuj/README.md | 10 ++++--- tests/e2e_cuj/helpers/evidence.py | 6 +++- tests/e2e_cuj/test_cuj4_smart_routing.py | 6 ++-- tests/integration/README.md | 9 +++--- tests/integration/utils/terminal.py | 18 +----------- tests/test_cuj_evidence.py | 34 ++++++++++++++++++++++ tests/test_e2e_cuj_helpers.py | 37 ++++-------------------- 8 files changed, 62 insertions(+), 66 deletions(-) diff --git a/tests/README.md b/tests/README.md index 74d0ff30c..8f26faba6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,11 +30,11 @@ Offline PTY checks verify that the Claude background-task wait observes "No task running" or a native task menu containing only completed rows before sending `/exit`, without stopping tasks or confirming an exit dialog. Running/scheduled sections, incomplete row counts, and completed-task text outside the native menu cannot satisfy the wait. -If `/tasks` opens an agent's detail panel, the helper presses Left to return to the full -task list. Offline PTY cases require this navigation and still reject other running tasks. The live Claude subagent skill-toggle journey uses this wait after its final calculation. -CUJ4's Claude preset sessions also use it after verifying delegated file tasks: a completed -answer does not prove that a resumed child has stopped running. +CUJ4's preset sessions verify the child's answer and routed inference, then require +the parent's completed native turn containing the delegated result and a normal `/exit`. +They do not parse the `/tasks` menu. Offline evidence checks reject child answers, +notifications, and parent tool calls as substitutes for the parent's final result. They cover Claude/Codex helper dispatch and rejection of routing decisions without the agent-specific prompt-submission evidence. Agent-specific smart-routing preset component checks cover shared base presets, explicit agent mappings, diff --git a/tests/e2e_cuj/README.md b/tests/e2e_cuj/README.md index 3382630a2..b54be1e4c 100644 --- a/tests/e2e_cuj/README.md +++ b/tests/e2e_cuj/README.md @@ -93,7 +93,8 @@ Each preset session then explicitly requests one child for a separate hidden-val file task. Assertions require a new native child transcript containing the value, a correlated spawn-routing decision, and successful child inference on the router's selected model. This tests requested delegation, not automatic orchestrator delegation. -The wait uses the child's answer directly; parent-answer reconstruction is reserved for first-prompt tasks. +Child inference evidence uses the child's answer directly. Both agents also wait for the +parent's completed native turn containing the delegated result before exiting. Offline checks reject parent-only answers and accept child answers before the parent replies. Offline evidence regressions do not establish a live CUJ pass. Selector cases have separate TUI artifact names, and the session environment is restored afterward. @@ -102,9 +103,10 @@ Claude sessions start in auto mode; unless a routed first prompt switches the se Claude's informational auto-mode classifier billing notice over the transcript. The CUJ terminal waits for that exact notice to render stably, presses Enter (continue), and observes dismissal before continuing. Later occurrences are handled the same way; any other dialog still fails. -After verifying the delegated task, Claude sessions also wait in the native `/tasks` view -until no background work remains, including a child resumed after its initial answer. -Only then does the test close the task view and submit `/exit`. +After verifying the delegated task, both agents require the parent's final answer with +the file value and a normal `/exit`, without visiting `/tasks`. Native task notifications +can precede that answer but cannot satisfy it; parent tool calls do not count as completion. +This covers the delegation-and-reply journey rather than the background-task management UI. Each preset also runs public `ug revert` in cleanup, including after a failed assertion, so interactive launches' OS-managed settings cannot contaminate the next preset's configuration. The existing Claude explicit-model precedence case remains skipped; the routing-disabled case diff --git a/tests/e2e_cuj/helpers/evidence.py b/tests/e2e_cuj/helpers/evidence.py index 73a401474..9eafa5d72 100644 --- a/tests/e2e_cuj/helpers/evidence.py +++ b/tests/e2e_cuj/helpers/evidence.py @@ -194,7 +194,11 @@ def _completed_turn(records, task): responses = [] for row in records[prompts[0] + 1 :]: msg = row.get("message", {}) - if row.get("type") == "user" and isinstance(msg.get("content"), str): + if ( + row.get("type") == "user" + and isinstance(msg.get("content"), str) + and (row.get("origin") or {}).get("kind") != "task-notification" + ): break if row.get("type") == "assistant": assert row.get("sessionId") == session_id and session_id diff --git a/tests/e2e_cuj/test_cuj4_smart_routing.py b/tests/e2e_cuj/test_cuj4_smart_routing.py index c19ab368e..a11651167 100644 --- a/tests/e2e_cuj/test_cuj4_smart_routing.py +++ b/tests/e2e_cuj/test_cuj4_smart_routing.py @@ -245,7 +245,8 @@ def test_smart_router_config_version( """Scenario: launch both agents with a preset, then explicitly request a subagent. Expected: first-prompt routing and orchestrator context match the preset; - one routed native child completes the delegated task. + one routed native child completes the delegated task, the parent returns + its result, and the interactive session exits normally. """ session, workspace, recorder = cuj previous = session.env.get("SMART_ROUTER_CONFIG_VERSION") @@ -306,6 +307,7 @@ def test_smart_router_config_version( ), "completed native subagent file task", ) + tui.task(evidence, child_task) children = { path: records for path, records in agent_sessions(session, agent).items() @@ -343,8 +345,6 @@ def test_smart_router_config_version( assert canonical_model(inference.payload["model"]) == canonical_model( selections[0]["route_option"]["model"] ) - if agent == CLAUDE: - tui.wait_for_background_tasks() tui.exit_normally() finally: if previous is None: diff --git a/tests/integration/README.md b/tests/integration/README.md index 5985f685f..af06eadf4 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -62,13 +62,12 @@ completed task rows before submitting `/exit`. Claude 2.1.290 retains finished c checkmarked `done` rows and no running or scheduled section. The helper observes menu dismissal after Escape before typing `/exit`. Offline PTY checks reject active/scheduled tasks, partial lists, and completed-task text outside the native menu. -When `/tasks` opens a single agent's detail panel, the helper presses Left to return to -the full task list before checking completion. Offline PTY cases cover completed details, -a child that resumes, and another task that remains running. It neither stops tasks nor confirms an exit dialog. Process exit retains its 30-second timeout. Offline PTY checks cover this ordering. -The shared wait also runs before CUJ4's Claude preset sessions exit, after delegated-task -evidence is checked, because Claude can resume a child after its first completed answer. +CUJ4's preset journey instead verifies delegated child evidence, waits for the +parent's completed native turn containing the result, and requires normal `/exit`. +Its routing assertions are independent of `/tasks` presentation. Offline checks reject +child-only results, completion notifications, and parent tool calls as final parent answers. Agent-specific bundled instruction selection and prompt/compaction hook content are covered in `../test_skills.py`; those component checks do not run live agents. diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 711f8946e..299561298 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -429,24 +429,8 @@ def wait_for_background_tasks(self, timeout=180): """Wait in Claude's native task view without stopping or detaching work.""" assert self.agent == "claude", self.agent self.submit("/tasks") - navigating_back = False - - def completed(text): - nonlocal navigating_back - if re.search( - r"(?m)^\s*← to go back · Esc/Enter/Space to close · f to foreground\s*\Z", - text, - ): - # /tasks can open one agent's detail panel while updates are pending. - if not navigating_back: - self.send("\x1b[D", "return from agent details to the full task list") - navigating_back = True - return False - navigating_back = False - return _claude_background_tasks_complete(text) - self.wait_for( - completed, + _claude_background_tasks_complete, "Claude's task view reporting no running tasks", timeout=timeout, ) diff --git a/tests/test_cuj_evidence.py b/tests/test_cuj_evidence.py index 40f84ba74..43a9384bf 100644 --- a/tests/test_cuj_evidence.py +++ b/tests/test_cuj_evidence.py @@ -121,6 +121,40 @@ def test_cuj_evidence_claude_real_user_message_ends_parent_turn(): assert completed_turn(CLAUDE, [prompt, user, answer], task) is None +def test_claude_delegated_journey_requires_parent_result_after_child_notification(tmp_path): + task = FileTask(SimpleNamespace(cwd=tmp_path)) + task.prompt = task.delegate_prompt + evidence = SessionEvidence(tmp_path, CLAUDE) + parent = evidence.directory / "session.jsonl" + child = evidence.directory / "session" / "subagents" / "agent-child.jsonl" + prompt, answer = records(CLAUDE, task, "system.ai.claude-sonnet-4-6") + child_rows = records(CLAUDE, task, "system.ai.claude-haiku-4-5") + for row in child_rows: + row["isSidechain"] = True + write_rows(child, child_rows) + notification = { + "type": "user", + "sessionId": "session", + "origin": {"kind": "task-notification", "producer": "session-task"}, + "message": { + "content": f"{task.value}" + }, + } + write_rows(parent, [prompt, notification]) + assert evidence.completed(task) is None + + working = copy.deepcopy(answer) + working["message"]["stop_reason"] = "tool_use" + working["message"]["content"].append({"type": "tool_use", "name": "SendMessage"}) + write_rows(parent, [prompt, notification, working]) + assert evidence.completed(task) is None + + write_rows(parent, [prompt, notification, working, answer]) + turn = evidence.completed(task) + assert turn is not None + assert turn.answer == task.value + + @pytest.mark.parametrize("agent", [CLAUDE, CODEX]) @pytest.mark.parametrize( "failure", diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py index 0ccf9d5b4..cf32874a1 100644 --- a/tests/test_e2e_cuj_helpers.py +++ b/tests/test_e2e_cuj_helpers.py @@ -213,19 +213,7 @@ def test_wait_until_acknowledges_ready_billing_notices_and_observes_dismissal(tm @requires_pty @pytest.mark.parametrize( "view", - [ - "empty", - "completed", - "resumed", - "detail_completed", - "detail_resumed", - "detail_mixed", - "running", - "mixed", - "scheduled", - "partial", - "transcript", - ], + ["empty", "completed", "resumed", "running", "mixed", "scheduled", "partial", "transcript"], ) def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view): """An offline terminal fixture requires /tasks, completion, Escape, then /exit.""" @@ -258,17 +246,7 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) "transcript": ("Background\n\n Completed (1)\n✔ ug_subagent_one done · Sonnet 5\n\n❯"), } views["resumed"] = views["mixed"] - detail = view.startswith("detail_") - detail_view = ( - "Background task update waiting while this panel is open\n" - "agent › Read file contents\n" - "✔ Completed · 8s · 12.6k tokens · 2 tools · Haiku 4.5\n" - "← to go back · Esc/Enter/Space to close · f to foreground" - ) - views["detail_completed"] = views["completed"] - views["detail_resumed"] = views["mixed"] - views["detail_mixed"] = views["mixed"] - complete = view in ("empty", "completed", "resumed", "detail_completed", "detail_resumed") + complete = view in ("empty", "completed", "resumed") script = tmp_path / "ug" script.write_text( f"""#!{sys.executable} @@ -284,13 +262,8 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) time.sleep(0.8) previous = termios.tcgetattr(sys.stdin.fileno()) tty.setraw(sys.stdin.fileno()) -print("\\x1b[2J\\x1b[H" + {(detail_view if detail else views[view])!r}.replace("\\n", "\\r\\n"), flush=True) -if {detail!r}: - assert sys.stdin.read(3) == "\\x1b[D", "Must return to the full task list" - time.sleep(0.4) - assert not select.select([sys.stdin], [], [], 0)[0], "Repeated navigation before redraw" - print("\\x1b[2J\\x1b[H" + {views[view]!r}.replace("\\n", "\\r\\n"), flush=True) -if {view in ("resumed", "detail_resumed")!r}: +print("\\x1b[2J\\x1b[H" + {views[view]!r}.replace("\\n", "\\r\\n"), flush=True) +if {view == "resumed"!r}: time.sleep(0.8) assert not select.select([sys.stdin], [], [], 0)[0], "Exited with a resumed child running" print("\\x1b[2J\\x1b[H" + {views["completed"]!r}.replace("\\n", "\\r\\n"), flush=True) @@ -313,7 +286,7 @@ def test_claude_waits_for_background_task_completion_before_exit(tmp_path, view) if action["reason"] == "close the completed background-task view" ) assert views[view].splitlines()[0] in close["screen_before"] - if view in ("resumed", "detail_resumed"): + if view == "resumed": assert "Running (1)" not in close["screen_before"] assert "Completed (2)" in close["screen_before"] else: