"""Tests for the HITL (Human-In-The-Loop) workflow primitives. Covers: - _TaskPauseRegistry: register/resume/timeout/list_paused - pause_task / resume_task tools: success, timeout, not-found - @requires_approval decorator: approval granted, denied, RBAC bypass - HITLConfig loading from workspace config - Notification helpers: Slack URL construction, email config validation """ import asyncio import importlib.util import sys from pathlib import Path from types import ModuleType from unittest.mock import AsyncMock, MagicMock, patch import pytest ROOT = Path(__file__).resolve().parents[1] # --------------------------------------------------------------------------- # Module loader (isolated from conftest mocks) # --------------------------------------------------------------------------- def _load_hitl(monkeypatch): """Load tools/hitl.py in a fresh namespace with controlled dependencies.""" # Ensure langchain_core.tools.tool is a no-op decorator if "langchain_core" not in sys.modules: lc = ModuleType("langchain_core") lc_tools = ModuleType("langchain_core.tools") lc_tools.tool = lambda f: f monkeypatch.setitem(sys.modules, "langchain_core", lc) monkeypatch.setitem(sys.modules, "langchain_core.tools", lc_tools) else: monkeypatch.setattr(sys.modules["langchain_core.tools"], "tool", lambda f: f, raising=False) # Stub heavy deps the module imports at top level httpx_stub = ModuleType("httpx") httpx_stub.AsyncClient = MagicMock() monkeypatch.setitem(sys.modules, "httpx", httpx_stub) monkeypatch.setenv("PLATFORM_URL", "http://platform.test") monkeypatch.setenv("WORKSPACE_ID", "ws-test") monkeypatch.setitem(sys.modules, "builtin_tools.audit", MagicMock( log_event=MagicMock(return_value="trace-id"), check_permission=MagicMock(return_value=True), get_workspace_roles=MagicMock(return_value=(["operator"], {})), )) monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock( request_approval=MagicMock(ainvoke=AsyncMock(return_value={"approved": True, "approval_id": "appr-1"})), )) # Remove any cached hitl module monkeypatch.setitem(sys.modules, "builtin_tools.hitl", None) # force reload sys.modules.pop("builtin_tools.hitl", None) spec = importlib.util.spec_from_file_location( "builtin_tools.hitl", ROOT / "builtin_tools" / "hitl.py" ) mod = importlib.util.module_from_spec(spec) monkeypatch.setitem(sys.modules, "builtin_tools.hitl", mod) spec.loader.exec_module(mod) return mod # ============================================================================ # _TaskPauseRegistry # ============================================================================ class TestPauseRegistry: def test_register_creates_event(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() ev = reg.register("task-1") assert not ev.is_set() def test_resume_sets_event(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() reg.register("task-2") result = reg.resume("task-2", {"note": "approved"}) assert result is True def test_resume_unknown_returns_false(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() assert reg.resume("nonexistent", {}) is False def test_pop_result_returns_stored_payload(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() reg.register("task-3") reg.resume("task-3", {"data": "hello"}) r = reg.pop_result("task-3") assert r == {"data": "hello"} def test_pop_result_missing_returns_empty(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() assert reg.pop_result("no-such-task") == {} def test_list_paused_only_unset(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() reg.register("t-paused") reg.register("t-resumed") reg.resume("t-resumed", {}) assert "t-paused" in reg.list_paused() assert "t-resumed" not in reg.list_paused() def test_cleanup_removes_entries(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() reg.register("t-clean") reg.cleanup("t-clean") assert "t-clean" not in reg.list_paused() assert reg.pop_result("t-clean") == {} # ============================================================================ # pause_task / resume_task tools # ============================================================================ class TestPauseResumeTool: @pytest.mark.asyncio async def test_pause_resumes_on_signal(self, monkeypatch): mod = _load_hitl(monkeypatch) # Override the global registry with a fresh one reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) # Schedule a resume signal 50 ms after pause starts async def _schedule_resume(): await asyncio.sleep(0.05) reg.resume("task-a", {"note": "human approved"}) asyncio.create_task(_schedule_resume()) result = await mod.pause_task("task-a", "waiting for review") assert result["resumed"] is True assert result["task_id"] == "task-a" @pytest.mark.asyncio async def test_pause_times_out(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) # Set a very short timeout via the HITL config monkeypatch.setattr(mod, "_load_hitl_config", lambda: mod.HITLConfig(default_timeout=0.05)) result = await mod.pause_task("task-timeout", "will timeout") assert result["resumed"] is False assert "error" in result @pytest.mark.asyncio async def test_resume_task_success(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) reg.register("task-r") result = await mod.resume_task("task-r", "looks good") assert result["success"] is True assert result["task_id"] == "task-r" @pytest.mark.asyncio async def test_resume_task_not_found(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) result = await mod.resume_task("does-not-exist", "") assert result["success"] is False assert "error" in result @pytest.mark.asyncio async def test_resume_task_from_different_workspace_rejected(self, monkeypatch): # #265 regression: a task paused in workspace A must not be resumable # from workspace B even when the attacker guesses task_id. Ownership # is tracked as registry metadata; resume_task passes WORKSPACE_ID as # owner and the registry rejects a mismatch. mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) # Workspace A owns the task. reg.register("secret-task", owner="ws-A") # Switch process env to workspace B — resume_task will pass owner=ws-B. monkeypatch.setenv("WORKSPACE_ID", "ws-B") result = await mod.resume_task("secret-task", "pwned") assert result["success"] is False # Task is still registered; the legitimate owner can still resume it. assert "secret-task" in reg.list_paused() @pytest.mark.asyncio async def test_list_paused_tasks_empty(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) result = await mod.list_paused_tasks() assert result["count"] == 0 assert result["paused_tasks"] == [] @pytest.mark.asyncio async def test_list_paused_tasks_shows_registered(self, monkeypatch): mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) reg.register("t-show") result = await mod.list_paused_tasks() assert result["count"] == 1 assert "t-show" in result["paused_tasks"] # ============================================================================ # @requires_approval decorator # ============================================================================ class TestRequiresApproval: @pytest.mark.asyncio async def test_executes_when_approved(self, monkeypatch): mod = _load_hitl(monkeypatch) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(return_value={ "approved": True, "approval_id": "appr-ok" }) monkeypatch.setitem( sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock) ) executed = [] @mod.requires_approval("Run migration") async def run_migration(table: str): executed.append(table) return {"done": True} result = await run_migration(table="users") assert result == {"done": True} assert executed == ["users"] @pytest.mark.asyncio async def test_blocks_when_denied(self, monkeypatch): mod = _load_hitl(monkeypatch) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(return_value={ "approved": False, "approval_id": "appr-no", "message": "Denied by human" }) monkeypatch.setitem( sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock) ) executed = [] @mod.requires_approval("Drop table") async def drop_table(table: str): executed.append(table) return {"done": True} result = await drop_table(table="orders") assert result["success"] is False assert "not approved" in result["error"].lower() or "approved" in result["error"].lower() assert executed == [] # Never ran @pytest.mark.asyncio async def test_bypasses_for_admin_role(self, monkeypatch): mod = _load_hitl(monkeypatch) # Mock RBAC: workspace has 'admin' role audit_mock = MagicMock() audit_mock.get_workspace_roles = MagicMock(return_value=(["admin"], {})) audit_mock.check_permission = MagicMock(return_value=True) audit_mock.log_event = MagicMock(return_value="tid") monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) approval_called = [] approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(side_effect=lambda _: approval_called.append(1) or {"approved": True}) monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) @mod.requires_approval("Danger", bypass_roles=["admin"]) async def dangerous_op(): return {"ran": True} result = await dangerous_op() assert result == {"ran": True} assert len(approval_called) == 0 # approval was bypassed @pytest.mark.asyncio async def test_reason_template_interpolation(self, monkeypatch): mod = _load_hitl(monkeypatch) captured_reason = [] async def fake_ainvoke(args): captured_reason.append(args["reason"]) return {"approved": True} approval_mock = MagicMock() approval_mock.ainvoke = fake_ainvoke monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) @mod.requires_approval("Delete record", reason_template="Deleting record {record_id} from {table}") async def delete_record(record_id: str, table: str): return {"deleted": True} await delete_record(record_id="42", table="users") assert captured_reason == ["Deleting record 42 from users"] @pytest.mark.asyncio async def test_handles_approval_tool_exception(self, monkeypatch): mod = _load_hitl(monkeypatch) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(side_effect=ConnectionError("platform down")) monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) @mod.requires_approval("Risky op") async def risky(): return {"done": True} result = await risky() assert result["success"] is False assert "error" in result @pytest.mark.asyncio async def test_logs_hitl_denied_event(self, monkeypatch): """Art. 14 audit: denial outcome must be logged to activity_logs (#893).""" mod = _load_hitl(monkeypatch) audit_mock = MagicMock() audit_mock.log_event = MagicMock(return_value="trace-id") monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(return_value={ "approved": False, "approval_id": "appr-deny-123", "decided_by": "human-reviewer", "message": "Denied by human", }) monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) @mod.requires_approval("Delete production DB") async def delete_db(): return {"done": True} result = await delete_db() assert result["success"] is False # log_event must have been called with the denial outcome. log_calls = audit_mock.log_event.call_args_list denial_calls = [ c for c in log_calls if c.kwargs.get("outcome") == "denied" or (c.args and len(c.args) >= 3 and c.args[2] == "denied") ] assert denial_calls, ( "log_event(outcome='denied') was not called — Art. 14 audit gap (issue #893)" ) # Verify the call carries the expected resource / actor. dc = denial_calls[0] assert dc.kwargs.get("event_type") == "hitl" or "hitl" in str(dc) assert dc.kwargs.get("outcome") == "denied" @pytest.mark.asyncio async def test_logs_hitl_approved_event(self, monkeypatch): """Art. 14 audit: approval grant outcome must be logged to activity_logs (#893).""" mod = _load_hitl(monkeypatch) audit_mock = MagicMock() audit_mock.log_event = MagicMock(return_value="trace-id") monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(return_value={ "approved": True, "approval_id": "appr-ok-456", "decided_by": "human-reviewer", }) monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) executed = [] @mod.requires_approval("Run migration") async def run_migration(table: str): executed.append(table) return {"done": True} result = await run_migration(table="users") assert result == {"done": True} assert executed == ["users"] # log_event must have been called with the granted outcome. log_calls = audit_mock.log_event.call_args_list granted_calls = [ c for c in log_calls if c.kwargs.get("outcome") == "granted" ] assert granted_calls, ( "log_event(outcome='granted') was not called — Art. 14 audit gap (issue #893)" ) gc = granted_calls[0] assert gc.kwargs.get("event_type") == "hitl" assert gc.kwargs.get("outcome") == "granted" # ============================================================================ # HITLConfig loading # ============================================================================ class TestHITLConfig: def test_defaults_when_config_unavailable(self, monkeypatch): mod = _load_hitl(monkeypatch) monkeypatch.setitem(sys.modules, "config", MagicMock(load_config=MagicMock(side_effect=FileNotFoundError))) cfg = mod._load_hitl_config() assert cfg.default_timeout == 300.0 assert cfg.bypass_roles == [] assert any(c.get("type") == "dashboard" for c in cfg.channels) def test_loads_from_workspace_config(self, monkeypatch): mod = _load_hitl(monkeypatch) fake_hitl = mod.HITLConfig( channels=[{"type": "slack", "webhook_url": "https://slack.example.com"}], default_timeout=120.0, bypass_roles=["admin", "superuser"], ) fake_ws_cfg = MagicMock() fake_ws_cfg.hitl = fake_hitl monkeypatch.setitem(sys.modules, "config", MagicMock(load_config=MagicMock(return_value=fake_ws_cfg))) cfg = mod._load_hitl_config() assert cfg.default_timeout == 120.0 assert "admin" in cfg.bypass_roles assert cfg.channels[0]["type"] == "slack" # ============================================================================ # Notification channel helpers # ============================================================================ class TestNotificationChannels: @pytest.mark.asyncio async def test_slack_skipped_without_webhook_url(self, monkeypatch): mod = _load_hitl(monkeypatch) # Should not raise, and should log a warning await mod._notify_slack({}, "action", "reason", "appr-1", "http://platform.test", "ws-test") @pytest.mark.asyncio async def test_email_skipped_with_missing_config(self, monkeypatch): mod = _load_hitl(monkeypatch) # Missing smtp_host/from/to — should return without raising await mod._notify_email({}, "action", "reason", "appr-1", "http://platform.test", "ws-test") @pytest.mark.asyncio async def test_slack_posts_to_webhook(self, monkeypatch): mod = _load_hitl(monkeypatch) posted = [] class FakeAsyncClient: def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): pass async def post(self, url, json): posted.append({"url": url, "payload": json}) monkeypatch.setattr(mod.httpx, "AsyncClient", FakeAsyncClient) await mod._notify_slack( {"webhook_url": "https://hooks.slack.test/abc"}, "Delete bucket", "Spring cleanup", "appr-slack-1", "http://platform.test", "ws-test", ) assert len(posted) == 1 assert posted[0]["url"] == "https://hooks.slack.test/abc" payload = posted[0]["payload"] assert "Delete bucket" in str(payload) assert "appr-slack-1" in str(payload) @pytest.mark.asyncio async def test_notify_channels_ignores_channel_errors(self, monkeypatch): mod = _load_hitl(monkeypatch) cfg = mod.HITLConfig(channels=[ {"type": "slack", "webhook_url": "https://hooks.bad.test/fail"}, {"type": "dashboard"}, ]) # Make the slack post raise class FailingClient: def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): pass async def post(self, url, json): raise ConnectionError("webhook down") monkeypatch.setattr(mod.httpx, "AsyncClient", FailingClient) # Should not raise — channel errors are swallowed await mod._notify_channels("test action", "reason", "appr-x", cfg) @pytest.mark.asyncio async def test_notify_email_success(self, monkeypatch): """_notify_email sends email via SMTP when config is complete.""" mod = _load_hitl(monkeypatch) smtp_calls = [] class FakeSMTP: def __init__(self, host, port): smtp_calls.append({"host": host, "port": port}) self.sent = [] def __enter__(self): return self def __exit__(self, *a): pass def ehlo(self): pass def starttls(self): pass def login(self, user, pw): smtp_calls[-1]["login"] = (user, pw) def send_message(self, msg): smtp_calls[-1]["msg"] = msg async def fake_to_thread(fn, *args, **kwargs): fn() monkeypatch.setattr(mod.smtplib, "SMTP", FakeSMTP) monkeypatch.setattr(mod.asyncio, "to_thread", fake_to_thread) cfg = { "smtp_host": "smtp.example.com", "smtp_port": "587", "from": "from@example.com", "to": "to@example.com", "username": "user@example.com", "password": "secret", } await mod._notify_email( cfg, "Deploy prod", "scheduled maintenance", "appr-email-1", "http://platform.test", "ws-test", ) assert len(smtp_calls) == 1 assert smtp_calls[0]["host"] == "smtp.example.com" assert smtp_calls[0]["login"] == ("user@example.com", "secret") msg = smtp_calls[0]["msg"] # The body may be base64-encoded; decode it to check content body = msg.get_payload(decode=True).decode("utf-8") assert "appr-email-1" in body @pytest.mark.asyncio async def test_notify_email_missing_config(self, monkeypatch): """_notify_email with missing smtp_host logs warning and returns without error.""" mod = _load_hitl(monkeypatch) smtp_called = [] class FakeSMTP: def __init__(self, *a, **kw): smtp_called.append(True) def __enter__(self): return self def __exit__(self, *a): pass monkeypatch.setattr(mod.smtplib, "SMTP", FakeSMTP) # Missing smtp_host await mod._notify_email( {"from": "f@ex.com", "to": "t@ex.com"}, "action", "reason", "appr-x", "http://platform.test", "ws-test", ) assert smtp_called == [], "SMTP should not have been called with missing config" @pytest.mark.asyncio async def test_notify_channels_email_channel_error_is_swallowed(self, monkeypatch): """Exception in email channel notification is caught and logged, not re-raised.""" mod = _load_hitl(monkeypatch) cfg = mod.HITLConfig(channels=[ { "type": "email", "smtp_host": "smtp.example.com", "from": "a@b.com", "to": "c@d.com", }, ]) async def fake_to_thread(fn, *args, **kwargs): raise ConnectionRefusedError("SMTP server down") monkeypatch.setattr(mod.asyncio, "to_thread", fake_to_thread) # Should NOT raise — email errors are swallowed like slack errors await mod._notify_channels("action", "reason", "appr-y", cfg) # ============================================================================ # HITLConfig — attribute-less raw object (line 77) # ============================================================================ class TestHITLConfigEdgeCases: def test_defaults_when_raw_has_no_channels_attribute(self, monkeypatch): """When raw.channels attribute check fails, HITLConfig() defaults are used.""" mod = _load_hitl(monkeypatch) # Return a raw config object whose .hitl attribute has NO .channels attr raw_hitl = MagicMock(spec=[]) # spec=[] means NO attributes at all fake_ws_cfg = MagicMock() fake_ws_cfg.hitl = raw_hitl monkeypatch.setitem( sys.modules, "config", MagicMock(load_config=MagicMock(return_value=fake_ws_cfg)) ) cfg = mod._load_hitl_config() # Should fall back to defaults safely assert cfg.default_timeout == 300.0 assert cfg.channels == [{"type": "dashboard"}] assert cfg.bypass_roles == [] # ============================================================================ # @requires_approval — RBAC bypass exception path # ============================================================================ class TestRequiresApprovalEdgeCases: @pytest.mark.asyncio async def test_rbac_bypass_check_exception_proceeds_to_gate(self, monkeypatch): """If get_workspace_roles raises, the decorator falls through to the approval gate.""" mod = _load_hitl(monkeypatch) audit_mock = MagicMock() audit_mock.get_workspace_roles = MagicMock(side_effect=RuntimeError("rbac unavailable")) audit_mock.check_permission = MagicMock(return_value=True) audit_mock.log_event = MagicMock(return_value="tid") monkeypatch.setitem(sys.modules, "builtin_tools.audit", audit_mock) approval_mock = MagicMock() approval_mock.ainvoke = AsyncMock(return_value={"approved": True, "approval_id": "a1"}) monkeypatch.setitem( sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock), ) @mod.requires_approval("Risky action", bypass_roles=["admin"]) async def risky_op(): return {"ran": True} # Even though RBAC check raised, approval gate is invoked and fn executes result = await risky_op() assert result == {"ran": True} approval_mock.ainvoke.assert_called_once() # ============================================================================ # pause_task / resume_task — audit import error paths # ============================================================================ class TestAuditImportErrors: @pytest.mark.asyncio async def test_pause_task_audit_import_error(self, monkeypatch): """pause_task still completes even if tools.audit import raises.""" mod = _load_hitl(monkeypatch) # Make tools.audit unavailable so the import inside pause_task fails monkeypatch.setitem(sys.modules, "builtin_tools.audit", None) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) # Schedule resume quickly so we don't actually wait long async def _schedule_resume(): await asyncio.sleep(0.05) reg.resume("audit-err-task", {"ok": True}) asyncio.create_task(_schedule_resume()) result = await mod.pause_task("audit-err-task", "audit missing") assert result["resumed"] is True assert result["task_id"] == "audit-err-task" @pytest.mark.asyncio async def test_resume_task_audit_import_error(self, monkeypatch): """resume_task still works even if tools.audit import raises.""" mod = _load_hitl(monkeypatch) monkeypatch.setitem(sys.modules, "builtin_tools.audit", None) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) reg.register("audit-err-resume") result = await mod.resume_task("audit-err-resume", "all good") assert result["success"] is True assert result["task_id"] == "audit-err-resume" # ============================================================================ # @requires_approval — reason_template KeyError / IndexError (line 334-335) # ============================================================================ class TestRequiresApprovalReasonTemplate: @pytest.mark.asyncio async def test_requires_approval_reason_template_format_keyerror(self, monkeypatch): """If reason_template.format(**kwargs) raises KeyError, use raw template.""" mod = _load_hitl(monkeypatch) captured_reason = [] async def fake_ainvoke(args): captured_reason.append(args["reason"]) return {"approved": True} approval_mock = MagicMock() approval_mock.ainvoke = fake_ainvoke monkeypatch.setitem(sys.modules, "builtin_tools.approval", MagicMock(request_approval=approval_mock)) # reason_template references {nonexistent_field} which is not in kwargs @mod.requires_approval("Delete record", reason_template="Delete {nonexistent_field} from table") async def delete_record(record_id: str): return {"deleted": True} result = await delete_record(record_id="42") assert result == {"deleted": True} # The raw template should be used when format raises KeyError assert captured_reason == ["Delete {nonexistent_field} from table"] # ============================================================================ # _load_hitl_config — hitl attr is None (line 77) # ============================================================================ class TestLoadHitlConfigHitlAttrNone: def test_load_hitl_config_hitl_attr_none(self, monkeypatch): """When cfg.hitl is None, _load_hitl_config returns default HITLConfig().""" mod = _load_hitl(monkeypatch) mock_cfg = MagicMock() mock_cfg.hitl = None monkeypatch.setitem(sys.modules, "config", MagicMock(load_config=MagicMock(return_value=mock_cfg))) result = mod._load_hitl_config() assert isinstance(result, mod.HITLConfig) assert result.default_timeout == 300.0 assert result.bypass_roles == [] # ============================================================================ # Gap 2: pause_task timeout path — audit log_event raises inside except block # ============================================================================ class TestPauseTaskTimeoutAuditFails: @pytest.mark.asyncio async def test_pause_task_timeout_audit_log_event_raises(self, monkeypatch): """Lines 439-440: audit log_event raises inside timeout handler — except Exception: pass swallows it.""" mod = _load_hitl(monkeypatch) reg = mod._TaskPauseRegistry() monkeypatch.setattr(mod, "pause_registry", reg) monkeypatch.setattr(mod, "_load_hitl_config", lambda: mod.HITLConfig(default_timeout=0.01)) # Make tools.audit.log_event raise an exception — only affects the import # inside the timeout handler (from builtin_tools.audit import log_event) raising_audit = MagicMock() raising_audit.log_event = MagicMock(side_effect=RuntimeError("audit exploded")) raising_audit.check_permission = MagicMock(return_value=True) raising_audit.get_workspace_roles = MagicMock(return_value=(["operator"], {})) monkeypatch.setitem(sys.modules, "builtin_tools.audit", raising_audit) # Should timeout and swallow the audit exception result = await mod.pause_task("timeout-audit-fail", "will timeout") assert result["resumed"] is False assert "error" in result assert "timed out" in result["error"].lower() or "timeout" in result["error"].lower()