"""Tests for the React backend host protocol.""" from __future__ import annotations import asyncio import io import json from types import SimpleNamespace import pytest from openharness.api.client import ApiMessageCompleteEvent from openharness.api.usage import UsageSnapshot from openharness.engine.stream_events import CompactProgressEvent from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock from openharness.config.settings import Settings, save_settings from openharness.ui.backend_host import ( BackendHostConfig, ReactBackendHost, _build_user_message_with_images, _format_transcript_line, run_backend_host, ) from openharness.ui.protocol import BackendEvent, FrontendImageAttachment, FrontendRequest from openharness.ui.runtime import build_runtime, close_runtime, start_runtime class StaticApiClient: """Fake streaming client for backend host tests.""" def __init__(self, text: str) -> None: self._text = text async def stream_message(self, request): del request yield ApiMessageCompleteEvent( message=ConversationMessage(role="assistant", content=[TextBlock(text=self._text)]), usage=UsageSnapshot(input_tokens=2, output_tokens=3), stop_reason=None, ) class CapturingApiClient(StaticApiClient): """Fake streaming client that records model requests.""" def __init__(self, text: str) -> None: super().__init__(text) self.requests = [] async def stream_message(self, request): self.requests.append(request) async for event in super().stream_message(request): yield event class FailingApiClient: """Fake client that triggers the query-loop ErrorEvent path.""" def __init__(self, message: str) -> None: self._message = message async def stream_message(self, request): del request if False: yield None raise RuntimeError(self._message) class FakeBinaryStdout: """Capture protocol writes through a binary stdout buffer.""" def __init__(self) -> None: self.buffer = io.BytesIO() def flush(self) -> None: return None def test_frontend_request_accepts_image_attachments(): request = FrontendRequest.model_validate( { "type": "submit_line", "line": "describe this", "images": [ { "media_type": "image/png", "data": "aGVsbG8=", "source_path": "clipboard:clipboard.png", } ], } ) assert request.images[0].media_type == "image/png" assert request.images[0].data == "aGVsbG8=" def test_frontend_request_rejects_non_image_attachments(): with pytest.raises(ValueError): FrontendRequest.model_validate( { "type": "submit_line", "images": [ { "media_type": "text/plain", "data": "aGVsbG8=", } ], } ) def test_build_user_message_with_images(): message = _build_user_message_with_images( "What is in this screenshot?", [ FrontendImageAttachment( media_type="image/png", data="aGVsbG8=", source_path="clipboard:clipboard.png", ) ], ) assert message is not None assert message.text == "What is in this screenshot?" assert isinstance(message.content[1], ImageBlock) assert message.content[1].media_type == "image/png" assert message.content[1].source_path == "clipboard:clipboard.png" def test_format_transcript_line_mentions_attached_images(): assert _format_transcript_line("inspect", []) == "inspect" assert _format_transcript_line("", [FrontendImageAttachment(media_type="image/png", data="x")]) == "[1 image attached]" assert _format_transcript_line( "inspect", [ FrontendImageAttachment(media_type="image/png", data="x"), FrontendImageAttachment(media_type="image/jpeg", data="y"), ], ) == "inspect\n[2 images attached]" @pytest.mark.asyncio async def test_run_backend_host_accepts_permission_mode(monkeypatch): captured: dict[str, str | None] = {} async def _fake_run(self): captured["permission_mode"] = self._config.permission_mode return 0 monkeypatch.setattr("openharness.ui.backend_host.ReactBackendHost.run", _fake_run) result = await run_backend_host( api_client=StaticApiClient("unused"), permission_mode="full_auto", ) assert result == 0 assert captured["permission_mode"] == "full_auto" @pytest.mark.asyncio async def test_read_requests_resolves_permission_response_without_queueing(monkeypatch): host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) fut = asyncio.get_running_loop().create_future() host._permission_requests["req-1"] = fut payload = b'{"type":"permission_response","request_id":"req-1","allowed":true}\n' class _FakeBuffer: def __init__(self): self._reads = 0 def readline(self): self._reads += 1 if self._reads == 1: return payload return b"" class _FakeStdin: buffer = _FakeBuffer() monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin()) await host._read_requests() assert fut.done() assert fut.result() is True queued = await host._request_queue.get() assert queued.type == "shutdown" assert host._request_queue.empty() @pytest.mark.asyncio async def test_read_requests_resolves_edit_approval_response_without_queueing(monkeypatch): host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) fut = asyncio.get_running_loop().create_future() host._edit_approval_requests["req-1"] = fut payload = b'{"type":"permission_response","request_id":"req-1","allowed":true,"permission_reply":"always"}\n' class _FakeBuffer: def __init__(self): self._reads = 0 def readline(self): self._reads += 1 if self._reads == 1: return payload return b"" class _FakeStdin: buffer = _FakeBuffer() monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin()) await host._read_requests() assert fut.done() assert fut.result() == "always" queued = await host._request_queue.get() assert queued.type == "shutdown" assert host._request_queue.empty() @pytest.mark.asyncio async def test_read_requests_interrupt_cancels_active_request(monkeypatch): host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) async def _long_running(): await asyncio.Event().wait() task = asyncio.create_task(_long_running()) host._active_request_task = task class _FakeBuffer: def __init__(self): self._reads = 0 def readline(self): self._reads += 1 if self._reads == 1: return b'{"type":"interrupt"}\n' return b"" class _FakeStdin: buffer = _FakeBuffer() monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin()) await host._read_requests() with pytest.raises(asyncio.CancelledError): await task queued = await host._request_queue.get() assert queued.type == "shutdown" @pytest.mark.asyncio async def test_run_active_request_recovers_from_cancel(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) async def _long_running(): await asyncio.Event().wait() return True try: runner = asyncio.create_task(host._run_active_request(_long_running())) while host._active_request_task is None: await asyncio.sleep(0) await host._interrupt_active_request() assert await runner is True finally: await close_runtime(host._bundle) assert any( event.type == "transcript_item" and event.item and event.item.role == "system" and "Interrupted" in event.item.text for event in events ) assert any(event.type == "line_complete" for event in events) @pytest.mark.asyncio async def test_backend_host_processes_command(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._process_line("/version") finally: await close_runtime(host._bundle) assert should_continue is True assert any(event.type == "transcript_item" and event.item and event.item.role == "user" for event in events) assert any( event.type == "transcript_item" and event.item and event.item.role == "system" and "OpenHarness" in event.item.text for event in events ) assert any(event.type == "state_snapshot" for event in events) @pytest.mark.asyncio async def test_backend_host_processes_model_turn(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("hello from react backend"))) host._bundle = await build_runtime(api_client=StaticApiClient("hello from react backend")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._process_line("hi") finally: await close_runtime(host._bundle) assert should_continue is True assert any( event.type == "assistant_complete" and event.message == "hello from react backend" for event in events ) assert any( event.type == "assistant_complete" and event.item and event.item.role == "assistant" and "hello from react backend" in event.item.text for event in events ) @pytest.mark.asyncio async def test_backend_host_processes_image_turn(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) client = CapturingApiClient("image received") host = ReactBackendHost(BackendHostConfig(api_client=client)) host._bundle = await build_runtime(api_client=client) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._process_line( "", images=[ FrontendImageAttachment( media_type="image/png", data="aGVsbG8=", source_path="clipboard:clipboard.png", ) ], ) finally: await close_runtime(host._bundle) assert should_continue is True assert len(client.requests) == 1 image_messages = [ message for message in client.requests[0].messages if message.role == "user" and any(isinstance(block, ImageBlock) for block in message.content) ] assert len(image_messages) == 1 message = image_messages[0] assert message.text == "Please analyze the attached image." image = next(block for block in message.content if isinstance(block, ImageBlock)) assert image.data == "aGVsbG8=" assert any( event.type == "transcript_item" and event.item and event.item.role == "user" and event.item.text == "[1 image attached]" for event in events ) @pytest.mark.asyncio async def test_backend_host_emits_compact_progress_event(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) async def _fake_handle_line( bundle, line, print_system, render_event, clear_output, user_message=None ): del bundle, line, print_system, clear_output, user_message await render_event( CompactProgressEvent( phase="compact_start", trigger="auto", message="Compacting conversation memory.", checkpoint="compact_start", metadata={"token_count": 12345}, ) ) return True monkeypatch.setattr("openharness.ui.backend_host.handle_line", _fake_handle_line) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._process_line("hi") finally: await close_runtime(host._bundle) assert should_continue is True assert any( event.type == "compact_progress" and event.compact_phase == "compact_start" and event.compact_checkpoint == "compact_start" and event.compact_metadata == {"token_count": 12345} for event in events ) @pytest.mark.asyncio async def test_backend_host_surfaces_query_errors(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=FailingApiClient("rate limit"))) host._bundle = await build_runtime(api_client=FailingApiClient("rate limit")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._process_line("hi") finally: await close_runtime(host._bundle) assert should_continue is True assert any(event.type == "error" and "rate limit" in event.message for event in events) assert any( event.type == "transcript_item" and event.item and event.item.role == "system" and "rate limit" in event.item.text for event in events ) @pytest.mark.asyncio async def test_backend_host_command_does_not_reset_cli_overrides(tmp_path, monkeypatch): """Regression: slash commands should not snap model/provider back to persisted defaults. When the session is launched with CLI overrides (e.g. --provider openai -m 5.4), issuing a command like /fast triggers a UI state refresh. That refresh must preserve the effective session settings, not reload ~/.openharness/settings.json verbatim. """ monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime( api_client=StaticApiClient("unused"), model="5.4", api_format="openai", ) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: # Sanity: the initial session state reflects CLI overrides. assert host._bundle.app_state.get().model == "5.4" assert host._bundle.app_state.get().provider == "openai-compatible" # Run a command that triggers sync_app_state. await host._process_line("/fast show") # CLI overrides should remain in effect. assert host._bundle.app_state.get().model == "5.4" assert host._bundle.app_state.get().provider == "openai-compatible" finally: await close_runtime(host._bundle) @pytest.mark.asyncio async def test_backend_host_uses_effective_model_from_env_override(tmp_path, monkeypatch): """Regression: header model should reflect effective env override, not stale profile last_model.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) monkeypatch.setenv("OPENHARNESS_MODEL", "minimax-m1") host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: assert host._bundle.app_state.get().model == "minimax-m1" # Exercise sync_app_state through a slash command refresh path. await host._process_line("/fast show") assert host._bundle.app_state.get().model == "minimax-m1" finally: await close_runtime(host._bundle) @pytest.mark.asyncio async def test_build_runtime_leaves_interactive_sessions_unbounded_by_default(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) bundle = await build_runtime( api_client=StaticApiClient("unused"), enforce_max_turns=False, ) try: assert bundle.engine.max_turns is None assert bundle.enforce_max_turns is False finally: await close_runtime(bundle) @pytest.mark.asyncio async def test_backend_host_emits_utf8_protocol_bytes(monkeypatch): host = ReactBackendHost(BackendHostConfig()) fake_stdout = FakeBinaryStdout() monkeypatch.setattr("openharness.ui.backend_host.sys.stdout", fake_stdout) await host._emit(BackendEvent(type="assistant_delta", message="你好😊")) raw = fake_stdout.buffer.getvalue() assert raw.startswith(b"OHJSON:") decoded = raw.decode("utf-8").strip() payload = json.loads(decoded.removeprefix("OHJSON:")) assert payload["type"] == "assistant_delta" assert payload["message"] == "你好😊" @pytest.mark.asyncio async def test_backend_host_emits_model_select_request(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused"), model="opus", api_format="anthropic") events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: await host._handle_select_command("model") finally: await close_runtime(host._bundle) event = next(item for item in events if item.type == "select_request") assert event.modal["command"] == "model" assert any(option["value"] == "opus" and option.get("active") for option in event.select_options) assert any(option["value"] == "default" for option in event.select_options) @pytest.mark.asyncio async def test_backend_host_model_selector_uses_profile_model_allowlist(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) save_settings( Settings().model_copy( update={ "active_profile": "local-llm", "provider": "openai", "api_format": "openai", "base_url": "http://localhost:8000/v1", "model": "qwen-vl", "profiles": { "local-llm": { "label": "Local LLM", "provider": "openai", "api_format": "openai", "auth_source": "openai_api_key", "default_model": "deepseek-chat", "last_model": "qwen-vl", "base_url": "http://localhost:8000/v1", "allowed_models": ["deepseek-chat", "qwen-vl"], } }, } ) ) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: await host._handle_select_command("model") finally: await close_runtime(host._bundle) event = next(item for item in events if item.type == "select_request") assert [option["value"] for option in event.select_options] == ["deepseek-chat", "qwen-vl"] assert any(option["value"] == "qwen-vl" and option.get("active") for option in event.select_options) @pytest.mark.asyncio async def test_backend_host_emits_theme_select_request(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: await host._handle_select_command("theme") finally: await close_runtime(host._bundle) event = next(item for item in events if item.type == "select_request") assert event.modal["command"] == "theme" assert any(option["value"] == "default" for option in event.select_options) @pytest.mark.asyncio async def test_backend_host_emits_turns_select_request_with_unlimited_option(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused"), enforce_max_turns=False) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: await host._handle_select_command("turns") finally: await close_runtime(host._bundle) event = next(item for item in events if item.type == "select_request") assert event.modal["command"] == "turns" assert any(option["value"] == "unlimited" and option.get("active") for option in event.select_options) @pytest.mark.asyncio async def test_backend_host_emits_provider_select_request(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: await host._handle_select_command("provider") finally: await close_runtime(host._bundle) event = next(item for item in events if item.type == "select_request") assert event.modal["command"] == "provider" assert any(option["value"] == "claude-api" and option.get("active") for option in event.select_options) @pytest.mark.asyncio async def test_backend_host_apply_select_command_shows_single_segment_transcript(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._apply_select_command("theme", "default") finally: await close_runtime(host._bundle) assert should_continue is True user_event = next(item for item in events if item.type == "transcript_item" and item.item and item.item.role == "user") assert user_event.item.text == "/theme" @pytest.mark.asyncio async def test_backend_host_apply_provider_select_command_shows_single_segment_transcript(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) host._bundle = await build_runtime(api_client=StaticApiClient("unused")) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] await start_runtime(host._bundle) try: should_continue = await host._apply_select_command("provider", "claude-api") finally: await close_runtime(host._bundle) assert should_continue is True user_event = next(item for item in events if item.type == "transcript_item" and item.item and item.item.role == "user") assert user_event.item.text == "/provider" @pytest.mark.asyncio async def test_concurrent_ask_permission_are_serialised(): """Concurrent _ask_permission calls must be serialised so the frontend never receives two overlapping modal_request events. Without _permission_lock the second call emits a modal_request before the first future is resolved, overwriting the frontend's modal state. The first tool then silently waits 300 s and gets Permission denied. """ host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) emitted_order: list[str] = [] async def _fake_emit(event: BackendEvent) -> None: if event.type == "modal_request" and event.modal: emitted_order.append(str(event.modal.get("request_id", ""))) host._emit = _fake_emit # type: ignore[method-assign] async def _ask_and_approve(tool: str) -> bool: # Start the ask; a background task resolves the future once it appears. async def _resolver(): # Busy-wait until this tool's future is registered. while True: await asyncio.sleep(0) for rid, fut in list(host._permission_requests.items()): if not fut.done(): fut.set_result(True) return asyncio.create_task(_resolver()) return await host._ask_permission(tool, "reason") # Fire two permission requests concurrently. result_a, result_b = await asyncio.gather( _ask_and_approve("write_file"), _ask_and_approve("bash"), ) assert result_a is True assert result_b is True # With the lock in place the two modal_request events must be emitted # sequentially (one completes before the other starts), so exactly two # distinct request IDs must have been emitted. assert len(emitted_order) == 2 assert emitted_order[0] != emitted_order[1] @pytest.mark.asyncio async def test_ask_edit_approval_remembers_always_choice(): host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] async def _resolver(): while not host._edit_approval_requests: await asyncio.sleep(0) next(iter(host._edit_approval_requests.values())).set_result("always") asyncio.create_task(_resolver()) reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1) assert reply == "always" assert host._edit_always_approved is True assert any( event.type == "modal_request" and event.modal and event.modal.get("kind") == "edit_diff" for event in events ) events.clear() second_reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1) assert second_reply == "always" assert events == [] @pytest.mark.asyncio async def test_ask_edit_approval_skips_when_session_mode_is_full_auto(): host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) events = [] async def _emit(event): events.append(event) host._emit = _emit # type: ignore[method-assign] host._bundle = SimpleNamespace( app_state=SimpleNamespace(get=lambda: SimpleNamespace(permission_mode="full_auto")) ) reply = await host._ask_edit_approval("notes.txt", "@@ -1 +1 @@\n-old\n+new", 1, 1) assert reply == "always" assert events == []