From df31e8d5d281aa2272f8a4191f6803d1899db864 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 21 Aug 2026 11:51:14 -0700 Subject: [PATCH] [eric] tests: the suite can never resume real chats with live credentials (ENG-388) --- .../manager/session/SessionPersistence.py | 12 +++++ backend/tests/test_crash_auto_resume.py | 9 ++++ .../tests/test_no_real_turns_under_test.py | 49 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 backend/tests/test_no_real_turns_under_test.py diff --git a/backend/apps/agents/manager/session/SessionPersistence.py b/backend/apps/agents/manager/session/SessionPersistence.py index 45668b29..f2531de8 100644 --- a/backend/apps/agents/manager/session/SessionPersistence.py +++ b/backend/apps/agents/manager/session/SessionPersistence.py @@ -4,6 +4,7 @@ on boot). Split from SessionLifecycle (which handles ONE session at a time) so e one concern. self.sessions resolves across the MRO as before.""" import logging +import sys from typeguard import typechecked @@ -18,6 +19,13 @@ from backend.apps.agents.manager.session.apply_context_window import apply_conte logger = logging.getLogger(__name__) +def running_under_test() -> bool: + """Auto-resume dispatches REAL turns: live credentials, live Bash, in whatever tree the process + was started from. A test that boots the app lifespan must never do that to the developer's own + chats, so this is the one gate that keeps a suite run from becoming an agent run.""" + return "pytest" in sys.modules + + from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol @@ -80,6 +88,10 @@ class SessionPersistence(AgentManagerProtocol): """Fire one hidden continuation into each crash-interrupted session (called after restore, off the boot critical path). Failure is per-session and non-fatal: a session that cannot resume just keeps its amber chip.""" + if running_under_test(): + logger.info("crash-resume: skipped, running under test") + self.crash_resume_queue = [] + return for sid in list(getattr(self, "crash_resume_queue", []) or []): try: # send_message lives on the Messaging mixin; AgentManager composes both. diff --git a/backend/tests/test_crash_auto_resume.py b/backend/tests/test_crash_auto_resume.py index 20886ed0..95910b66 100644 --- a/backend/tests/test_crash_auto_resume.py +++ b/backend/tests/test_crash_auto_resume.py @@ -58,6 +58,13 @@ def test_second_consecutive_crash_trips_the_breaker(manager, tmp_path, monkeypat assert json.load(f)["crash_interrupt_count"] == 2 +def p_act_like_a_real_app(monkeypatch) -> None: + """Auto-resume refuses to dispatch under pytest, because it sends REAL turns and the suite shares + the developer's data root (ENG-388). These tests are about the production path, so they opt out.""" + import backend.apps.agents.manager.session.SessionPersistence as p_sp + monkeypatch.setattr(p_sp, "running_under_test", lambda: False) + + def test_waiting_approval_never_auto_resumes(manager, tmp_path, monkeypatch): p_write_session(tmp_path, monkeypatch, "s-appr", "waiting_approval", [p_msg("user"), p_msg("tool_call")]) asyncio.run(manager.reconcile_on_startup()) @@ -65,6 +72,7 @@ def test_waiting_approval_never_auto_resumes(manager, tmp_path, monkeypatch): def test_auto_resume_sends_one_hidden_continuation(manager, tmp_path, monkeypatch): + p_act_like_a_real_app(monkeypatch) p_write_session(tmp_path, monkeypatch, "s-cut", "running", [p_msg("user"), p_msg("tool_call")]) asyncio.run(manager.reconcile_on_startup()) sent = [] @@ -81,6 +89,7 @@ def test_auto_resume_sends_one_hidden_continuation(manager, tmp_path, monkeypatc def test_resume_failure_is_per_session_and_non_fatal(manager, tmp_path, monkeypatch): + p_act_like_a_real_app(monkeypatch) p_write_session(tmp_path, monkeypatch, "s-a", "running", [p_msg("user"), p_msg("tool_call")]) p_write_session(tmp_path, monkeypatch, "s-b", "running", [p_msg("user"), p_msg("tool_call")]) asyncio.run(manager.reconcile_on_startup()) diff --git a/backend/tests/test_no_real_turns_under_test.py b/backend/tests/test_no_real_turns_under_test.py new file mode 100644 index 00000000..3d085b84 --- /dev/null +++ b/backend/tests/test_no_real_turns_under_test.py @@ -0,0 +1,49 @@ +"""A test run must never become an agent run. + +The bug class (caught live 2026-08-21): `agents_lifespan` fires `auto_resume_crashed_turns()`, which +sends a real hidden continuation into every crash-interrupted session. The suite does not isolate the +data root, so any test that boots the app lifespan resumed the DEVELOPER'S OWN chats with live +credentials and live Bash, inside whatever tree the suite was running from. Observed: a full +`pytest backend/tests` run spawned two real CLI processes which each started their own +`pytest backend/tests/` run, because that was the task those chats had been interrupted mid-way +through. It spends real money, rewrites real session files, and executes commands from old +conversations against the working tree. + +The seal: auto-resume refuses to dispatch when pytest is loaded. +""" + +import asyncio + +import backend.apps.agents.manager.session.SessionPersistence as p_sp +from backend.apps.agents.agent_manager import agent_manager + + +def p_capture_sends(monkeypatch) -> list: + sent: list = [] + + async def fake_send(session_id, prompt, hidden=False, **kwargs): + sent.append(session_id) + + monkeypatch.setattr(agent_manager, "send_message", fake_send) + agent_manager.crash_resume_queue = ["sid-a", "sid-b"] + return sent + + +def test_auto_resume_never_dispatches_under_test(monkeypatch): + sent = p_capture_sends(monkeypatch) + asyncio.run(agent_manager.auto_resume_crashed_turns()) + assert sent == [], "a suite run must not spend credentials resuming real chats" + assert agent_manager.crash_resume_queue == [], "the queue is still cleared, so nothing resumes later either" + + +def test_the_gate_is_what_stops_it(monkeypatch): + """Control: with the gate reporting a normal app process, the very same call does dispatch. + Without this arm the test above passes even if auto-resume quietly stopped working.""" + sent = p_capture_sends(monkeypatch) + monkeypatch.setattr(p_sp, "running_under_test", lambda: False) + asyncio.run(agent_manager.auto_resume_crashed_turns()) + assert sent == ["sid-a", "sid-b"] + + +def test_the_gate_sees_pytest(): + assert p_sp.running_under_test() is True