"""Unit tests for DeepAgentsApp."""

from __future__ import annotations

import asyncio
import contextlib
import inspect
import locale
import logging
import os
import signal
import time
import webbrowser
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, call, patch

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Iterator

    from deepagents_code.notifications import PendingNotification
    from deepagents_code.sessions import ThreadInfo

import pytest
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.content import Content
from textual.css.query import NoMatches
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Checkbox, Input, Static

from deepagents_code._version import CHANGELOG_URL
from deepagents_code.app import (
    _DEEPAGENTS_IMPORT_LOCK,
    _TYPING_IDLE_THRESHOLD_SECONDS,
    DeepAgentsApp,
    DeferredAction,
    ExternalInput,
    QueuedMessage,
    TextualSessionState,
    _build_whats_new_message,
    _extra_is_ready,
)
from deepagents_code.event_bus import ExternalEvent
from deepagents_code.widgets.chat_input import ChatInput
from deepagents_code.widgets.launch_init import LaunchNameScreen
from deepagents_code.widgets.messages import (
    AppMessage,
    ErrorMessage,
    QueuedUserMessage,
    SummarizationMessage,
    UserMessage,
)


async def _wait_for_branch(app: DeepAgentsApp, branch: str) -> None:
    """Wait until the status bar reports the expected git branch."""
    for _ in range(100):
        if app._status_bar is not None and app._status_bar.branch == branch:
            return
        await asyncio.sleep(0.01)
    msg = f"Timed out waiting for branch {branch!r}"
    raise AssertionError(msg)


def _closing_run_worker_mock(
    work: object, *args: object, **kwargs: object
) -> MagicMock:
    """Close coroutine work swallowed by a `run_worker` mock."""
    del args, kwargs
    if inspect.iscoroutine(work):
        work.close()
    return MagicMock()


class TestWhatsNewMessage:
    """Tests for the post-upgrade banner content."""

    def test_changelog_url_is_clickable(self) -> None:
        """The changelog URL should be carried as a Textual link span."""
        content = _build_whats_new_message("Updated to v1.2.3")

        assert content.plain == f"Updated to v1.2.3\nSee what's new: {CHANGELOG_URL}"
        links = [
            link
            for span in content.spans
            if (link := getattr(span.style, "link", None))
        ]
        assert links == [CHANGELOG_URL]


class TestInitialPromptOnMount:
    """Test that -m initial prompt is submitted on mount."""

    async def test_initial_prompt_triggers_handle_user_message(self) -> None:
        """When initial_prompt is set, the prompt should be auto-submitted."""
        mock_agent = MagicMock()
        app = DeepAgentsApp(
            agent=mock_agent,
            thread_id="new-thread-123",
            initial_prompt="hello world",
        )
        submitted: list[str] = []

        # Must be async to match _handle_user_message's signature
        async def capture(msg: str) -> None:  # noqa: RUF029
            submitted.append(msg)

        app._handle_user_message = capture  # ty: ignore

        async with app.run_test() as pilot:
            # Give call_after_refresh time to fire
            await pilot.pause()
            await pilot.pause()

        assert submitted == ["hello world"]

    async def test_initial_skill_triggers_invoke_skill(self) -> None:
        """When `--skill` is set, startup should invoke that skill."""
        mock_agent = MagicMock()
        app = DeepAgentsApp(
            agent=mock_agent,
            thread_id="new-thread-123",
            initial_prompt="  keep leading whitespace",
            initial_skill="code-review",
        )
        submitted: list[tuple[str, str, str | None]] = []

        async def capture(  # noqa: RUF029
            skill_name: str,
            args: str = "",
            *,
            command: str | None = None,
        ) -> None:
            submitted.append((skill_name, args, command))

        app._invoke_skill = capture  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

        assert submitted == [("code-review", "  keep leading whitespace", None)]

    async def test_initial_skill_runs_after_server_ready(self) -> None:
        """Deferred startup should invoke the requested skill after connect."""
        app = DeepAgentsApp(
            thread_id="new-thread-123",
            initial_prompt="review this diff",
            initial_skill="code-review",
        )
        app._connecting = True
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore
        app.call_after_refresh = lambda cb: cb()  # ty: ignore
        submitted: list[tuple[str, str, str | None]] = []

        async def capture(  # noqa: RUF029
            skill_name: str,
            args: str = "",
            *,
            command: str | None = None,
        ) -> None:
            submitted.append((skill_name, args, command))

        app._invoke_skill = capture  # ty: ignore

        app.on_deep_agents_app_server_ready(
            app.ServerReady(
                agent=MagicMock(),
                server_proc=None,
                mcp_server_info=[],
            )
        )
        # Server-ready schedules `_run_session_start_sequence` onto the loop.
        # A few yields keep the test stable across that async handoff.
        for _ in range(3):
            await asyncio.sleep(0)

        assert submitted == [("code-review", "review this diff", None)]

    async def test_server_ready_refreshes_status_bar_model(self) -> None:
        """ServerReady should push current settings into the StatusBar model display.

        Regression for the case where `/model` recovers from a failed startup
        (`ModelConfigError`) — the status bar was only seeded at mount and
        never refreshed when `_retry_startup_with_model` swapped the provider.
        `spec=StatusBar` makes the test fail if `set_model`'s keyword-only
        signature drifts.
        """
        from deepagents_code.widgets.status import StatusBar

        app = DeepAgentsApp(thread_id="thread-123")
        app._connecting = True
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore
        app.call_after_refresh = lambda cb: cb()  # ty: ignore
        status_bar = MagicMock(spec=StatusBar)
        app._status_bar = status_bar

        with patch("deepagents_code.config.settings") as mock_settings:
            mock_settings.model_provider = "anthropic"
            mock_settings.model_name = "claude-opus-4-7"
            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=[],
                )
            )
            for _ in range(3):
                await asyncio.sleep(0)

        status_bar.set_model.assert_called_once_with(
            provider="anthropic", model="claude-opus-4-7"
        )

    async def test_server_ready_warns_when_status_bar_missing(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """A missing `_status_bar` at ServerReady is a defect — surface it.

        Mirrors the welcome-banner branch which logs on `NoMatches`.
        """
        app = DeepAgentsApp(thread_id="thread-123")
        app._connecting = True
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore
        app.call_after_refresh = lambda cb: cb()  # ty: ignore
        app._status_bar = None

        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=[],
                )
            )
            for _ in range(3):
                await asyncio.sleep(0)

        assert any(
            "Status bar not found" in record.message for record in caplog.records
        )

    async def test_server_ready_warns_when_settings_model_missing(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Missing model identity at ServerReady should warn, not silently blank."""
        from deepagents_code.widgets.status import StatusBar

        app = DeepAgentsApp(thread_id="thread-123")
        app._connecting = True
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore
        app.call_after_refresh = lambda cb: cb()  # ty: ignore
        status_bar = MagicMock(spec=StatusBar)
        app._status_bar = status_bar

        with (
            patch("deepagents_code.config.settings") as mock_settings,
            caplog.at_level(logging.WARNING, logger="deepagents_code.app"),
        ):
            mock_settings.model_provider = None
            mock_settings.model_name = None
            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=[],
                )
            )
            for _ in range(3):
                await asyncio.sleep(0)

        # Still calls set_model with the falsy-coerced strings so the widget
        # doesn't render stale state — but emits a warning so the misconfig
        # isn't invisible.
        status_bar.set_model.assert_called_once_with(provider="", model="")
        assert any(
            "Settings missing model identity" in record.message
            for record in caplog.records
        )

    async def test_deferred_start_preserves_initial_prompt_until_server_ready(
        self,
    ) -> None:
        """No-credentials startup should not consume `-m` before connect."""
        app = DeepAgentsApp(
            thread_id="new-thread-123",
            initial_prompt="hello after auth",
            server_kwargs={"assistant_id": "agent", "model_name": None},
            defer_server_start=True,
        )
        submitted: list[str] = []

        async def capture(msg: str) -> None:  # noqa: RUF029
            submitted.append(msg)

        app._handle_user_message = capture  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()
            assert submitted == []
            assert app._has_initial_submission()

            app._server_startup_deferred = False
            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=[],
                )
            )
            for _ in range(3):
                await pilot.pause()

        assert submitted == ["hello after auth"]

    async def test_deferred_start_mounts_auth_guidance(self) -> None:
        """First launch without credentials should show next-step guidance."""
        app = DeepAgentsApp(
            server_kwargs={"assistant_id": "agent", "model_name": None},
            defer_server_start=True,
        )
        messages: list[AppMessage] = []

        async def capture(message: AppMessage) -> None:  # noqa: RUF029
            messages.append(message)

        def fake_run_worker(work: object, *args: object, **kwargs: object) -> MagicMock:
            del args, kwargs
            if inspect.iscoroutine(work):
                work.close()
            return MagicMock()

        app._mount_message = capture  # ty: ignore
        app.run_worker = fake_run_worker  # ty: ignore

        with patch(
            "deepagents_code.update_check.is_update_check_enabled",
            return_value=False,
        ):
            await app._post_paint_init()

        assert len(messages) == 1
        assert "/model" in str(messages[0].content)
        assert "credentials" in str(messages[0].content)


class TestStartupSequence:
    """Tests for post-connect startup sequencing."""

    async def test_session_start_sequence_is_idempotent_across_server_ready(
        self,
    ) -> None:
        """Subsequent `ServerReady` events must not re-run history hydration.

        Regression for the `/mcp reconnect` and `/restart` flows: respawns
        post a fresh `ServerReady`, which would otherwise re-run
        `_load_thread_history` against an already-populated `MessageStore`
        and raise `DuplicateIds` during widget mount.
        """
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            resume_thread="thread-123",
        )
        call_count = 0

        async def capture_history(  # noqa: RUF029
            *,
            thread_id: str | None = None,
            preloaded_payload: object | None = None,
        ) -> None:
            del thread_id, preloaded_payload
            nonlocal call_count
            call_count += 1

        app._load_thread_history = capture_history  # ty: ignore

        await app._run_session_start_sequence()
        await app._run_session_start_sequence()
        await app._run_session_start_sequence()

        assert call_count == 1
        assert app._initial_session_started is True

    async def test_reconnect_drains_queue_without_reloading_history(self) -> None:
        """Later `ServerReady` events should drain queued input once connected."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            resume_thread="thread-123",
        )
        app._initial_session_started = True
        app._pending_messages.append(QueuedMessage(text="queued", mode="normal"))
        load_history = AsyncMock()
        drain_deferred = AsyncMock()
        process_next = AsyncMock()
        app._load_thread_history = load_history  # ty: ignore
        app._maybe_drain_deferred = drain_deferred  # ty: ignore
        app._process_next_from_queue = process_next  # ty: ignore

        await app._run_session_start_sequence()

        load_history.assert_not_awaited()
        drain_deferred.assert_awaited_once()
        process_next.assert_awaited_once()

    async def test_resumed_history_loads_before_startup_command(self) -> None:
        """Resumed threads should mount prior history before startup output."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            resume_thread="thread-123",
            startup_cmd="echo hi",
        )
        order: list[str] = []

        async def capture_history(  # noqa: RUF029
            *,
            thread_id: str | None = None,
            preloaded_payload: object | None = None,
        ) -> None:
            del thread_id, preloaded_payload
            order.append("history")

        async def capture_startup(command: str) -> None:  # noqa: RUF029
            assert command == "echo hi"
            order.append("startup")

        app._load_thread_history = capture_history  # ty: ignore
        app._run_startup_command = capture_startup  # ty: ignore

        await app._run_session_start_sequence()

        assert order == ["history", "startup"]
        assert app._startup_sequence_running is False

    @pytest.mark.parametrize(
        "kwargs",
        [
            {"initial_prompt": "hello world"},
            {"initial_skill": "code-review"},
        ],
    )
    async def test_resumed_model_adopts_before_initial_submission(
        self,
        kwargs: dict[str, str],
    ) -> None:
        """Resume + startup submission should switch before first agent call."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            **kwargs,  # ty: ignore  # parametrized str kwargs
        )
        app._resume_thread_intent = None
        app._should_adopt_resumed_model = True
        order: list[str] = []

        async def capture_state(thread_id: str) -> dict[str, object]:  # noqa: RUF029
            assert thread_id == "thread-123"
            order.append("state")
            return {"_model_spec": "anthropic:claude-sonnet-4-5"}

        async def capture_switch(  # noqa: RUF029
            model_spec: str,
            *,
            announce_unchanged: bool = True,
            persist: bool = True,
            from_resume: bool = False,
        ) -> None:
            assert model_spec == "anthropic:claude-sonnet-4-5"
            assert announce_unchanged is False
            assert persist is False
            assert from_resume is True
            order.append("switch")

        async def capture_initial_submission() -> None:  # noqa: RUF029
            order.append("initial")

        app._get_thread_state_values = capture_state  # ty: ignore
        app._switch_model = capture_switch  # ty: ignore
        app._submit_initial_submission = capture_initial_submission  # ty: ignore
        load_history = AsyncMock()
        app._load_thread_history = load_history  # ty: ignore

        await app._run_session_start_sequence()

        assert order == ["state", "switch", "initial"]
        assert app._should_adopt_resumed_model is False
        load_history.assert_not_awaited()
        assert app._startup_sequence_running is False

    async def test_resumed_model_adoption_failure_blocks_initial_submission(
        self,
    ) -> None:
        """State-read failures should not submit into an unverified resume."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            initial_prompt="hello world",
        )
        app._resume_thread_intent = None
        app._should_adopt_resumed_model = True
        order: list[str] = []

        async def fail_state(thread_id: str) -> dict[str, object]:  # noqa: RUF029
            assert thread_id == "thread-123"
            order.append("state")
            msg = "checkpoint unavailable"
            raise RuntimeError(msg)

        async def capture_initial_submission() -> None:  # noqa: RUF029
            order.append("initial")

        app._get_thread_state_values = fail_state  # ty: ignore
        app._submit_initial_submission = capture_initial_submission  # ty: ignore
        mount_message = AsyncMock()
        app._mount_message = mount_message  # ty: ignore

        await app._run_session_start_sequence()

        assert order == ["state"]
        assert app._should_adopt_resumed_model is False
        assert app._startup_sequence_running is False
        mount_message.assert_awaited_once()

    async def test_startup_cleanup_defers_queue_until_initial_submission(self) -> None:
        """Queued input should wait until startup submission owns the agent slot."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            initial_prompt="hello world",
            startup_cmd="echo hi",
        )
        order: list[str] = []

        async def capture_startup(command: str) -> None:
            assert command == "echo hi"
            order.append("startup")
            app._pending_messages.append(
                QueuedMessage(text="typed during startup", mode="normal")
            )
            await app._cleanup_shell_task()

        async def capture_initial_submission() -> None:  # noqa: RUF029
            order.append("initial")
            app._agent_running = True

        queue_mock = AsyncMock()
        app._run_startup_command = capture_startup  # ty: ignore
        app._submit_initial_submission = (  # ty: ignore
            capture_initial_submission
        )
        app._process_next_from_queue = queue_mock  # ty: ignore

        await app._run_session_start_sequence()

        assert order == ["startup", "initial"]
        queue_mock.assert_not_awaited()
        assert len(app._pending_messages) == 1
        assert app._pending_messages[0].text == "typed during startup"
        assert app._startup_sequence_running is False

    async def test_cleanup_shell_task_defers_queue_during_startup(self) -> None:
        """`_cleanup_shell_task` must not drain the queue while sequencing."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._startup_sequence_running = True
        app._pending_messages.append(QueuedMessage(text="queued", mode="normal"))
        drain_mock = AsyncMock()
        queue_mock = AsyncMock()
        app._process_next_from_queue = queue_mock  # ty: ignore
        app._maybe_drain_deferred = drain_mock  # ty: ignore

        await app._cleanup_shell_task()

        queue_mock.assert_not_awaited()
        assert app._shell_running is False

    async def test_cleanup_agent_task_defers_queue_during_startup(self) -> None:
        """`_cleanup_agent_task` must not drain the queue while sequencing."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._startup_sequence_running = True
        app._pending_messages.append(QueuedMessage(text="queued", mode="normal"))
        drain_mock = AsyncMock()
        queue_mock = AsyncMock()
        spinner_mock = AsyncMock()
        app._process_next_from_queue = queue_mock  # ty: ignore
        app._maybe_drain_deferred = drain_mock  # ty: ignore
        app._set_spinner = spinner_mock  # ty: ignore

        await app._cleanup_agent_task()

        queue_mock.assert_not_awaited()
        assert app._agent_running is False

    async def test_cleanup_agent_task_schedules_git_branch_refresh(self) -> None:
        """Agent cleanup should refresh repo state after a turn completes."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        drain_mock = AsyncMock()
        queue_mock = AsyncMock()
        spinner_mock = AsyncMock()
        refresh_mock = MagicMock()
        app._process_next_from_queue = queue_mock  # ty: ignore
        app._maybe_drain_deferred = drain_mock  # ty: ignore
        app._set_spinner = spinner_mock  # ty: ignore
        app._schedule_git_branch_refresh = refresh_mock  # ty: ignore

        await app._cleanup_agent_task()

        refresh_mock.assert_called_once_with()
        drain_mock.assert_awaited_once()
        queue_mock.assert_awaited_once()

    async def test_schedule_git_branch_refresh_noops_during_exit(self) -> None:
        """Shutdown should prevent new background git refresh tasks."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._exit = True

        with patch("deepagents_code.app.asyncio.create_task") as mock_create_task:
            app._schedule_git_branch_refresh()

        assert app._git_branch_refresh_task is None
        mock_create_task.assert_not_called()

    async def test_schedule_git_branch_refresh_inline_fast_path(
        self, tmp_path: Path
    ) -> None:
        """Filesystem probe should update the footer without spawning a task."""
        repo = tmp_path / "repo"
        git_dir = repo / ".git"
        git_dir.mkdir(parents=True)
        (git_dir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8")

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        status_bar = MagicMock()
        app._status_bar = status_bar
        app._cwd = str(repo)

        with patch("deepagents_code.app.asyncio.create_task") as mock_create_task:
            app._schedule_git_branch_refresh()

        assert status_bar.branch == "feature"
        mock_create_task.assert_not_called()
        assert app._git_branch_refresh_task is None

    async def test_schedule_git_branch_refresh_falls_back_to_subprocess(
        self,
    ) -> None:
        """Unusual repo layouts should spawn the off-thread subprocess fallback."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        status_bar = MagicMock()
        app._status_bar = status_bar

        fallback_mock = AsyncMock()
        app._refresh_git_branch_subprocess_fallback = (  # ty: ignore
            fallback_mock
        )

        with patch(
            "deepagents_code.app.read_git_branch_from_filesystem",
            return_value=None,
        ):
            app._schedule_git_branch_refresh()

        refresh_task = app._git_branch_refresh_task
        assert refresh_task is not None
        await refresh_task
        fallback_mock.assert_awaited_once_with(app._cwd)

    def test_empty_startup_cmd_is_normalized_to_none(self) -> None:
        """Empty or whitespace-only `--startup-cmd` should be treated as unset."""
        for raw in ("", "   ", "\t\n"):
            app = DeepAgentsApp(
                agent=MagicMock(), thread_id="thread-123", startup_cmd=raw
            )
            assert app._startup_cmd is None, f"Expected {raw!r} to normalize to None"

    async def test_startup_cmd_cleared_after_execution(self) -> None:
        """`_startup_cmd` should be cleared before the command runs (one-shot)."""
        app = DeepAgentsApp(
            agent=MagicMock(), thread_id="thread-123", startup_cmd="echo hi"
        )
        observed_cmd: list[str] = []
        observed_attr_during_run: list[str | None] = []

        async def capture_startup(command: str) -> None:  # noqa: RUF029
            observed_cmd.append(command)
            observed_attr_during_run.append(app._startup_cmd)

        async def stub_history(  # noqa: RUF029
            *,
            thread_id: str | None = None,
            preloaded_payload: object | None = None,
        ) -> None:
            del thread_id, preloaded_payload

        app._run_startup_command = capture_startup  # ty: ignore
        app._load_thread_history = stub_history  # ty: ignore

        await app._run_session_start_sequence()

        assert observed_cmd == ["echo hi"]
        assert observed_attr_during_run == [None]
        assert app._startup_cmd is None

    async def test_launch_init_runs_before_initial_submission(self) -> None:
        """Onboarding setup should complete before the startup prompt is submitted."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            thread_id="thread-123",
            initial_prompt="hello world",
            launch_init=True,
        )
        order: list[str] = []
        initial_submitted = asyncio.Event()

        async def capture_init() -> None:  # noqa: RUF029
            order.append("init")

        async def capture_initial_submission() -> None:  # noqa: RUF029
            order.append("initial")
            initial_submitted.set()

        app._run_launch_init_sequence = capture_init  # ty: ignore
        app._submit_initial_submission = (  # ty: ignore
            capture_initial_submission
        )

        await app._run_session_start_sequence()
        await asyncio.wait_for(initial_submitted.wait(), timeout=2)

        assert order == ["init", "initial"]
        assert app._launch_init_requested is False

    async def test_launch_init_name_screen_focuses_on_mount(self) -> None:
        """The first launch modal should be active and typeable immediately."""
        app = DeepAgentsApp(launch_init=True)
        app._prewarm_deferred_imports = MagicMock()  # ty: ignore
        app._resolve_git_branch_and_continue = AsyncMock()  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()

            assert isinstance(app.screen, LaunchNameScreen)
            name_input = app.screen.query_one("#launch-name-input", Input)
            assert name_input.has_focus

            await pilot.press("a", "d", "a")
            assert name_input.value == "ada"

            launch_task = app._launch_init_task
            assert launch_task is not None
            app.screen.action_cancel()
            await asyncio.wait_for(launch_task, timeout=2)
            await pilot.pause()

    async def test_server_ready_keeps_launch_name_screen_typeable(self) -> None:
        """Server-ready handling should not steal focus from the launch name field."""
        app = DeepAgentsApp(launch_init=True)
        app._prewarm_deferred_imports = MagicMock()  # ty: ignore
        app._resolve_git_branch_and_continue = AsyncMock()  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()

            assert isinstance(app.screen, LaunchNameScreen)
            name_input = app.screen.query_one("#launch-name-input", Input)
            assert name_input.has_focus

            app._connecting = True
            with patch.object(
                app, "_run_session_start_sequence", new_callable=AsyncMock
            ) as startup_mock:
                app.on_deep_agents_app_server_ready(
                    app.ServerReady(
                        agent=MagicMock(),
                        server_proc=None,
                        mcp_server_info=[],
                    )
                )
                await pilot.pause()
                await pilot.pause()

            startup_mock.assert_awaited_once()

            assert isinstance(app.screen, LaunchNameScreen)
            assert name_input.has_focus

            await pilot.press("a", "d", "a")
            assert name_input.value == "ada"

            await pilot.press("escape")
            await pilot.pause()

    async def test_server_ready_start_sequence_keeps_launch_name_screen_typeable(
        self,
    ) -> None:
        """The real post-connect startup task should not block name input."""
        app = DeepAgentsApp(launch_init=True)
        app._prewarm_deferred_imports = MagicMock()  # ty: ignore
        app._resolve_git_branch_and_continue = AsyncMock()  # ty: ignore
        app._maybe_drain_deferred = AsyncMock()  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()

            assert isinstance(app.screen, LaunchNameScreen)
            name_input = app.screen.query_one("#launch-name-input", Input)
            assert name_input.has_focus

            app._connecting = True
            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=[],
                )
            )
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen, LaunchNameScreen)
            assert name_input.has_focus

            await pilot.press("a", "d", "a")
            assert name_input.value == "ada"

            launch_task = app._launch_init_task
            assert launch_task is not None
            app.screen.action_cancel()
            await asyncio.wait_for(launch_task, timeout=2)
            await pilot.pause()

    async def test_launch_init_does_not_defer_server_startup(self) -> None:
        """Onboarding setup should still let the server startup worker begin."""
        app = DeepAgentsApp(
            launch_init=True,
            server_kwargs={"assistant_id": "agent", "model_name": None},
            model_kwargs={"model_spec": None},
        )
        app._resolve_git_branch_and_continue = AsyncMock()  # ty: ignore
        started_groups: list[str | None] = []

        def fake_run_worker(work: object, *args: object, **kwargs: object) -> MagicMock:
            del args
            group = kwargs.get("group")
            started_groups.append(group if isinstance(group, str) else None)
            if inspect.iscoroutine(work):
                work.close()
            return MagicMock()

        app.run_worker = fake_run_worker  # ty: ignore

        with patch(
            "deepagents_code.update_check.is_update_check_enabled",
            return_value=False,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._post_paint_init()

                assert "server-startup" in started_groups

                launch_task = app._launch_init_task
                assert launch_task is not None
                assert isinstance(app.screen, LaunchNameScreen)
                app.screen.action_cancel()
                await asyncio.wait_for(launch_task, timeout=2)

    async def test_launch_init_sequence_captures_name_and_switches_model(self) -> None:
        """Onboarding setup should store the name and apply the selected model."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id="thread-123",
        )
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        prompt_flow_mock = AsyncMock(return_value=(True, ("openai:gpt-5", "openai")))
        mount_message_mock = AsyncMock()
        events: list[str] = []
        switch_model_mock = AsyncMock(
            side_effect=lambda model_spec, **_: events.append(f"switch:{model_spec}")
        )
        app._prompt_launch_dependencies_then_model = prompt_flow_mock  # ty: ignore

        async def track_mount_message(message: AppMessage) -> None:
            events.append("welcome")
            await mount_message_mock(message)

        def track_mark_complete() -> bool:
            events.append("mark")
            return True

        app._switch_model = switch_model_mock  # ty: ignore
        app._mount_message = track_mount_message  # ty: ignore
        app._dispatch_launch_name_hook = MagicMock()  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                side_effect=track_mark_complete,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
        ):
            await app._run_launch_init_sequence()

        assert app._launch_user_name == "Ada"
        app._dispatch_launch_name_hook.assert_called_once_with("Ada", "coder")  # ty: ignore
        prompt_flow_mock.assert_awaited_once_with()
        write_name.assert_called_once_with("Ada", "coder")
        switch_model_mock.assert_awaited_once_with(
            "openai:gpt-5", announce_unchanged=False
        )
        mark_complete.assert_called_once_with()
        mount_message_mock.assert_awaited_once()
        assert events == ["switch:openai:gpt-5", "mark", "welcome"]

    async def test_launch_init_sequence_allows_empty_name(self) -> None:
        """Onboarding setup should continue to model selection without a name."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value="")  # ty: ignore
        prompt_flow_mock = AsyncMock(return_value=(True, ("openai:gpt-5", "openai")))
        switch_model_mock = AsyncMock()
        mount_message_mock = AsyncMock()
        app._prompt_launch_dependencies_then_model = prompt_flow_mock  # ty: ignore
        app._switch_model = switch_model_mock  # ty: ignore
        app._mount_message = mount_message_mock  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
        ):
            await app._run_launch_init_sequence()

        assert app._launch_user_name is None
        mount_message_mock.assert_not_awaited()
        prompt_flow_mock.assert_awaited_once_with()
        write_name.assert_not_called()
        switch_model_mock.assert_awaited_once_with(
            "openai:gpt-5", announce_unchanged=False
        )
        mark_complete.assert_called_once_with()

    async def test_launch_init_name_memory_does_not_delay_model_prompt(self) -> None:
        """Writing the optional name should not hold the dependency/model transition."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        model_prompted = asyncio.Event()
        release_write = asyncio.Event()

        async def write_name(_name: str) -> None:
            await release_write.wait()

        def prompt_flow() -> tuple[bool, tuple[str, str] | None]:
            model_prompted.set()
            return (True, None)

        app._write_launch_name_memory = AsyncMock(side_effect=write_name)  # ty: ignore
        app._prompt_launch_dependencies_then_model = AsyncMock(side_effect=prompt_flow)  # ty: ignore

        with patch(
            "deepagents_code.onboarding.mark_onboarding_complete",
            return_value=True,
        ) as mark_complete:
            task = asyncio.create_task(app._run_launch_init_sequence())
            await asyncio.wait_for(model_prompted.wait(), timeout=1)

            assert task.done() is False
            release_write.set()
            await asyncio.wait_for(task, timeout=1)

        app._write_launch_name_memory.assert_awaited_once_with("Ada")  # ty: ignore
        app._prompt_launch_dependencies_then_model.assert_awaited_once_with()  # ty: ignore
        mark_complete.assert_called_once_with()

    async def test_launch_init_sequence_skips_and_marks_complete(self) -> None:
        """Skipping the name screen should finish onboarding without model setup."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value=None)  # ty: ignore
        prompt_flow_mock = AsyncMock()
        switch_model_mock = AsyncMock()
        app._prompt_launch_dependencies_then_model = prompt_flow_mock  # ty: ignore
        app._switch_model = switch_model_mock  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
        ):
            await app._run_launch_init_sequence()

        prompt_flow_mock.assert_not_awaited()
        switch_model_mock.assert_not_awaited()
        write_name.assert_not_called()
        mark_complete.assert_called_once_with()

    async def test_launch_init_sequence_dependency_skip_remembers_name(self) -> None:
        """Skipping dependency info should remember a submitted name and stop."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id="thread-123",
        )
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        prompt_flow_mock = AsyncMock(return_value=(False, None))
        switch_model_mock = AsyncMock()
        app._prompt_launch_dependencies_then_model = prompt_flow_mock  # ty: ignore
        app._switch_model = switch_model_mock  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._dispatch_launch_name_hook = MagicMock()  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
        ):
            await app._run_launch_init_sequence()

        write_name.assert_called_once_with("Ada", "coder")
        app._dispatch_launch_name_hook.assert_called_once_with("Ada", "coder")  # ty: ignore
        prompt_flow_mock.assert_awaited_once_with()
        switch_model_mock.assert_not_awaited()
        app._mount_message.assert_awaited_once()  # ty: ignore
        mark_complete.assert_called_once_with()

    async def test_launch_init_sequence_model_skip_remembers_name(self) -> None:
        """Skipping model selection should still remember a submitted name."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id="thread-123",
        )
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        app._prompt_launch_dependencies_then_model = AsyncMock(  # ty: ignore
            return_value=(True, None)
        )
        switch_model_mock = AsyncMock()
        app._switch_model = switch_model_mock  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._dispatch_launch_name_hook = MagicMock()  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
        ):
            await app._run_launch_init_sequence()

        write_name.assert_called_once_with("Ada", "coder")
        app._dispatch_launch_name_hook.assert_called_once_with("Ada", "coder")  # ty: ignore
        switch_model_mock.assert_not_awaited()
        app._mount_message.assert_awaited_once()  # ty: ignore
        mark_complete.assert_called_once_with()

    async def test_launch_init_sequence_surfaces_switch_model_failure(self) -> None:
        """Failed onboarding model switch should toast and still mark complete."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        app._prompt_launch_dependencies_then_model = AsyncMock(  # ty: ignore
            return_value=(True, ("openai:gpt-5", "openai"))
        )
        switch_failure = RuntimeError("missing credentials")
        app._switch_model = AsyncMock(side_effect=switch_failure)  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._dispatch_launch_name_hook = MagicMock()  # ty: ignore
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        with (
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ),
        ):
            await app._run_launch_init_sequence()

        app._switch_model.assert_awaited_once()  # ty: ignore
        mark_complete.assert_called_once_with()
        notify_mock.assert_called_once()
        notify_kwargs = notify_mock.call_args.kwargs
        assert notify_kwargs.get("severity") == "error"
        assert notify_kwargs.get("markup") is False
        assert "missing credentials" in notify_mock.call_args.args[0]

    async def test_launch_init_sequence_surfaces_marker_failure(self) -> None:
        """A failed onboarding-complete write should surface a warning toast."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value=None)  # ty: ignore
        app._prompt_launch_dependencies_then_model = AsyncMock()  # ty: ignore
        app._switch_model = AsyncMock()  # ty: ignore
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        with patch(
            "deepagents_code.onboarding.mark_onboarding_complete",
            return_value=False,
        ):
            await app._run_launch_init_sequence()

        notify_mock.assert_called_once()
        notify_kwargs = notify_mock.call_args.kwargs
        assert notify_kwargs.get("severity") == "warning"
        assert notify_kwargs.get("markup") is False

    def test_dispatch_launch_name_hook_sends_name_payload(self) -> None:
        """The onboarding name hook should include the submitted name."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id="thread-123",
        )

        with patch(
            "deepagents_code.hooks.dispatch_hook_fire_and_forget"
        ) as dispatch_hook:
            app._dispatch_launch_name_hook("Ada", "coder")

        dispatch_hook.assert_called_once_with(
            "user.name.set",
            {
                "name": "Ada",
                "assistant_id": "coder",
            },
        )

    async def test_write_launch_name_waits_for_resume_agent_resolution(self) -> None:
        """The name hook should use the agent resolved from a resumed thread."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id=None,
            resume_thread="thread-from-coder",
            thread_id="thread-123",
        )

        with (
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ) as write_name,
            patch(
                "deepagents_code.hooks.dispatch_hook_fire_and_forget"
            ) as dispatch_hook,
        ):
            task = asyncio.create_task(app._write_launch_name_memory("Ada"))
            await asyncio.sleep(0)

            write_name.assert_not_called()
            dispatch_hook.assert_not_called()

            app._assistant_id = "coder"
            app._resume_thread_resolved_event.set()
            await asyncio.wait_for(task, timeout=1)

        write_name.assert_called_once_with("Ada", "coder")
        dispatch_hook.assert_called_once_with(
            "user.name.set",
            {
                "name": "Ada",
                "assistant_id": "coder",
            },
        )

    async def test_launch_init_sequence_times_out_waiting_for_server(self) -> None:
        """A stuck server should not trap onboarding past the timeout."""
        from deepagents_code import app as app_module

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._push_screen_wait = AsyncMock(return_value="Ada")  # ty: ignore
        app._prompt_launch_dependencies_then_model = AsyncMock(  # ty: ignore
            return_value=(True, ("openai:gpt-5", "openai"))
        )
        app._switch_model = AsyncMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._connecting = True
        app._dispatch_launch_name_hook = MagicMock()  # ty: ignore
        # Constructor pre-sets the readiness event when no server is configured;
        # clear it so the wait_for actually has to time out.
        app._connection_ready_event.clear()
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        with (
            patch.object(app_module, "_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS", 0.05),
            patch(
                "deepagents_code.onboarding.mark_onboarding_complete",
                return_value=True,
            ) as mark_complete,
            patch(
                "deepagents_code.onboarding.write_onboarding_name_memory",
                return_value=True,
            ),
        ):
            await app._run_launch_init_sequence()

        app._switch_model.assert_not_awaited()  # ty: ignore
        mark_complete.assert_called_once_with()
        notify_mock.assert_called_once()
        notify_kwargs = notify_mock.call_args.kwargs
        assert notify_kwargs.get("severity") == "warning"

    def test_curated_model_selector_uses_onboarding_copy(self) -> None:
        """Onboarding model selector should use dedicated title and description."""
        from deepagents_code.widgets.model_selector import ModelSelectorScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")

        screen = app._build_model_selector_screen(curated=True)

        assert isinstance(screen, ModelSelectorScreen)
        assert screen._title == "Choose a Recommended Model"
        assert (
            screen._description
            == "These models have performed well in Deep Agents evals and are "
            "a solid starting set. You can explore the full model list "
            "later with /model."
        )


class TestAppCSSValidation:
    """Test that app CSS is valid and doesn't cause runtime errors."""

    async def test_app_css_validates_on_mount(self) -> None:
        """App should mount without CSS validation errors.

        This test catches invalid CSS properties like 'overflow: visible'
        which are only validated at runtime when styles are applied.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            # Give the app time to render and apply CSS
            await pilot.pause()
            # If we get here without exception, CSS is valid
            assert app.is_running


class TestThreadCachePrewarm:
    """Tests for startup thread-cache prewarming."""

    async def test_prewarm_uses_current_thread_limit(self) -> None:
        """Prewarm helper should pass the resolved thread limit through."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")

        with (
            patch("deepagents_code.sessions.get_thread_limit", return_value=7),
            patch(
                "deepagents_code.sessions.prewarm_thread_message_counts",
                new_callable=AsyncMock,
            ) as mock_prewarm,
        ):
            await app._prewarm_threads_cache()

        mock_prewarm.assert_awaited_once_with(limit=7)

    async def test_show_thread_selector_uses_cached_rows(self) -> None:
        """Thread selector should receive prefetched rows when available."""
        cached_threads = [
            {
                "thread_id": "thread-abc",
                "agent_name": "agent1",
                "updated_at": "2024-01-01T00:00:00+00:00",
                "message_count": 2,
            }
        ]
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch("deepagents_code.sessions.get_thread_limit", return_value=9),
                patch(
                    "deepagents_code.sessions.get_cached_threads",
                    return_value=cached_threads,
                ),
                patch(
                    "deepagents_code.widgets.thread_selector.ThreadSelectorScreen"
                ) as mock_screen_cls,
                patch.object(app, "push_screen") as push_screen,
            ):
                mock_screen = MagicMock()
                mock_screen_cls.return_value = mock_screen
                await app._show_thread_selector()

                assert app._session_state is not None
                mock_screen_cls.assert_called_once_with(
                    current_thread=app._session_state.thread_id,
                    thread_limit=9,
                    initial_threads=cached_threads,
                )
                push_screen.assert_called_once()

    async def test_thread_selector_selection_refocuses_after_resume(self) -> None:
        """Selecting a thread should not refocus chat before resume modals finish."""
        app = DeepAgentsApp()
        chat_input = MagicMock()
        resume_order: list[str] = []
        callbacks: list[Callable[[], None]] = []
        workers: list[Awaitable[None]] = []
        selector_callback: Callable[[str | None], None] | None = None

        async def resume_thread(thread_id: str) -> None:
            chat_input.focus_input.assert_not_called()
            await asyncio.sleep(0)
            resume_order.append(thread_id)

        def push_screen(
            _screen: object,
            callback: Callable[[str | None], None],
        ) -> None:
            nonlocal selector_callback
            selector_callback = callback

        def run_worker(work: Awaitable[None], **_kwargs: object) -> MagicMock:
            workers.append(work)
            return MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._chat_input = chat_input
            run_worker_mock = MagicMock(side_effect=run_worker)
            app._resume_thread = resume_thread  # ty: ignore[invalid-assignment]
            app.call_after_refresh = MagicMock(side_effect=callbacks.append)  # ty: ignore[invalid-assignment]
            app.run_worker = run_worker_mock  # ty: ignore[invalid-assignment]
            with (
                patch("deepagents_code.sessions.get_thread_limit", return_value=9),
                patch("deepagents_code.sessions.get_cached_threads", return_value=[]),
                patch.object(app, "push_screen", side_effect=push_screen),
            ):
                await app._show_thread_selector()

            assert selector_callback is not None
            selector_callback("thread-abc")
            chat_input.focus_input.assert_not_called()
            assert len(callbacks) == 1

            callbacks[0]()
            run_worker_mock.assert_called_once()
            assert len(workers) == 1
            await workers[0]

            assert resume_order == ["thread-abc"]
            chat_input.focus_input.assert_called_once_with()


class TestAppBindings:
    """Test app keybindings."""

    def test_ctrl_c_binding_has_priority(self) -> None:
        """Ctrl+C should be priority-bound so focused modal inputs don't swallow it."""
        bindings = [b for b in DeepAgentsApp.BINDINGS if isinstance(b, Binding)]
        bindings_by_key = {b.key: b for b in bindings}
        ctrl_c = bindings_by_key.get("ctrl+c")

        assert ctrl_c is not None
        assert ctrl_c.action == "quit_or_interrupt"
        assert ctrl_c.priority is True

    def test_toggle_tool_output_has_ctrl_o_binding(self) -> None:
        """Ctrl+O should be bound to toggle_tool_output with priority."""
        bindings = [b for b in DeepAgentsApp.BINDINGS if isinstance(b, Binding)]
        bindings_by_key = {b.key: b for b in bindings}
        ctrl_o = bindings_by_key.get("ctrl+o")

        assert ctrl_o is not None
        assert ctrl_o.action == "toggle_tool_output"
        assert ctrl_o.priority is True

    def test_ctrl_e_not_bound(self) -> None:
        """Ctrl+E must not be bound — it shadows TextArea cursor_line_end."""
        bindings = [b for b in DeepAgentsApp.BINDINGS if isinstance(b, Binding)]
        bindings_by_key = {b.key: b for b in bindings}
        assert "ctrl+e" not in bindings_by_key


class TestCtrlCCopySelection:
    """Test Ctrl+C copying a focused input's selection instead of quitting."""

    async def test_ctrl_c_copies_chat_text_area_selection(self) -> None:
        """Ctrl+C with a selection in ChatTextArea copies it, no quit/interrupt."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            chat_input = app.query_one(ChatInput)
            text_area = chat_input.input_widget
            assert text_area is not None
            text_area.text = "hello world"
            await pilot.pause()
            text_area.focus()
            await pilot.pause()
            text_area.select_all()
            await pilot.pause()

            assert text_area.selected_text == "hello world"

            # Arm quit first so the post-copy assertion that `_quit_pending` is
            # reset is load-bearing (it defaults to False, so without arming it
            # the assertion would pass even if the reset were removed).
            app._quit_pending = True

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_called_once_with(app, "hello world")
            exit_mock.assert_not_called()
            assert app._quit_pending is False

    async def test_ctrl_c_without_selection_still_quits(self) -> None:
        """Ctrl+C with no selection in ChatTextArea falls through to quit hint."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            chat_input = app.query_one(ChatInput)
            text_area = chat_input.input_widget
            assert text_area is not None
            text_area.text = "hello world"
            await pilot.pause()
            text_area.focus()
            await pilot.pause()

            assert text_area.selected_text == ""

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

                copy_mock.assert_not_called()
                exit_mock.assert_not_called()
                assert app._quit_pending is True
                notify_mock.assert_called_once_with(
                    "Press Ctrl+C again to quit",
                    timeout=3,
                    markup=False,
                )

                app.action_quit_or_interrupt()
                exit_mock.assert_called_once()

    async def test_ctrl_c_copies_input_selection(self) -> None:
        """Ctrl+C with a selection in a focused Input copies it, no quit."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            test_input = Input(value="abc123")
            await app.mount(test_input)
            test_input.focus()
            await pilot.pause()
            test_input.select_all()
            await pilot.pause()

            assert test_input.selected_text == "abc123"

            # Arm quit so the post-copy reset assertion is load-bearing.
            app._quit_pending = True

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_called_once_with(app, "abc123")
            exit_mock.assert_not_called()
            assert app._quit_pending is False

    async def test_ctrl_c_skips_password_input_selection(self) -> None:
        """Ctrl+C must not copy selected text from masked password inputs."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            test_input = Input(value="secret-api-key", password=True)
            await app.mount(test_input)
            test_input.focus()
            await pilot.pause()
            test_input.select_all()
            await pilot.pause()

            assert test_input.selected_text == "secret-api-key"

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_not_called()
            exit_mock.assert_not_called()
            assert app._quit_pending is True
            notify_mock.assert_called_once_with(
                "Press Ctrl+C again to quit",
                timeout=3,
                markup=False,
            )

    async def test_ctrl_c_copy_takes_precedence_over_agent_interrupt(self) -> None:
        """A successful copy wins over interrupting a running agent."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            chat_input = app.query_one(ChatInput)
            text_area = chat_input.input_widget
            assert text_area is not None
            text_area.text = "hello world"
            await pilot.pause()
            text_area.focus()
            await pilot.pause()
            text_area.select_all()
            await pilot.pause()

            assert text_area.selected_text == "hello world"

            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_called_once_with(app, "hello world")
            # Copy consumed the keypress, so the agent worker is left running.
            mock_worker.cancel.assert_not_called()
            exit_mock.assert_not_called()
            assert app._quit_pending is False

    async def test_ctrl_c_copy_failure_falls_through_to_interrupt(self) -> None:
        """When the copy fails, Ctrl+C still interrupts the running agent.

        Returning `True` unconditionally would swallow the keypress and leave
        the agent running with only a transient warning, making interrupt
        unreachable while a selection lingers. The copy must degrade to the
        safety-critical interrupt path on failure.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            chat_input = app.query_one(ChatInput)
            text_area = chat_input.input_widget
            assert text_area is not None
            text_area.text = "hello world"
            await pilot.pause()
            text_area.focus()
            await pilot.pause()
            text_area.select_all()
            await pilot.pause()

            assert text_area.selected_text == "hello world"

            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(False, "boom"),
                ) as copy_mock,
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_called_once_with(app, "hello world")
            notify_mock.assert_called_once_with(
                "Failed to copy selection: boom",
                severity="warning",
                timeout=3,
                markup=False,
            )
            # Fell through to the agent-interrupt branch instead of quitting.
            mock_worker.cancel.assert_called_once()
            exit_mock.assert_not_called()
            assert app._quit_pending is False

    async def test_ctrl_c_interrupt_marks_active_user_message_cancelled(self) -> None:
        """Ctrl+C dims the prompt for the interrupted turn."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            user_message = UserMessage("running prompt")
            messages = app.query_one("#messages", Container)
            await messages.mount(user_message)
            await pilot.pause()

            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker
            app._active_user_message = user_message

            app.action_quit_or_interrupt()
            await pilot.pause()

            assert user_message.has_class("-cancelled")
            mock_worker.cancel.assert_called_once()
            assert app._quit_pending is False

    async def test_ctrl_c_non_input_focus_falls_through(self) -> None:
        """Ctrl+C with a non-Input/TextArea widget focused never copies."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            checkbox = Checkbox()
            await app.mount(checkbox)
            checkbox.focus()
            await pilot.pause()

            assert app.focused is checkbox

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_not_called()
            exit_mock.assert_not_called()
            assert app._quit_pending is True

    async def test_ctrl_c_no_focus_falls_through(self) -> None:
        """Ctrl+C with nothing focused never copies and does not crash."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app.set_focus(None)
            await pilot.pause()
            assert app.focused is None

            with (
                patch(
                    "deepagents_code.clipboard.copy_text_to_clipboard",
                    return_value=(True, None),
                ) as copy_mock,
                patch.object(app, "exit") as exit_mock,
            ):
                app.action_quit_or_interrupt()

            copy_mock.assert_not_called()
            exit_mock.assert_not_called()
            assert app._quit_pending is True


class TestModalScreenEscapeDismissal:
    """Test that escape key dismisses modal screens."""

    @staticmethod
    async def test_escape_dismisses_modal_screen() -> None:
        """Escape should dismiss any active ModalScreen.

        The app's action_interrupt binding intercepts escape with priority=True.
        When a modal screen is active, it should dismiss the modal rather than
        performing the default interrupt behavior.
        """

        class SimpleModal(ModalScreen[str | None]):
            """A simple test modal."""

            BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]

            def compose(self) -> ComposeResult:
                yield Static("Test Modal")

            def action_cancel(self) -> None:
                self.dismiss(None)

        class TestApp(App[None]):
            """Test app with escape -> action_interrupt binding."""

            BINDINGS: ClassVar[list[BindingType]] = [
                Binding("escape", "interrupt", "Interrupt", priority=True)
            ]

            def __init__(self) -> None:
                super().__init__()
                self.modal_dismissed = False
                self.interrupt_called = False

            def compose(self) -> ComposeResult:
                yield Container()

            def action_interrupt(self) -> None:
                if isinstance(self.screen, ModalScreen):
                    self.screen.dismiss(None)
                    return
                self.interrupt_called = True

            def show_modal(self) -> None:
                def on_dismiss(_result: str | None) -> None:
                    self.modal_dismissed = True

                self.push_screen(SimpleModal(), on_dismiss)

        app = TestApp()
        async with app.run_test() as pilot:
            app.show_modal()
            await pilot.pause()

            # Escape should dismiss the modal, not call interrupt
            await pilot.press("escape")
            await pilot.pause()

            assert app.modal_dismissed is True
            assert app.interrupt_called is False


class TestModalScreenCtrlDHandling:
    """Tests for app-level Ctrl+D behavior while modals are open."""

    async def test_ctrl_d_deletes_in_thread_selector_instead_of_quitting(self) -> None:
        """App-level quit binding should delegate to thread delete in the modal."""
        from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

        mock_threads: list[ThreadInfo] = [
            {
                "thread_id": "thread-123",
                "agent_name": "agent",
                "updated_at": "2026-03-08T02:00:00+00:00",
                "created_at": "2026-03-08T01:00:00+00:00",
                "initial_prompt": "prompt",
            }
        ]
        with patch(
            "deepagents_code.sessions.list_threads",
            new_callable=AsyncMock,
            return_value=mock_threads,
        ):
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()

                screen = ThreadSelectorScreen(
                    current_thread=None,
                    initial_threads=mock_threads,
                    filter_cwd=None,
                )
                app.push_screen(screen)
                await pilot.pause()

                with patch.object(app, "exit") as mock_exit:
                    await pilot.press("ctrl+d")
                    await pilot.pause()
                    await pilot.pause()

                assert screen._confirming_delete is True
                mock_exit.assert_not_called()

    async def test_escape_closes_thread_delete_confirm_without_dismissing_modal(
        self,
    ) -> None:
        """Escape should close thread delete confirmation before dismissing modal."""
        from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

        mock_threads: list[ThreadInfo] = [
            {
                "thread_id": "thread-123",
                "agent_name": "agent",
                "updated_at": "2026-03-08T02:00:00+00:00",
                "created_at": "2026-03-08T01:00:00+00:00",
                "initial_prompt": "prompt",
            }
        ]
        with patch(
            "deepagents_code.sessions.list_threads",
            new_callable=AsyncMock,
            return_value=mock_threads,
        ):
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()

                screen = ThreadSelectorScreen(
                    current_thread=None,
                    initial_threads=mock_threads,
                    filter_cwd=None,
                )
                app.push_screen(screen)
                await pilot.pause()

                await pilot.press("ctrl+d")
                await pilot.pause()
                await pilot.pause()
                assert screen.is_delete_confirmation_open is True

                await pilot.press("escape")
                await pilot.pause()
                await pilot.pause()

                assert app.screen is screen
                assert screen.is_delete_confirmation_open is False

    async def test_ctrl_d_twice_quits_from_delete_confirmation(self) -> None:
        """Ctrl+D should use a double-press quit flow inside delete confirmation."""
        from deepagents_code.widgets.thread_selector import (
            DeleteThreadConfirmScreen,
            ThreadSelectorScreen,
        )

        mock_threads: list[ThreadInfo] = [
            {
                "thread_id": "thread-123",
                "agent_name": "agent",
                "updated_at": "2026-03-08T02:00:00+00:00",
                "created_at": "2026-03-08T01:00:00+00:00",
                "initial_prompt": "prompt",
            }
        ]
        with patch(
            "deepagents_code.sessions.list_threads",
            new_callable=AsyncMock,
            return_value=mock_threads,
        ):
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()

                screen = ThreadSelectorScreen(
                    current_thread=None,
                    initial_threads=mock_threads,
                    filter_cwd=None,
                )
                app.push_screen(screen)
                await pilot.pause()

                await pilot.press("ctrl+d")
                await pilot.pause()
                await pilot.pause()
                assert isinstance(app.screen, DeleteThreadConfirmScreen)

                with (
                    patch.object(app, "notify") as notify_mock,
                    patch.object(app, "exit") as exit_mock,
                ):
                    await pilot.press("ctrl+d")
                    await pilot.pause()
                    notify_mock.assert_called_once_with(
                        "Press Ctrl+D again to quit",
                        timeout=3,
                        markup=False,
                    )
                    assert app._quit_pending is True
                    exit_mock.assert_not_called()

                    await pilot.press("ctrl+d")
                    await pilot.pause()
                    exit_mock.assert_called_once()

    async def test_ctrl_c_still_works_from_delete_confirmation(self) -> None:
        """Ctrl+C should preserve the normal double-press quit flow in confirmation."""
        from deepagents_code.widgets.thread_selector import (
            DeleteThreadConfirmScreen,
            ThreadSelectorScreen,
        )

        mock_threads: list[ThreadInfo] = [
            {
                "thread_id": "thread-123",
                "agent_name": "agent",
                "updated_at": "2026-03-08T02:00:00+00:00",
                "created_at": "2026-03-08T01:00:00+00:00",
                "initial_prompt": "prompt",
            }
        ]
        with patch(
            "deepagents_code.sessions.list_threads",
            new_callable=AsyncMock,
            return_value=mock_threads,
        ):
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()

                screen = ThreadSelectorScreen(
                    current_thread=None,
                    initial_threads=mock_threads,
                    filter_cwd=None,
                )
                app.push_screen(screen)
                await pilot.pause()

                await pilot.press("ctrl+d")
                await pilot.pause()
                await pilot.pause()
                assert isinstance(app.screen, DeleteThreadConfirmScreen)

                with (
                    patch.object(app, "notify") as notify_mock,
                    patch.object(app, "exit") as exit_mock,
                ):
                    app.action_quit_or_interrupt()
                    notify_mock.assert_called_once_with(
                        "Press Ctrl+C again to quit",
                        timeout=3,
                        markup=False,
                    )
                    assert app._quit_pending is True
                    exit_mock.assert_not_called()

                    app.action_quit_or_interrupt()
                    exit_mock.assert_called_once()

    async def test_ctrl_d_quits_from_model_selector_with_input_focused(
        self,
    ) -> None:
        """Ctrl+D should not be swallowed or ignored in the model selector."""
        from deepagents_code.widgets.model_selector import ModelSelectorScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = ModelSelectorScreen(
                current_model="claude-sonnet-4-5",
                current_provider="anthropic",
            )
            app.push_screen(screen)
            await pilot.pause()

            filter_input = screen.query_one("#model-filter", Input)
            assert filter_input.has_focus

            with patch.object(app, "exit") as exit_mock:
                await pilot.press("ctrl+d")
                await pilot.pause()

            exit_mock.assert_called_once()

    async def test_ctrl_d_quits_from_mcp_viewer(self) -> None:
        """Ctrl+D should still quit while the MCP viewer modal is open."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
        from deepagents_code.widgets.mcp_viewer import MCPViewerScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = MCPViewerScreen(
                server_info=[
                    MCPServerInfo(
                        name="filesystem",
                        transport="stdio",
                        tools=(
                            MCPToolInfo(
                                name="read_file",
                                description="Read a file",
                            ),
                        ),
                    )
                ]
            )
            app.push_screen(screen)
            await pilot.pause()

            with patch.object(app, "exit") as exit_mock:
                await pilot.press("ctrl+d")
                await pilot.pause()

            exit_mock.assert_called_once()

    async def test_ctrl_d_opens_delete_confirm_in_auth_prompt(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Ctrl+D in the auth prompt should open the confirm modal, not quit."""
        from deepagents_code import auth_store
        from deepagents_code.widgets.auth import (
            AuthPromptScreen,
            DeleteCredentialConfirmScreen,
        )

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )
        auth_store.set_stored_key("openai", "k")

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app.push_screen(AuthPromptScreen("openai", "OPENAI_API_KEY"))
            await pilot.pause()

            with patch.object(app, "exit") as exit_mock:
                await pilot.press("ctrl+d")
                await pilot.pause()

            assert isinstance(app.screen, DeleteCredentialConfirmScreen)
            exit_mock.assert_not_called()

    async def test_shift_tab_moves_focus_back_in_auth_prompt(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Shift+Tab steps focus from the base-URL field back to the key field.

        The app binds Shift+Tab (priority) to auto-approve toggling, so the
        Screen's own `app.focus_previous` binding never fires. The prompt has
        two inputs now, so the toggle handler must delegate backward navigation
        instead of swallowing the key.
        """
        from deepagents_code.widgets.auth import AuthPromptScreen

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app.push_screen(AuthPromptScreen("openai", "OPENAI_API_KEY"))
            await pilot.pause()

            assert app.focused is not None
            assert app.focused.id == "auth-prompt-input"

            await pilot.press("f2")
            await pilot.pause()
            await pilot.press("tab")
            await pilot.pause()
            assert app.focused is not None
            assert app.focused.id == "auth-prompt-base-url"

            await pilot.press("shift+tab")
            await pilot.pause()
            assert app.focused is not None
            assert app.focused.id == "auth-prompt-input"

    async def test_ctrl_d_in_auth_confirm_arms_quit(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Ctrl+D inside the auth confirm modal arms the double-press quit."""
        from deepagents_code import auth_store
        from deepagents_code.widgets.auth import AuthPromptScreen

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )
        auth_store.set_stored_key("openai", "k")

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app.push_screen(AuthPromptScreen("openai", "OPENAI_API_KEY"))
            await pilot.pause()
            await pilot.press("ctrl+d")
            await pilot.pause()

            with patch.object(app, "exit") as exit_mock:
                await pilot.press("ctrl+d")
                await pilot.pause()
                exit_mock.assert_not_called()
                assert app._quit_pending is True

                await pilot.press("ctrl+d")
                await pilot.pause()
                exit_mock.assert_called_once()


class TestModalScreenShiftTabHandling:
    """Tests for app-level Shift+Tab behavior while modals are open."""

    async def test_shift_tab_moves_backward_in_thread_selector(self) -> None:
        """Shift+Tab should move backward in the thread selector controls."""
        from textual.widgets import Select

        from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = ThreadSelectorScreen(
                current_thread=None,
                initial_threads=[
                    {
                        "thread_id": "thread-123",
                        "agent_name": "agent",
                        "updated_at": "2026-03-08T02:00:00+00:00",
                        "created_at": "2026-03-08T01:00:00+00:00",
                        "initial_prompt": "prompt",
                    }
                ],
                filter_cwd=None,
            )
            app.push_screen(screen)
            await pilot.pause()

            assert app._auto_approve is False
            filter_input = screen.query_one("#thread-filter", Input)
            scope_select = screen.query_one("#thread-scope-select", Select)
            agent_select = screen.query_one("#thread-agent-select", Select)
            sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)

            await pilot.press("tab")
            await pilot.pause()
            assert scope_select.has_focus

            await pilot.press("tab")
            await pilot.pause()
            assert agent_select.has_focus

            await pilot.press("tab")
            await pilot.pause()
            assert sort_switch.has_focus

            await pilot.press("shift+tab")
            await pilot.pause()
            assert agent_select.has_focus

            await pilot.press("shift+tab")
            await pilot.pause()
            assert scope_select.has_focus

            await pilot.press("shift+tab")
            await pilot.pause()

            assert filter_input.has_focus
            assert app._auto_approve is False

    async def test_shift_tab_navigates_in_auth_manager(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Shift+Tab should move the manager option-list cursor up, not toggle."""
        from textual.widgets import OptionList

        from deepagents_code.widgets.auth import AuthManagerScreen

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = AuthManagerScreen()
            app.push_screen(screen)
            await pilot.pause()

            options = screen.query_one("#auth-manager-options", OptionList)
            await pilot.press("tab")
            await pilot.pause()
            await pilot.press("tab")
            await pilot.pause()
            after_tab = options.highlighted

            await pilot.press("shift+tab")
            await pilot.pause()

            assert options.highlighted is not None
            assert after_tab is not None
            assert options.highlighted < after_tab
            assert app._auto_approve is False


class TestModalScreenCtrlCHandling:
    """Tests for app-level Ctrl+C behavior while modals are open."""

    async def test_ctrl_c_quits_from_thread_selector_with_input_focused(
        self,
    ) -> None:
        """Ctrl+C should reach the app even when the thread filter has focus."""
        from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = ThreadSelectorScreen(
                current_thread=None,
                initial_threads=[
                    {
                        "thread_id": "thread-123",
                        "agent_name": "agent",
                        "updated_at": "2026-03-08T02:00:00+00:00",
                        "created_at": "2026-03-08T01:00:00+00:00",
                        "initial_prompt": "prompt",
                    }
                ],
                filter_cwd=None,
            )
            app.push_screen(screen)
            await pilot.pause()

            filter_input = screen.query_one("#thread-filter", Input)
            assert filter_input.has_focus

            with (
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
                patch.object(app, "set_timer"),
            ):
                await pilot.press("ctrl+c")
                await pilot.pause()
                notify_mock.assert_called_once_with(
                    "Press Ctrl+C again to quit",
                    timeout=3,
                    markup=False,
                )
                assert app._quit_pending is True
                exit_mock.assert_not_called()

                await pilot.press("ctrl+c")
                await pilot.pause()
                exit_mock.assert_called_once()

    async def test_ctrl_c_quits_from_model_selector_with_input_focused(
        self,
    ) -> None:
        """Ctrl+C should not be swallowed by the model filter input."""
        from deepagents_code.widgets.model_selector import ModelSelectorScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = ModelSelectorScreen(
                current_model="claude-sonnet-4-5",
                current_provider="anthropic",
            )
            app.push_screen(screen)
            await pilot.pause()

            filter_input = screen.query_one("#model-filter", Input)
            assert filter_input.has_focus

            with (
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
                patch.object(app, "set_timer"),
            ):
                await pilot.press("ctrl+c")
                await pilot.pause()
                notify_mock.assert_called_once_with(
                    "Press Ctrl+C again to quit",
                    timeout=3,
                    markup=False,
                )
                assert app._quit_pending is True
                exit_mock.assert_not_called()

                await pilot.press("ctrl+c")
                await pilot.pause()
                exit_mock.assert_called_once()

    async def test_ctrl_c_quits_from_mcp_viewer(self) -> None:
        """Ctrl+C should still trigger app quit flow while the MCP modal is open."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
        from deepagents_code.widgets.mcp_viewer import MCPViewerScreen

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            screen = MCPViewerScreen(
                server_info=[
                    MCPServerInfo(
                        name="filesystem",
                        transport="stdio",
                        tools=(
                            MCPToolInfo(
                                name="read_file",
                                description="Read a file",
                            ),
                        ),
                    )
                ]
            )
            app.push_screen(screen)
            await pilot.pause()

            with (
                patch.object(app, "notify") as notify_mock,
                patch.object(app, "exit") as exit_mock,
                patch.object(app, "set_timer"),
            ):
                await pilot.press("ctrl+c")
                await pilot.pause()
                notify_mock.assert_called_once_with(
                    "Press Ctrl+C again to quit",
                    timeout=3,
                    markup=False,
                )
                assert app._quit_pending is True
                exit_mock.assert_not_called()

                await pilot.press("ctrl+c")
                await pilot.pause()
                exit_mock.assert_called_once()


class TestMountMessageNoMatches:
    """Test _mount_message resilience when #messages container is missing.

    When a user interrupts a streaming response, the cancellation handler and
    error handler both call _mount_message. If the screen has been torn down
    (e.g. #messages container no longer exists), this should not crash.
    """

    async def test_mount_message_no_crash_when_messages_missing(self) -> None:
        """_mount_message should not raise NoMatches when #messages is absent."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Verify the #messages container exists initially
            messages_container = app.query_one("#messages", Container)
            assert messages_container is not None

            # Remove #messages to simulate a torn-down screen state
            await messages_container.remove()

            # Verify it's truly gone
            with pytest.raises(NoMatches):
                app.query_one("#messages", Container)

            # _mount_message should handle the missing container gracefully
            # Before the fix, this raises NoMatches
            await app._mount_message(AppMessage("Interrupted by user"))

    async def test_mount_error_message_no_crash_when_messages_missing(
        self,
    ) -> None:
        """ErrorMessage via _mount_message should not crash without #messages.

        This is the second crash in the cascade: after _mount_message fails
        in the CancelledError handler, _run_agent_task's except clause also
        calls _mount_message(ErrorMessage(...)), which fails the same way.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            messages_container = app.query_one("#messages", Container)
            await messages_container.remove()

            # Should not raise
            await app._mount_message(ErrorMessage("Agent error: something"))

    async def test_mount_transient_app_message_returns_none_when_missing(
        self,
    ) -> None:
        """`_mount_transient_app_message` returns `None` without a #messages.

        Callers gate transient removal on `if restarting is not None:`. When the
        screen is torn down before the status can mount, the helper must report
        the miss (so the gate skips removal) rather than crash or mount an
        orphaned widget.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            messages_container = app.query_one("#messages", Container)
            await messages_container.remove()
            with pytest.raises(NoMatches):
                app.query_one("#messages", Container)

            before = len(app.query(AppMessage))
            result = await app._mount_transient_app_message("Restarting server...")

            assert result is None
            # Nothing was mounted, so the gate has nothing to remove later.
            assert len(app.query(AppMessage)) == before


class TestQueuedMessage:
    """Test QueuedMessage dataclass."""

    def test_frozen(self) -> None:
        """QueuedMessage should be immutable."""
        msg = QueuedMessage(text="hello", mode="normal")
        with pytest.raises(AttributeError):
            msg.text = "changed"  # ty: ignore

    def test_fields(self) -> None:
        """QueuedMessage should store text and mode."""
        msg = QueuedMessage(text="hello", mode="shell")
        assert msg.text == "hello"
        assert msg.mode == "shell"


class TestMessageQueue:
    """Test message queue behavior in DeepAgentsApp."""

    async def test_message_queued_when_agent_running(self) -> None:
        """Messages should be queued when agent is running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("queued msg", "normal"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "queued msg"
            assert app._pending_messages[0].mode == "normal"

    async def test_message_queued_while_connecting(self) -> None:
        """Messages submitted during server startup should be queued."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            app.post_message(ChatInput.Submitted("early msg", "normal"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "early msg"
            widgets = app.query(QueuedUserMessage)
            assert len(widgets) == 1

    async def test_message_blocked_while_thread_switching(self) -> None:
        """Submissions should be ignored while thread switching is in-flight."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._thread_switching = True
            with patch.object(app, "notify") as notify_mock:
                app.post_message(ChatInput.Submitted("blocked msg", "normal"))
                await pilot.pause()

                assert len(app._pending_messages) == 0
                user_msgs = app.query(UserMessage)
                assert not any(w._content == "blocked msg" for w in user_msgs)
                notify_mock.assert_called_once_with(
                    "Thread switch in progress. Please wait.",
                    severity="warning",
                    timeout=3,
                )

    async def test_queued_widget_mounted(self) -> None:
        """Queued messages should produce a QueuedUserMessage widget."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("test msg", "normal"))
            await pilot.pause()

            widgets = app.query(QueuedUserMessage)
            assert len(widgets) == 1
            assert len(app._queued_widgets) == 1

    async def test_queued_widget_not_stored_as_user_message(self) -> None:
        """Queued placeholders should stay out of persistent message data."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("test msg", "normal"))
            await pilot.pause()

            assert len(app.query(QueuedUserMessage)) == 1
            assert app._message_store.get_all_messages() == []

            await app._mount_message(UserMessage("test msg"))

            messages = app._message_store.get_all_messages()
            assert len(messages) == 1
            assert messages[0].content == "test msg"

    async def test_queued_incognito_shell_preserves_mode_on_drain(self) -> None:
        """Queued incognito shell commands should drain as incognito shell."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("!!echo hidden", "shell_incognito"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "!!echo hidden"
            assert app._pending_messages[0].mode == "shell_incognito"

            handler = AsyncMock()
            app._handle_shell_command = handler  # ty: ignore
            app._agent_running = False

            await app._process_next_from_queue()

            handler.assert_awaited_once_with("echo hidden", incognito=True)

    async def test_immediate_processing_when_agent_idle(self) -> None:
        """Messages should process immediately when agent is not running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert not app._agent_running

            app.post_message(ChatInput.Submitted("direct msg", "normal"))
            await pilot.pause()

            # Should not be queued
            assert len(app._pending_messages) == 0
            # Should be mounted as a regular UserMessage
            user_msgs = app.query(UserMessage)
            assert any(w._content == "direct msg" for w in user_msgs)

    async def test_fifo_order(self) -> None:
        """Queued messages should process in FIFO order."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("first", "normal"))
            await pilot.pause()
            app.post_message(ChatInput.Submitted("second", "normal"))
            await pilot.pause()

            assert len(app._pending_messages) == 2
            assert app._pending_messages[0].text == "first"
            assert app._pending_messages[1].text == "second"

    async def test_escape_pops_last_queued_message(self) -> None:
        """Escape should pop the last queued message (LIFO), not nuke all."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            app.post_message(ChatInput.Submitted("msg1", "normal"))
            await pilot.pause()
            app.post_message(ChatInput.Submitted("msg2", "normal"))
            await pilot.pause()

            assert len(app._pending_messages) == 2

            # First ESC pops the last queued message
            app.action_interrupt()
            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "msg1"
            mock_worker.cancel.assert_not_called()

            # Second ESC pops the remaining message
            app.action_interrupt()
            assert len(app._pending_messages) == 0
            mock_worker.cancel.assert_not_called()

            # Third ESC interrupts the agent
            app.action_interrupt()
            mock_worker.cancel.assert_called_once()

    async def test_escape_restores_text_to_empty_input(self) -> None:
        """Popped message text is restored to chat input when input is empty."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            app._agent_worker = MagicMock()

            app.post_message(ChatInput.Submitted("restore me", "normal"))
            await pilot.pause()
            assert len(app._pending_messages) == 1

            chat = app._chat_input
            assert chat is not None
            # Input is empty — text should be restored
            chat.value = ""
            app.action_interrupt()
            assert chat.value == "restore me"

    async def test_escape_preserves_existing_input_text(self) -> None:
        """Popped message text is discarded when chat input already has content."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            app._agent_worker = MagicMock()

            app.post_message(ChatInput.Submitted("queued msg", "normal"))
            await pilot.pause()
            assert len(app._pending_messages) == 1

            chat = app._chat_input
            assert chat is not None
            # Input has content — should NOT be overwritten
            chat.value = "draft text"
            app.action_interrupt()
            assert chat.value == "draft text"
            assert len(app._pending_messages) == 0

    async def test_escape_pop_shows_toast(self) -> None:
        """Popping a queued message shows a differentiated toast."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            app._agent_worker = MagicMock()

            # Queue a message and pop with empty input — "moved to input"
            app._pending_messages.append(QueuedMessage(text="a", mode="normal"))
            chat = app._chat_input
            assert chat is not None
            chat.value = ""
            with patch.object(app, "notify") as mock_notify:
                app.action_interrupt()
                mock_notify.assert_called_once_with(
                    "Queued message moved to input", timeout=2
                )

            # Queue another and pop with non-empty input — "discarded"
            app._pending_messages.append(QueuedMessage(text="b", mode="normal"))
            chat.value = "existing"
            with patch.object(app, "notify") as mock_notify:
                app.action_interrupt()
                mock_notify.assert_called_once_with(
                    "Queued message discarded (input not empty)", timeout=3
                )

    async def test_escape_pop_single_then_interrupt(self) -> None:
        """Single queued message is popped, then next ESC interrupts agent."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            app._pending_messages.append(QueuedMessage(text="only", mode="normal"))
            app._queued_widgets.append(MagicMock())

            app.action_interrupt()
            assert len(app._pending_messages) == 0
            mock_worker.cancel.assert_not_called()

            app.action_interrupt()
            mock_worker.cancel.assert_called_once()

    async def test_escape_pop_handles_widget_desync(self) -> None:
        """Pop completes gracefully when _queued_widgets is empty but messages exist."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            app._agent_worker = MagicMock()

            # Messages without corresponding widgets (desync scenario)
            app._pending_messages.append(QueuedMessage(text="orphan", mode="normal"))
            assert len(app._queued_widgets) == 0

            app.action_interrupt()
            assert len(app._pending_messages) == 0
            # No crash — method handled the desync

    async def test_interrupt_dismisses_completion_without_stopping_agent(self) -> None:
        """Esc should dismiss completion popup without interrupting the agent."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            # Activate completion by typing "/"
            chat = app._chat_input
            assert chat is not None
            assert chat._text_area is not None
            chat._text_area.text = "/"
            await pilot.pause()
            assert chat._current_suggestions  # completion is active

            # Esc should dismiss completion, NOT cancel the agent
            app.action_interrupt()

            assert chat._current_suggestions == []
            mock_worker.cancel.assert_not_called()
            assert app._agent_running is True

    async def test_interrupt_falls_through_when_no_completion(self) -> None:
        """Esc should interrupt the agent when completion is not active."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            # No completion active — interrupt should reach the agent
            chat = app._chat_input
            assert chat is not None
            assert not chat._current_suggestions

            app.action_interrupt()

            mock_worker.cancel.assert_called_once()

    async def test_queue_cleared_on_ctrl_c(self) -> None:
        """Ctrl+C should clear the message queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            mock_worker = MagicMock()
            app._agent_worker = mock_worker

            app.post_message(ChatInput.Submitted("msg", "normal"))
            await pilot.pause()

            app.action_quit_or_interrupt()

            assert len(app._pending_messages) == 0
            assert len(app._queued_widgets) == 0

    async def test_process_next_from_queue_removes_widget(self) -> None:
        """Processing a queued message should remove its ephemeral widget."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Manually enqueue
            app._pending_messages.append(QueuedMessage(text="test", mode="normal"))
            widget = QueuedUserMessage("test")
            messages = app.query_one("#messages", Container)
            await messages.mount(widget)
            app._queued_widgets.append(widget)

            await app._process_next_from_queue()
            await pilot.pause()

            assert len(app._queued_widgets) == 0

    async def test_shell_command_continues_chain(self) -> None:
        """Shell/command messages should not break the queue processing chain."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Queue a shell command followed by a normal message
            app._pending_messages.append(QueuedMessage(text="!echo hi", mode="shell"))
            app._pending_messages.append(
                QueuedMessage(text="hello agent", mode="normal")
            )

            await app._process_next_from_queue()
            await pilot.pause()
            await pilot.pause()

            # The shell command should have been processed and the normal
            # message should also have been picked up (mounted as UserMessage)
            user_msgs = app.query(UserMessage)
            assert any(w._content == "hello agent" for w in user_msgs)


class TestAskUserLifecycle:
    """Tests for ask_user widget cleanup flows."""

    def test_tall_ask_user_scrolls_to_widget_top(self) -> None:
        """Tall ask_user menus should align their top border with the viewport."""
        app = DeepAgentsApp(agent=MagicMock())
        menu = MagicMock()
        menu.outer_size = SimpleNamespace(height=30)
        chat = MagicMock()
        chat.size = SimpleNamespace(height=20)

        with patch.object(app, "query_one", return_value=chat):
            app._scroll_ask_user_into_view(menu)

        menu.scroll_visible.assert_called_once_with(animate=False, top=True)

    def test_short_ask_user_uses_default_scroll_visible(self) -> None:
        """Short ask_user menus should keep the existing scroll behavior."""
        app = DeepAgentsApp(agent=MagicMock())
        menu = MagicMock()
        menu.outer_size = SimpleNamespace(height=10)
        chat = MagicMock()
        chat.size = SimpleNamespace(height=20)

        with patch.object(app, "query_one", return_value=chat):
            app._scroll_ask_user_into_view(menu)

        menu.scroll_visible.assert_called_once_with()

    def test_ctrl_o_targets_pending_ask_user_tool_row(self) -> None:
        """App-level Ctrl+O should toggle the active ask_user tool row."""
        app = DeepAgentsApp(agent=MagicMock())
        app._pending_ask_user_widget = MagicMock()
        tool = MagicMock()
        tool.has_expandable_args = True

        with patch.object(app, "query", return_value=[tool]):
            app.action_toggle_tool_output()

        tool.toggle_args.assert_called_once_with()

    def test_ctrl_o_falls_back_to_tool_with_expandable_args(self) -> None:
        """When no ask_user is pending, Ctrl+O still expands an ask_user-like row."""
        app = DeepAgentsApp(agent=MagicMock())
        app._pending_ask_user_widget = None
        tool = MagicMock()
        tool.has_output = False
        tool.has_expandable_args = True

        def fake_query(query_type: object) -> list[object]:
            from deepagents_code.widgets.messages import (
                SkillMessage,
                ToolCallMessage,
            )

            if query_type is ToolCallMessage:
                return [tool]
            if query_type is SkillMessage:
                return []
            return []

        with patch.object(app, "query", side_effect=fake_query):
            app.action_toggle_tool_output()

        tool.toggle_args.assert_called_once_with()
        tool.toggle_output.assert_not_called()

    def test_ctrl_o_prefers_tool_with_output_over_expandable_args(self) -> None:
        """Tool with real output wins over a later one with only expandable args."""
        app = DeepAgentsApp(agent=MagicMock())
        app._pending_ask_user_widget = None
        older = MagicMock()
        older.has_output = True
        older.has_expandable_args = False
        newer = MagicMock()
        newer.has_output = False
        newer.has_expandable_args = True

        def fake_query(query_type: object) -> list[object]:
            from deepagents_code.widgets.messages import (
                SkillMessage,
                ToolCallMessage,
            )

            if query_type is ToolCallMessage:
                return [older, newer]
            if query_type is SkillMessage:
                return []
            return []

        with patch.object(app, "query", side_effect=fake_query):
            app.action_toggle_tool_output()

        # Iterates in reverse, so newer (expandable args) is hit first.
        newer.toggle_args.assert_called_once_with()
        older.toggle_output.assert_not_called()

    async def test_request_ask_user_timeout_cleans_old_widget(self) -> None:
        """Timeout cleanup should cancel then remove the previous widget."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            old_widget = MagicMock()
            old_widget.remove = AsyncMock()
            app._pending_ask_user_widget = old_widget

            with patch("deepagents_code.app._monotonic", side_effect=[0.0, 31.0]):
                await app._request_ask_user([{"question": "Name?", "type": "text"}])

            old_widget.action_cancel.assert_called_once()
            old_widget.remove.assert_awaited_once()
            assert old_widget.mock_calls[:2] == [call.action_cancel(), call.remove()]
            assert app._pending_ask_user_widget is not old_widget

    async def test_on_ask_user_menu_answered_ignores_remove_errors(self) -> None:
        """Answered handler should swallow remove races and clear tracking."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            widget = MagicMock()
            widget.remove = AsyncMock(side_effect=RuntimeError("already removed"))
            app._pending_ask_user_widget = widget

            await app.on_ask_user_menu_answered(object())
            await pilot.pause()

            assert app._pending_ask_user_widget is None
            widget.remove.assert_awaited_once()

    async def test_on_ask_user_menu_cancelled_ignores_remove_errors(self) -> None:
        """Cancelled handler should swallow remove races and clear tracking."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            widget = MagicMock()
            widget.remove = AsyncMock(side_effect=RuntimeError("already removed"))
            app._pending_ask_user_widget = widget

            await app.on_ask_user_menu_cancelled(object())
            await pilot.pause()

            assert app._pending_ask_user_widget is None
            widget.remove.assert_awaited_once()


class TestLoadingSpinnerLifecycle:
    """Tests for loading spinner timer cleanup in app flows."""

    async def test_hide_stops_spinner_before_remove_completes(self) -> None:
        """Hiding the spinner should stop animation before DOM removal finishes."""
        app = DeepAgentsApp()
        original_remove = Widget.remove

        def delayed_remove(widget: Widget) -> Awaitable[None]:
            async def do_remove() -> None:
                await asyncio.sleep(0.3)
                await original_remove(widget)

            return do_remove()

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._set_spinner("Thinking")
            await pilot.pause()

            widget = app._loading_widget
            assert widget is not None

            before_tick = widget._spinner._position
            await asyncio.sleep(0.25)
            assert widget._spinner._position != before_tick
            # Pre-condition: timer must be running before hide so the `is None`
            # assertion below isn't vacuously satisfied.
            assert widget._animation_timer is not None

            with patch.object(Widget, "remove", new=delayed_remove):
                hide_task = asyncio.create_task(app._set_spinner(None))
                # Sleep while delayed_remove is blocking (0.3s).  Check the
                # timer flag rather than a frozen position counter: the
                # Textual timer may fire one final tick before cancellation
                # on slow CI runners, making position equality racy.
                await asyncio.sleep(0.25)
                assert widget._animation_timer is None
                await hide_task

            assert app._loading_widget is None

    async def test_reposition_preserves_spinner_state(self) -> None:
        """Repositioning should reorder without disturbing widget state.

        Repositioning uses `move_child`, which keeps the same LoadingWidget
        instance mounted. Its animation timer and `_start_time` must carry
        through unchanged so the "(Ns, esc to interrupt)" hint doesn't jump
        back to 0s mid-stream.
        """
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._set_spinner("Thinking")
            await pilot.pause()

            widget = app._loading_widget
            assert widget is not None

            messages = app.query_one("#messages", Container)
            queued_widget = QueuedUserMessage("queued")
            await messages.mount(queued_widget, before=widget)
            app._queued_widgets.append(queued_widget)

            before_tick = widget._spinner._position
            original_timer = widget._animation_timer
            original_start_time = widget._start_time
            await asyncio.sleep(0.25)
            assert widget._spinner._position != before_tick
            assert original_timer is not None

            await app._set_spinner("Thinking")
            await pilot.pause()

            # Same instance, same timer, same start time — only DOM order changed.
            assert app._loading_widget is widget
            assert widget._animation_timer is original_timer
            assert widget._start_time == original_start_time

            children = list(messages.children)
            assert children.index(widget) == children.index(queued_widget) - 1

    async def test_reposition_moves_spinner_after_last_message_when_no_queue(
        self,
    ) -> None:
        """No queued widgets: spinner must move after the last non-spinner child.

        This is the common streaming case — an `AssistantMessage` mounts at
        the end of `#messages` (landing below the spinner), and the next
        `_set_spinner("Thinking")` call must re-anchor the spinner after it
        via `move_child(..., after=non_spinner[-1])`. Covers the no-queued
        branch of `_reposition_spinner` that the queued-widget test doesn't.
        """
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._set_spinner("Thinking")
            await pilot.pause()

            widget = app._loading_widget
            assert widget is not None
            assert not app._queued_widgets

            messages = app.query_one("#messages", Container)
            new_message = AppMessage("streamed")
            await messages.mount(new_message)
            await pilot.pause()

            # Sanity: mount appended at the end, so spinner is now above it.
            children = list(messages.children)
            assert children.index(widget) < children.index(new_message)

            await app._set_spinner("Thinking")
            await pilot.pause()

            # Same widget instance; spinner now sits at the end.
            assert app._loading_widget is widget
            children = list(messages.children)
            assert children[-1] is widget


class TestTraceCommand:
    """Test /trace slash command."""

    async def test_trace_opens_browser_when_configured(self) -> None:
        """Should open the LangSmith thread URL in the browser."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    return_value="https://smith.langchain.com/o/org/projects/p/proj",
                ),
                patch("deepagents_code.app.webbrowser.open") as mock_open,
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            expected_url = (
                "https://smith.langchain.com/o/org/projects/p/proj"
                "/t/test-thread-123?utm_source=deepagents-code"
            )
            mock_open.assert_called_once_with(expected_url)
            app_msgs = app.query(AppMessage)
            rendered = "\n".join(str(w._content) for w in app_msgs)
            assert f"Opening tracing project 'proj':\n{expected_url}" in rendered

    async def test_trace_shows_error_when_not_configured(self) -> None:
        """Should show configuration hint when LangSmith is not set up."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState()

            with patch(
                "deepagents_code.config.get_langsmith_project_name",
                return_value=None,
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            assert any("LANGSMITH_API_KEY" in str(w._content) for w in app_msgs)

    async def test_trace_shows_network_error_when_url_fetch_times_out(self) -> None:
        """Should distinguish a network/timeout failure from a config gap.

        When tracing is configured (project name resolves) but the URL fetch
        raises `LangSmithLookupTimeoutError` (network unreachable, slow API),
        the user should see a network-flavored error rather than the misleading
        "not configured" message.
        """
        from deepagents_code.config import LangSmithLookupTimeoutError

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    side_effect=LangSmithLookupTimeoutError("timed out"),
                ),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            rendered = " ".join(str(w._content) for w in app_msgs)
            assert "Check your network" in rendered
            assert "LANGSMITH_API_KEY" not in rendered

    async def test_trace_shows_import_error_when_langsmith_missing(self) -> None:
        """Should tell the user to install `langsmith` when import fails."""
        from deepagents_code.config import LangSmithImportError

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    side_effect=LangSmithImportError("not installed"),
                ),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            rendered = " ".join(str(w._content) for w in app_msgs)
            assert "langsmith" in rendered.lower()
            assert "install" in rendered.lower()
            assert "network" not in rendered.lower()

    async def test_trace_shows_api_error_when_lookup_rejected(self) -> None:
        """Should surface the SDK error (auth, 404) rather than blame network."""
        from deepagents_code.config import LangSmithApiError

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    side_effect=LangSmithApiError("401 Unauthorized"),
                ),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            rendered = " ".join(str(w._content) for w in app_msgs)
            assert "401 Unauthorized" in rendered
            assert "LANGSMITH_API_KEY" in rendered
            assert "Check your network" not in rendered

    async def test_trace_shows_error_when_project_name_raises(self) -> None:
        """Should surface a friendly error if `get_langsmith_project_name` raises."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with patch(
                "deepagents_code.config.get_langsmith_project_name",
                side_effect=RuntimeError("env resolution failed"),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            rendered = " ".join(str(w._content) for w in app_msgs)
            assert "project name" in rendered.lower()

    async def test_trace_shows_error_when_no_session(self) -> None:
        """Should show error when there is no active session."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = None

            await app._handle_trace_command("/trace")
            await pilot.pause()

            app_msgs = app.query(AppMessage)
            assert any("No active session" in str(w._content) for w in app_msgs)

    async def test_trace_shows_link_when_browser_fails(self) -> None:
        """Should still display the URL link even if the browser cannot open."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    return_value="https://smith.langchain.com",
                ),
                patch(
                    "deepagents_code.app.webbrowser.open",
                    side_effect=webbrowser.Error("no browser"),
                ) as mock_open,
                patch("deepagents_code.app.logger") as mock_logger,
            ):
                await app._handle_trace_command("/trace")
                # Give the executor thread time to run and fail
                await pilot.pause()
                await asyncio.sleep(0.1)

            # Browser was attempted
            mock_open.assert_called_once()
            # Exception was logged, not silently dropped
            mock_logger.debug.assert_called()
            calls = mock_logger.debug.call_args_list
            assert any("Could not open browser" in str(c) for c in calls)
            # Link still rendered despite browser failure
            app_msgs = app.query(AppMessage)
            assert any(
                "https://smith.langchain.com/t/test-thread-123" in str(w._content)
                for w in app_msgs
            )

    async def test_trace_defers_output_when_busy(self) -> None:
        """Should defer chat output when the agent is running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")
            app._agent_running = True

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    return_value="https://smith.langchain.com",
                ),
                patch("deepagents_code.app.webbrowser.open"),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            # A QueuedUserMessage should be mounted as a placeholder
            queued = app.query(QueuedUserMessage)
            assert len(queued) == 1

            # A deferred action should be queued
            assert len(app._deferred_actions) == 1
            action = app._deferred_actions[0]
            assert action.kind == "chat_output"

            # Execute the deferred action (simulates drain after agent finishes)
            await action.execute()
            await pilot.pause()

            # Queued widget replaced by real UserMessage + AppMessage with link
            assert len(app.query(QueuedUserMessage)) == 0
            app_msgs = app.query(AppMessage)
            rendered = "\n".join(str(w._content) for w in app_msgs)
            assert (
                "Opening tracing project 'proj':\n"
                "https://smith.langchain.com/t/test-thread-123"
            ) in rendered

    async def test_trace_shows_error_when_url_build_raises(self) -> None:
        """Should show error message when the URL fetch raises."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="test-thread-123")

            with (
                patch(
                    "deepagents_code.config.get_langsmith_project_name",
                    return_value="proj",
                ),
                patch(
                    "deepagents_code.config.fetch_langsmith_project_url_or_raise",
                    side_effect=RuntimeError("SDK error"),
                ),
            ):
                await app._handle_trace_command("/trace")
                await pilot.pause()

            app_msgs = app.query(AppMessage)
            assert any("Failed to resolve" in str(w._content) for w in app_msgs)

    async def test_trace_routed_from_handle_command(self) -> None:
        """'/trace' should be correctly routed through _handle_command."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = None

            await app._handle_command("/trace")
            await pilot.pause()

            app_msgs = app.query(AppMessage)
            assert any("No active session" in str(w._content) for w in app_msgs)

    async def test_auth_routed_from_handle_command(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """'/auth' should push the AuthManagerScreen modal."""
        from deepagents_code.widgets.auth import AuthManagerScreen

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            await app._handle_command("/auth")
            await pilot.pause()
            assert isinstance(app.screen, AuthManagerScreen)

    async def test_connect_alias_routed_from_handle_command(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """'/connect' should push the AuthManagerScreen modal."""
        from deepagents_code.widgets.auth import AuthManagerScreen

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
        )
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            await app._handle_command("/connect")
            await pilot.pause()
            assert isinstance(app.screen, AuthManagerScreen)


class TestClearCommand:
    """Test /clear slash command."""

    async def test_clear_syncs_thread_id_and_schedules_link_upgrade(self) -> None:
        """/clear should render the new ID like the resumed-thread footer."""
        app = DeepAgentsApp(thread_id="old-thread")
        async with app.run_test() as pilot:
            await pilot.pause()
            app._session_state = TextualSessionState(thread_id="old-thread")
            app._lc_thread_id = "old-thread"

            with (
                patch("deepagents_code.app._new_thread_id", return_value="new-thread"),
                patch.object(app, "_schedule_thread_message_link") as schedule,
            ):
                await app._handle_command("/clear")
                await pilot.pause()

            assert app._session_state.thread_id == "new-thread"
            assert app._lc_thread_id == "new-thread"

            app_msgs = list(app.query(AppMessage))
            assert any(
                str(widget._content) == "Started new thread: new-thread"
                for widget in app_msgs
            )
            schedule.assert_called_once()
            widget = schedule.call_args.args[0]
            assert isinstance(widget, AppMessage)
            assert widget in app_msgs
            assert schedule.call_args.kwargs == {
                "prefix": "Started new thread",
                "thread_id": "new-thread",
            }


class TestCopyCommand:
    """Tests for `/copy` command behavior."""

    async def test_copy_latest_assistant_message_to_clipboard(self) -> None:
        """`/copy` copies the latest stored assistant markdown exactly."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        markdown = "# Result\n\n- keep **markdown** source"
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content=markdown)
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(True, None),
            ) as copy_mock:
                await app._handle_command("/copy")
                await pilot.pause()

            copy_mock.assert_called_once_with(app, markdown)
            assert any(w._content == "/copy" for w in app.query(UserMessage))
            assert any(
                str(w._content) == "Copied latest assistant message to clipboard."
                for w in app.query(AppMessage)
            )

    async def test_copy_skips_ineligible_newer_messages(self) -> None:
        """`/copy` reverse-scans for the newest completed assistant text."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        expected = "older completed assistant"
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content=expected)
        )
        app._message_store.append(MessageData(type=MessageType.USER, content="thanks"))
        app._message_store.append(MessageData(type=MessageType.ASSISTANT, content=""))
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content="   ")
        )
        app._message_store.append(MessageData(type=MessageType.APP, content="status"))

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(True, None),
            ) as copy_mock:
                await app._handle_command("/copy")
                await pilot.pause()

            copy_mock.assert_called_once_with(app, expected)

    async def test_copy_falls_back_when_only_streaming_assistant_present(self) -> None:
        """`/copy` skips an in-flight stream and copies the prior completed reply."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        completed = "completed reply"
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content=completed)
        )
        app._message_store.append(
            MessageData(
                type=MessageType.ASSISTANT,
                content="partial response",
                is_streaming=True,
            )
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(True, None),
            ) as copy_mock:
                await app._handle_command("/copy")
                await pilot.pause()

            copy_mock.assert_called_once_with(app, completed)

    async def test_copy_reports_streaming_pending_when_only_stream_present(
        self,
    ) -> None:
        """`/copy` distinguishes in-flight streams from a truly empty history."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        app._message_store.append(MessageData(type=MessageType.USER, content="hi"))
        app._message_store.append(
            MessageData(
                type=MessageType.ASSISTANT,
                content="partial response",
                is_streaming=True,
            )
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(True, None),
            ) as copy_mock:
                await app._handle_command("/copy")
                await pilot.pause()

            copy_mock.assert_not_called()
            assert any(
                "still streaming" in str(w._content) for w in app.query(AppMessage)
            )

    async def test_copy_reports_empty_state_without_clipboard_call(self) -> None:
        """`/copy` reports empty state when no assistant text is eligible."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        app._message_store.append(MessageData(type=MessageType.USER, content="hello"))
        app._message_store.append(MessageData(type=MessageType.ASSISTANT, content=" "))

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(True, None),
            ) as copy_mock:
                await app._handle_command("/copy")
                await pilot.pause()

            copy_mock.assert_not_called()
            assert any(
                str(w._content) == "No message to copy yet."
                for w in app.query(AppMessage)
            )

    async def test_copy_reports_clipboard_failure_with_reason(self) -> None:
        """`/copy` surfaces the backend error so users can self-diagnose."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content="assistant text")
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(False, "no clipboard mechanism for your system"),
            ):
                await app._handle_command("/copy")
                await pilot.pause()

            assert any(
                str(w._content)
                == "Failed to copy latest assistant message to clipboard:"
                " no clipboard mechanism for your system"
                for w in app.query(AppMessage)
            )

    async def test_copy_reports_clipboard_failure_without_reason(self) -> None:
        """`/copy` falls back to a generic message when no error string is given."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app = DeepAgentsApp()
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content="assistant text")
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.clipboard.copy_text_to_clipboard",
                return_value=(False, None),
            ):
                await app._handle_command("/copy")
                await pilot.pause()

            assert any(
                str(w._content)
                == "Failed to copy latest assistant message to clipboard."
                for w in app.query(AppMessage)
            )


class TestRunAgentTaskMediaTracker:
    """Tests image tracker wiring from app into textual execution."""

    async def test_run_agent_task_passes_image_tracker(self) -> None:
        """`_run_agent_task` should forward the shared image tracker."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._ui_adapter is not None

            with patch(
                "deepagents_code.textual_adapter.execute_task_textual",
                new_callable=AsyncMock,
            ) as mock_execute:
                await app._run_agent_task("hello")

            mock_execute.assert_awaited_once()
            assert mock_execute.await_args is not None
            assert mock_execute.await_args.kwargs["image_tracker"] is app._image_tracker
            assert mock_execute.await_args.kwargs["sandbox_type"] is app._sandbox_type

    async def test_run_agent_task_finalizes_pending_tools_on_error(self) -> None:
        """Unexpected agent errors should stop/clear in-flight tool widgets."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._ui_adapter is not None

            pending_tool = MagicMock()
            app._ui_adapter._current_tool_messages = {"tool-1": pending_tool}

            with patch(
                "deepagents_code.textual_adapter.execute_task_textual",
                new_callable=AsyncMock,
                side_effect=RuntimeError("boom"),
            ):
                await app._run_agent_task("hello")
                await pilot.pause()

            pending_tool.set_error.assert_called_once_with("Agent error: boom")
            assert app._ui_adapter._current_tool_messages == {}

            errors = app.query(ErrorMessage)
            assert any("Agent error: boom" in str(w._content) for w in errors)

    async def test_run_agent_task_formats_remote_exception_dict_payload(
        self,
    ) -> None:
        """`RemoteException({...})` renders as `Type: message`, not dict repr.

        Regression guard: the production fix replaces `f"Agent error: {e}"`
        with `format_agent_exception(e)`. Without this test, reverting the
        helper call would silently regress (the existing `RuntimeError("boom")`
        test passes either way).
        """
        from langgraph.pregel.remote import RemoteException

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._ui_adapter is not None

            pending_tool = MagicMock()
            app._ui_adapter._current_tool_messages = {"tool-1": pending_tool}

            exc = RemoteException(
                {"error": "ToolException", "message": "An internal error occurred"}
            )
            with patch(
                "deepagents_code.textual_adapter.execute_task_textual",
                new_callable=AsyncMock,
                side_effect=exc,
            ):
                await app._run_agent_task("hello")
                await pilot.pause()

            expected = "Agent error: ToolException: An internal error occurred"
            pending_tool.set_error.assert_called_once_with(expected)

            errors = app.query(ErrorMessage)
            assert any(expected in str(w._content) for w in errors)
            # Confirm the ugly dict repr would have differed.
            assert not any(
                "{'error': 'ToolException'" in str(w._content) for w in errors
            )

    async def test_run_agent_task_permission_denied_links_docs(self) -> None:
        """`PermissionDeniedError` errors append a clickable gateway docs link."""
        from langgraph.pregel.remote import RemoteException

        from deepagents_code.app import _GATEWAY_DOCS_URL

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._ui_adapter is not None

            exc = RemoteException(
                {"error": "PermissionDeniedError", "message": "An internal error"}
            )
            with patch(
                "deepagents_code.textual_adapter.execute_task_textual",
                new_callable=AsyncMock,
                side_effect=exc,
            ):
                await app._run_agent_task("hello")
                await pilot.pause()

            errors = app.query(ErrorMessage)
            assert any(_GATEWAY_DOCS_URL in str(w._content) for w in errors)

    async def test_run_agent_task_permission_denied_names_env_var(self) -> None:
        """A detected gateway/key mismatch names the offending env var in the UI."""
        from langgraph.pregel.remote import RemoteException

        from deepagents_code import app as app_module

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._ui_adapter is not None

            exc = RemoteException(
                {"error": "PermissionDeniedError", "message": "An internal error"}
            )
            with (
                patch.object(
                    app_module,
                    "_langsmith_gateway_key_mismatch",
                    lambda _provider: "OPENAI_API_KEY",
                ),
                patch(
                    "deepagents_code.textual_adapter.execute_task_textual",
                    new_callable=AsyncMock,
                    side_effect=exc,
                ),
            ):
                await app._run_agent_task("hello")
                await pilot.pause()

            errors = app.query(ErrorMessage)
            assert any("OPENAI_API_KEY" in str(w._content) for w in errors)


class TestBuildAgentErrorBody:
    """Cover the docs-link augmentation for agent-stream errors."""

    def test_permission_denied_appends_docs_link(self) -> None:
        from langgraph.pregel.remote import RemoteException

        from deepagents_code.app import _GATEWAY_DOCS_URL, _build_agent_error_body

        exc = RemoteException({"error": "PermissionDeniedError", "message": "x"})
        body = _build_agent_error_body("Agent error: x", exc)
        assert _GATEWAY_DOCS_URL in str(body)

    def test_other_errors_unchanged(self) -> None:
        from deepagents_code.app import _build_agent_error_body

        body = _build_agent_error_body("Agent error: boom", RuntimeError("boom"))
        assert body == "Agent error: boom"

    def test_gateway_key_mismatch_names_env_var(self) -> None:
        from langgraph.pregel.remote import RemoteException

        from deepagents_code.app import _build_agent_error_body

        exc = RemoteException({"error": "PermissionDeniedError", "message": "x"})
        body = str(
            _build_agent_error_body("Agent error: x", exc, key_env="OPENAI_API_KEY")
        )
        assert "OPENAI_API_KEY" in body
        assert "LANGCHAIN_DISABLE_GATEWAY=1" in body

    def test_permission_denied_without_mismatch_is_generic(self) -> None:
        from langgraph.pregel.remote import RemoteException

        from deepagents_code.app import _GATEWAY_DOCS_URL, _build_agent_error_body

        exc = RemoteException({"error": "PermissionDeniedError", "message": "x"})
        body = str(_build_agent_error_body("Agent error: x", exc))
        assert _GATEWAY_DOCS_URL in body
        assert "LANGCHAIN_DISABLE_GATEWAY" not in body


class TestLangsmithGatewayKeyMismatch:
    """Cover gateway/key mismatch detection without inspecting the secret."""

    def _patch(
        self,
        monkeypatch,
        *,
        base_url: str | None,
        key: str | None,
        key_env: str | None = "OPENAI_API_KEY",
    ) -> None:
        from deepagents_code import model_config

        class _Cfg:
            def get_base_url(self, _provider: str) -> str | None:
                return base_url

        monkeypatch.setattr(model_config.ModelConfig, "load", staticmethod(_Cfg))
        monkeypatch.setattr(
            model_config, "get_credential_env_var", lambda _provider: key_env
        )
        monkeypatch.setattr(model_config, "resolve_env_var", lambda _name: key)

    def test_detects_non_langsmith_key_on_gateway(self, monkeypatch) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        self._patch(
            monkeypatch,
            base_url="https://smith.langchain.com/openai",
            key="sk-proj-abc",
        )
        assert _langsmith_gateway_key_mismatch("openai") == "OPENAI_API_KEY"

    def test_detects_prefixed_non_langsmith_key_on_gateway(self, monkeypatch) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        monkeypatch.setenv("DEEPAGENTS_CODE_OPENAI_API_KEY", "sk-proj-abc")
        self._patch(
            monkeypatch,
            base_url="https://smith.langchain.com/openai",
            key="sk-proj-abc",
        )
        assert (
            _langsmith_gateway_key_mismatch("openai")
            == "DEEPAGENTS_CODE_OPENAI_API_KEY"
        )

    def test_langsmith_key_is_not_flagged(self, monkeypatch) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        self._patch(
            monkeypatch,
            base_url="https://smith.langchain.com/openai",
            key="lsv2_pt_abc",
        )
        assert _langsmith_gateway_key_mismatch("openai") is None

    def test_non_gateway_endpoint_is_not_flagged(self, monkeypatch) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        self._patch(
            monkeypatch, base_url="https://api.openai.com/v1", key="sk-proj-abc"
        )
        assert _langsmith_gateway_key_mismatch("openai") is None

    def test_no_provider_is_not_flagged(self) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        assert _langsmith_gateway_key_mismatch(None) is None

    def test_no_base_url_is_not_flagged(self, monkeypatch) -> None:
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        self._patch(monkeypatch, base_url=None, key="sk-proj-abc")
        assert _langsmith_gateway_key_mismatch("openai") is None

    def test_unknown_provider_key_env_is_not_flagged(self, monkeypatch) -> None:
        # A provider with no credential env var name cannot be diagnosed.
        self._patch(
            monkeypatch,
            base_url="https://smith.langchain.com/openai",
            key="sk-proj-abc",
            key_env=None,
        )
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        assert _langsmith_gateway_key_mismatch("openai") is None

    def test_no_key_is_not_flagged(self, monkeypatch) -> None:
        # Gateway endpoint, but the key env var resolves to nothing.
        self._patch(
            monkeypatch,
            base_url="https://smith.langchain.com/openai",
            key=None,
        )
        from deepagents_code.app import _langsmith_gateway_key_mismatch

        assert _langsmith_gateway_key_mismatch("openai") is None


class TestActiveProvider:
    """Cover the provider derivation that feeds gateway/key-mismatch detection."""

    def test_from_model_override(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        app._model_override = "openai:gpt-5.3-codex"
        assert app._active_provider() == "openai"

    def test_falls_back_to_settings_provider(self, monkeypatch) -> None:
        from deepagents_code import config

        app = DeepAgentsApp(agent=MagicMock())
        app._model_override = None
        # No model name → no full spec, so the settings fallback supplies the
        # provider directly.
        monkeypatch.setattr(config.settings, "model_provider", "anthropic")
        monkeypatch.setattr(config.settings, "model_name", "")
        assert app._active_provider() == "anthropic"

    def test_none_when_unconfigured(self, monkeypatch) -> None:
        from deepagents_code import config

        app = DeepAgentsApp(agent=MagicMock())
        app._model_override = None
        monkeypatch.setattr(config.settings, "model_provider", "")
        monkeypatch.setattr(config.settings, "model_name", "")
        assert app._active_provider() is None


class TestAppFocusRestoresChatInput:
    """Test `on_app_focus` restores chat input focus after terminal regains focus."""

    async def test_app_focus_restores_chat_input(self) -> None:
        """Regaining terminal focus should re-focus the chat input."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None

            # Blur the input to simulate focus loss from webbrowser.open
            app._chat_input._text_area.blur()
            await pilot.pause()

            app.on_app_focus()
            await pilot.pause()

            # chat_input.focus_input should have been called
            assert app._chat_input._text_area.has_focus

    async def test_app_focus_skips_when_modal_open(self) -> None:
        """Regaining focus should not steal focus from an open modal."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Push a modal screen
            from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

            screen = ThreadSelectorScreen(current_thread=None)
            app.push_screen(screen)
            await pilot.pause()

            assert isinstance(app.screen, ModalScreen)

            # on_app_focus should be a no-op with modal open
            with patch.object(app._chat_input, "focus_input") as mock_focus:
                app.on_app_focus()

            mock_focus.assert_not_called()

    async def test_click_skips_when_modal_open(self) -> None:
        """App-level click recovery should not steal focus from modal inputs."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None

            screen = LaunchNameScreen()
            app.push_screen(screen)
            await pilot.pause()

            name_input = screen.query_one("#launch-name-input", Input)
            assert name_input.has_focus

            fake_event = MagicMock()
            fake_event.widget = name_input
            with patch.object(app._chat_input, "focus_input") as mock_focus:
                app.on_click(fake_event)
                await pilot.pause()

            mock_focus.assert_not_called()
            assert name_input.has_focus

    async def test_app_focus_skips_when_approval_pending(self) -> None:
        """Regaining focus should not steal focus from the approval widget."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None

            # Simulate a pending approval widget
            app._pending_approval_widget = MagicMock()

            with patch.object(app._chat_input, "focus_input") as mock_focus:
                app.on_app_focus()

            mock_focus.assert_not_called()

    async def test_app_focus_resumes_blink_with_modal_open(self) -> None:
        """Blink should resume on focus regain even when a modal blocks refocus."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None

            from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

            app.push_screen(ThreadSelectorScreen(current_thread=None))
            await pilot.pause()
            assert isinstance(app.screen, ModalScreen)

            app._chat_input._text_area.cursor_blink = False
            app.on_app_focus()
            await pilot.pause()

            assert app._chat_input._text_area.cursor_blink is True

    async def test_app_focus_resumes_blink_with_approval_pending(self) -> None:
        """Blink should resume on focus regain even when an approval is pending."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None

            app._pending_approval_widget = MagicMock()
            app._chat_input._text_area.cursor_blink = False

            app.on_app_focus()
            await pilot.pause()

            assert app._chat_input._text_area.cursor_blink is True

    async def test_app_blur_then_focus_arms_refocus_suppression(self) -> None:
        """`on_app_blur`/`on_app_focus` forward terminal focus state to the input.

        Guards the wiring between the app's focus handlers and the text area:
        without these forwarded calls the refocus-click suppression never arms,
        so the cursor-jump fix would silently break while its own unit tests
        (which poke the text area directly) keep passing.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            text_area = app._chat_input._text_area
            assert text_area is not None

            app.on_app_blur()
            assert text_area._app_blurred is True

            app.on_app_focus()
            await pilot.pause()
            assert text_area._app_blurred is False
            assert text_area._refocus_time is not None


class TestChatScrollKeepsInputFocus:
    """Clicking a chat message must not steal focus from the chat input."""

    async def test_clicking_message_keeps_input_focused(self) -> None:
        """A click on a mounted message should leave the input focused.

        The `#chat` scroll container is focusable, so without
        `FOCUS_ON_CLICK = False` Textual would walk up from the clicked
        message to the container and refocus it, de-focusing the input.

        Drives `Screen._forward_event` with a real `MouseDown` rather than
        `Pilot.click`: the headless pilot's synthesized click doesn't exercise
        the screen's focus-on-click resolution, so it can't catch a regression.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None

            app._chat_input._text_area.focus()
            await pilot.pause()
            assert app._chat_input._text_area.has_focus

            messages = app.query_one("#messages", Container)
            message = AppMessage("hello", id="focus-test-message")
            await messages.mount(message)
            await pilot.pause()

            region = message.region
            x, y = region.x + 1, region.y
            app.screen._forward_event(
                events.MouseDown(
                    widget=message,
                    x=x,
                    y=y,
                    delta_x=0,
                    delta_y=0,
                    button=1,
                    shift=False,
                    meta=False,
                    ctrl=False,
                    screen_x=x,
                    screen_y=y,
                    style=None,
                )
            )
            await pilot.pause()

            assert app._chat_input._text_area.has_focus

    async def test_chat_scroll_does_not_focus_on_click(self) -> None:
        """The chat scroll container opts out of focus-on-click."""
        from textual.containers import VerticalScroll

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            chat = app.query_one("#chat", VerticalScroll)
            # Stays focusable (keyboard scrolling) but doesn't grab focus on click.
            assert chat.focusable
            assert chat.focus_on_click() is False


class TestMessageTimestampFooters:
    """Tests for toggleable message timestamp footers."""

    @staticmethod
    def _sync_tz() -> None:
        if hasattr(time, "tzset"):
            time.tzset()

    async def test_toggle_adds_and_removes_footers(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The `/timestamps` toggle shows and hides existing footer widgets."""
        previous_tz = os.environ.get("TZ")
        monkeypatch.setenv("TZ", "UTC")
        # Sandbox config so the toggle's persistence starts from "hidden" and
        # does not touch the real user config.
        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_CONFIG_PATH",
            tmp_path / "config.toml",
        )
        # Pin the clock style so the assertion is locale-independent.
        monkeypatch.setattr(
            "deepagents_code.formatting.uses_24_hour_clock", lambda: False
        )
        self._sync_tz()
        app = DeepAgentsApp()

        try:
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._mount_message(UserMessage("hello", id="msg-fixed"))

                footer = app.query_one("#msg-fixed-timestamp-footer", Static)
                assert footer.display is False

                await app._toggle_message_timestamp_footers()
                await pilot.pause()

                footer = app.query_one("#msg-fixed-timestamp-footer", Static)
                assert footer.display is True

                await app._toggle_message_timestamp_footers()
                await pilot.pause()

                footer = app.query_one("#msg-fixed-timestamp-footer", Static)
                assert footer.display is False
        finally:
            if previous_tz is None:
                monkeypatch.delenv("TZ", raising=False)
            else:
                monkeypatch.setenv("TZ", previous_tz)
            self._sync_tz()

    def test_build_footer_formats_timestamp(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Timestamp footer labels are formatted; `visible` stamps the class.

        `visible` is the only build-time lever over footer visibility, so it is
        asserted in both directions: a footer built with `visible=False` must
        not carry the visible class, and one built with `visible=True` must.
        """
        from deepagents_code.widgets.message_store import MessageData, MessageType

        previous_tz = os.environ.get("TZ")
        monkeypatch.setenv("TZ", "UTC")
        monkeypatch.setattr(
            "deepagents_code.formatting.uses_24_hour_clock", lambda: False
        )
        self._sync_tz()
        app = DeepAgentsApp()
        data = MessageData(
            type=MessageType.USER,
            content="hello",
            id="msg-fixed",
            timestamp=1_704_110_405.0,
        )

        try:
            footer = app._build_message_timestamp_footer(data, visible=False)

            assert footer is not None
            rendered = footer.render()
            assert isinstance(rendered, Content)
            assert rendered.plain == "Jan 1, 12:00:05 PM"
            assert not footer.has_class("message-timestamp-footer-visible")

            visible_footer = app._build_message_timestamp_footer(data, visible=True)
            assert visible_footer is not None
            assert visible_footer.has_class("message-timestamp-footer-visible")
        finally:
            if previous_tz is None:
                monkeypatch.delenv("TZ", raising=False)
            else:
                monkeypatch.setenv("TZ", previous_tz)
            self._sync_tz()

    async def test_toggle_positions_footer_after_each_message(self) -> None:
        """Message mounting keeps one footer directly after every message."""
        from deepagents_code.app import _message_timestamp_footer_id

        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            for index in range(5):
                await app._mount_message(UserMessage("hi", id=f"msg-{index}"))
            await pilot.pause()

            messages = app.query_one("#messages", Container)
            children = list(messages.children)
            for index in range(5):
                message = app.query_one(f"#msg-{index}", UserMessage)
                position = children.index(message)
                footer = children[position + 1]
                assert footer.id == _message_timestamp_footer_id(f"msg-{index}")

    async def test_repeated_toggles_do_not_mount_or_remove_footers(self) -> None:
        """Toggling flips visibility only; it never mounts or removes footers."""
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._mount_message(UserMessage("hi", id="msg-dup"))
            await pilot.pause()

            assert len(app.query("#msg-dup-timestamp-footer")) == 1
            await app._toggle_message_timestamp_footers()
            await app._toggle_message_timestamp_footers()
            await pilot.pause()

            assert len(app.query("#msg-dup-timestamp-footer")) == 1

    async def test_toggle_with_no_footers_is_a_noop(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Toggling on an empty thread takes the no-footer early-return safely.

        With zero footers mounted, `_sync_message_timestamps_display` queries an
        empty `DOMQuery` and returns early. This guards that realistic path
        (open app, run `/timestamps` before sending anything) against a crash,
        and confirms the visibility flag still flips as the source of truth.
        """
        # Sandbox the config so the toggle's persistence never touches the real
        # user config on disk.
        config = tmp_path / "config.toml"
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            assert not app.query(".message-timestamp-footer")

            before = app._message_timestamps_visible
            await app._toggle_message_timestamp_footers()
            await pilot.pause()

            assert not app.query(".message-timestamp-footer")
            assert app._message_timestamps_visible is not before

    async def test_footers_visible_on_startup_when_preference_saved(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A saved `visible` preference shows footers on startup without toggling.

        Footers are always mounted; new ones built while the preference is on
        carry the visible class so they render without waiting for a toggle.
        This guards the restart-visibility path.
        """
        # Sandbox config with the preference persisted as visible so __init__
        # loads `True` and new footers build with the visible class.
        config = tmp_path / "config.toml"
        config.write_text("[ui]\nshow_message_timestamps = true\n")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        app = DeepAgentsApp()
        assert app._message_timestamps_visible is True

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._mount_message(UserMessage("hello", id="msg-start"))
            await pilot.pause()

            footer = app.query_one("#msg-start-timestamp-footer", Static)
            assert footer.has_class("message-timestamp-footer-visible")
            assert footer.display is True

    async def test_footers_render_for_restored_thread_history(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Restoring an old thread builds visible footers for its messages."""
        from deepagents_code.app import _ThreadHistoryPayload
        from deepagents_code.widgets.message_store import MessageData, MessageType

        config = tmp_path / "config.toml"
        config.write_text("[ui]\nshow_message_timestamps = true\n")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            payload = _ThreadHistoryPayload(
                [
                    MessageData(
                        type=MessageType.USER,
                        content="restored",
                        id="hist-msg",
                        timestamp=1_704_110_405.0,
                    ),
                    # Excluded types never receive a footer, even on restore.
                    MessageData(
                        type=MessageType.APP,
                        content="Resumed thread",
                        id="hist-app",
                        timestamp=1_704_110_406.0,
                    ),
                ],
                0,
                "",
            )
            await app._load_thread_history(
                thread_id="t-restored", preloaded_payload=payload
            )
            await pilot.pause()

            footer = app.query_one("#hist-msg-timestamp-footer", Static)
            assert footer.display is True
            # Footer sits directly after its message in the DOM.
            messages = app.query_one("#messages", Container)
            children = list(messages.children)
            anchor = app.query_one("#hist-msg", UserMessage)
            assert children[children.index(anchor) + 1] is footer
            # Excluded-type messages get no footer, even on restore.
            with pytest.raises(NoMatches):
                app.query_one("#hist-app-timestamp-footer", Static)

    async def test_footers_render_for_hydrated_messages_above(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Scroll-up hydration of older messages builds visible footers."""
        from deepagents_code.app import _ThreadHistoryPayload
        from deepagents_code.widgets.message_store import MessageData, MessageType

        config = tmp_path / "config.toml"
        config.write_text("[ui]\nshow_message_timestamps = true\n")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            # Shrink the window so a small load archives messages above the
            # visible range, mirroring a long thread scrolled to the bottom.
            monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 2)
            payload = _ThreadHistoryPayload(
                [
                    MessageData(
                        type=MessageType.USER,
                        content=f"m{index}",
                        id=f"hist-{index}",
                        timestamp=1_704_110_400.0 + index,
                    )
                    for index in range(4)
                ],
                0,
                "",
            )
            await app._load_thread_history(
                thread_id="t-long", preloaded_payload=payload
            )
            await pilot.pause()

            # Older messages start archived (no widget/footer mounted yet).
            with pytest.raises(NoMatches):
                app.query_one("#hist-0-timestamp-footer", Static)

            await app._hydrate_messages_above()
            await pilot.pause()

            footer = app.query_one("#hist-0-timestamp-footer", Static)
            assert footer.display is True
            # Footer sits directly after its hydrated message in the DOM.
            messages = app.query_one("#messages", Container)
            children = list(messages.children)
            anchor = app.query_one("#hist-0", UserMessage)
            assert children[children.index(anchor) + 1] is footer

    async def test_mount_message_adds_footer_when_enabled(self) -> None:
        """New messages receive a footer while timestamps are enabled."""
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._message_timestamps_visible = True
            await app._mount_message(UserMessage("hello", id="msg-live"))
            await pilot.pause()

            assert app.query_one("#msg-live-timestamp-footer", Static)

    async def test_app_message_has_no_footer_when_enabled(self) -> None:
        """App-status notes (e.g. resumed-thread) never receive a footer."""
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._message_timestamps_visible = True
            await app._mount_message(AppMessage("Resumed thread: abc123", id="msg-app"))
            await pilot.pause()

            assert app.query_one("#msg-app", AppMessage)
            with pytest.raises(NoMatches):
                app.query_one("#msg-app-timestamp-footer", Static)

    async def test_summarization_message_has_no_footer(self) -> None:
        """`SUMMARIZATION` system notices never receive a footer."""
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._message_timestamps_visible = True
            await app._mount_message(
                SummarizationMessage("Summarized prior turns", id="msg-summ")
            )
            await pilot.pause()

            assert app.query_one("#msg-summ", SummarizationMessage)
            with pytest.raises(NoMatches):
                app.query_one("#msg-summ-timestamp-footer", Static)

    async def test_command_shows_toast_not_app_message(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`/timestamps` reports its new state via a toast, not a chat message."""
        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_CONFIG_PATH",
            tmp_path / "config.toml",
        )
        app = DeepAgentsApp()

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "notify") as notify_mock:
                await app._handle_command("/timestamps")
                await pilot.pause()

            notify_mock.assert_called_once()
            assert notify_mock.call_args.args[0] == "Message timestamps shown."
            assert notify_mock.call_args.kwargs.get("severity") == "information"
            assert notify_mock.call_args.kwargs.get("markup") is False
            assert not app.query(UserMessage)

            # Toggling back reports the "hidden" state via the same toast path.
            with patch.object(app, "notify") as notify_mock:
                await app._handle_command("/timestamps")
                await pilot.pause()

            notify_mock.assert_called_once()
            assert notify_mock.call_args.args[0] == "Message timestamps hidden."
            assert notify_mock.call_args.kwargs.get("severity") == "information"
            assert notify_mock.call_args.kwargs.get("markup") is False
            assert not app.query(UserMessage)


class TestMessageTimestampsPersistence:
    """Tests for persisting the timestamp-footer visibility to config."""

    def test_load_defaults_false_when_config_missing(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A missing config yields the hidden default."""
        from deepagents_code.app import _load_message_timestamps_visible

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_CONFIG_PATH",
            tmp_path / "config.toml",
        )
        assert _load_message_timestamps_visible() is False

    def test_load_reads_saved_true(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """An explicit `true` preference is read back."""
        from deepagents_code.app import _load_message_timestamps_visible

        config = tmp_path / "config.toml"
        config.write_text("[ui]\nshow_message_timestamps = true\n")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        assert _load_message_timestamps_visible() is True

    def test_load_ignores_non_boolean(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """A non-boolean preference is ignored with a warning."""
        from deepagents_code.app import _load_message_timestamps_visible

        config = tmp_path / "config.toml"
        config.write_text('[ui]\nshow_message_timestamps = "yes"\n')
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        with caplog.at_level("WARNING", logger="deepagents_code.app"):
            assert _load_message_timestamps_visible() is False
        assert any(
            "show_message_timestamps" in record.getMessage()
            for record in caplog.records
        )

    def test_save_round_trips(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Saving then loading returns the saved value, both directions."""
        from deepagents_code.app import (
            _load_message_timestamps_visible,
            _save_message_timestamps_visible_result,
        )

        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_CONFIG_PATH",
            tmp_path / "config.toml",
        )
        assert _save_message_timestamps_visible_result(True).ok is True
        assert _load_message_timestamps_visible() is True
        assert _save_message_timestamps_visible_result(False).ok is True
        assert _load_message_timestamps_visible() is False

    def test_save_preserves_other_ui_keys(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Persisting the toggle leaves unrelated `[ui]` keys intact."""
        import tomllib

        from deepagents_code.app import _save_message_timestamps_visible_result

        config = tmp_path / "config.toml"
        config.write_text('[ui]\ntheme = "langchain"\n')
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
        assert _save_message_timestamps_visible_result(True).ok is True
        data = tomllib.loads(config.read_text())
        assert data["ui"]["theme"] == "langchain"
        assert data["ui"]["show_message_timestamps"] is True


class TestAppBlurPausesCursorBlink:
    """Test `on_app_blur` pauses cursor blink without changing widget focus."""

    async def test_app_blur_pauses_blink(self) -> None:
        """Losing terminal focus should pause the chat input cursor blink."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None
            assert app._chat_input._text_area.cursor_blink is True

            app.on_app_blur()
            await pilot.pause()

            assert app._chat_input._text_area.cursor_blink is False

    async def test_app_blur_preserves_widget_focus(self) -> None:
        """Pausing blink must not blur the chat input widget."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None
            assert app._chat_input._text_area is not None
            app._chat_input._text_area.focus()
            await pilot.pause()

            app.on_app_blur()
            await pilot.pause()

            assert app._chat_input._text_area.has_focus is True

    async def test_app_blur_noop_before_mount(self) -> None:
        """`on_app_blur` should silently ignore blur events before mount."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._chat_input = None

            app.on_app_blur()


class TestPasteRouting:
    """Tests app-level paste routing when chat input focus lags."""

    async def test_on_paste_routes_unfocused_event_to_chat_input(self) -> None:
        """Unfocused paste events should be forwarded to chat input handler."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None

            event = events.Paste("/tmp/photo.png")
            with (
                patch.object(app, "_is_input_focused", return_value=False),
                patch.object(
                    app._chat_input, "handle_external_paste", return_value=True
                ) as mock_handle,
                patch.object(event, "prevent_default") as mock_prevent,
                patch.object(event, "stop") as mock_stop,
            ):
                app.on_paste(event)

            mock_handle.assert_called_once_with("/tmp/photo.png")
            mock_prevent.assert_called_once()
            mock_stop.assert_called_once()

    async def test_on_paste_does_not_route_when_input_already_focused(self) -> None:
        """Focused input should keep normal TextArea paste handling path."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None

            event = events.Paste("/tmp/photo.png")
            with (
                patch.object(app, "_is_input_focused", return_value=True),
                patch.object(
                    app._chat_input, "handle_external_paste", return_value=True
                ) as mock_handle,
                patch.object(event, "prevent_default") as mock_prevent,
                patch.object(event, "stop") as mock_stop,
            ):
                app.on_paste(event)

            mock_handle.assert_not_called()
            mock_prevent.assert_not_called()
            mock_stop.assert_not_called()

    async def test_on_paste_does_not_route_when_modal_open(self) -> None:
        """Modal inputs should keep paste handling instead of routing to chat input."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._chat_input is not None

            app.push_screen(LaunchNameScreen())
            await pilot.pause()

            event = events.Paste("Ada")
            with (
                patch.object(
                    app._chat_input, "handle_external_paste", return_value=True
                ) as mock_handle,
                patch.object(event, "prevent_default") as mock_prevent,
                patch.object(event, "stop") as mock_stop,
            ):
                app.on_paste(event)

            mock_handle.assert_not_called()
            mock_prevent.assert_not_called()
            mock_stop.assert_not_called()


class TestShellCommandInterrupt:
    """Tests for interruptible shell commands (! prefix) using worker pattern."""

    async def test_escape_cancels_shell_worker(self) -> None:
        """Esc while shell command is running should cancel the worker."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._shell_running = True
            mock_worker = MagicMock()
            app._shell_worker = mock_worker

            app.action_interrupt()

            mock_worker.cancel.assert_called_once()
            assert len(app._pending_messages) == 0

    async def test_ctrl_c_cancels_shell_worker(self) -> None:
        """Ctrl+C while shell command is running should cancel the worker."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._shell_running = True
            mock_worker = MagicMock()
            app._shell_worker = mock_worker

            # Queue a message to verify it gets cleared
            app._pending_messages.append(QueuedMessage(text="queued", mode="normal"))

            app.action_quit_or_interrupt()

            mock_worker.cancel.assert_called_once()
            assert len(app._pending_messages) == 0
            assert app._quit_pending is False

    async def test_process_killed_on_cancelled_error(self) -> None:
        """CancelledError in _run_shell_task should kill the process."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            mock_proc = AsyncMock()
            mock_proc.communicate = AsyncMock(side_effect=asyncio.CancelledError)
            mock_proc.returncode = None
            mock_proc.pid = 12345
            mock_proc.wait = AsyncMock()

            with (
                patch(
                    "asyncio.create_subprocess_shell",
                    return_value=mock_proc,
                ),
                patch("os.killpg") as mock_killpg,
                patch("os.getpgid", return_value=12345),
                pytest.raises(asyncio.CancelledError),
            ):
                await app._run_shell_task("sleep 999")

            mock_killpg.assert_called()
            buffered = app._pending_shell_messages
            assert buffered[0].content == "!sleep 999"
            assert "Command interrupted" in buffered[1].content

    async def test_cleanup_clears_state(self) -> None:
        """_cleanup_shell_task should reset all shell state."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._shell_running = True
            app._shell_worker = MagicMock()
            app._shell_worker.is_cancelled = False
            app._shell_process = None

            await app._cleanup_shell_task()

            assert app._shell_process is None
            assert app._shell_running is False
            assert app._shell_worker is None

    async def test_cleanup_refreshes_git_branch(self, tmp_path: Path) -> None:
        """Verify branch refresh on shell cleanup.

        `_cleanup_shell_task` must re-resolve the branch so commands like
        `git checkout` are reflected in the footer.
        """
        import subprocess

        repo = tmp_path / "repo"
        repo.mkdir()

        def _init_repo_on_feature_branch() -> None:
            env = {
                **os.environ,
                "GIT_AUTHOR_NAME": "t",
                "GIT_AUTHOR_EMAIL": "t@t",
                "GIT_COMMITTER_NAME": "t",
                "GIT_COMMITTER_EMAIL": "t@t",
            }
            for args in (
                ["git", "init", "-q", "-b", "main"],
                ["git", "add", "f"],
                ["git", "commit", "-q", "-m", "init"],
                ["git", "checkout", "-q", "-b", "feature"],
            ):
                if args[1] == "add":
                    (repo / "f").write_text("x")
                subprocess.run(args, cwd=repo, env=env, check=True, capture_output=True)

        await asyncio.to_thread(_init_repo_on_feature_branch)

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            assert app._status_bar is not None
            app._cwd = str(repo)
            app._status_bar.branch = "stale"
            app._shell_running = True
            app._shell_worker = MagicMock()
            app._shell_worker.is_cancelled = False
            app._shell_process = None

            await app._cleanup_shell_task()
            await asyncio.wait_for(_wait_for_branch(app, "feature"), timeout=1)

            assert app._status_bar.branch == "feature"

    async def test_refresh_git_branch_reads_gitdir_pointer(
        self, tmp_path: Path
    ) -> None:
        """Worktree-style `.git` files should resolve to the pointed git dir."""
        repo = tmp_path / "repo"
        worktree = tmp_path / "worktree"
        nested = worktree / "src"
        git_dir = repo / ".git" / "worktrees" / "feature"

        nested.mkdir(parents=True)
        git_dir.mkdir(parents=True)
        (worktree / ".git").write_text(
            "gitdir: ../repo/.git/worktrees/feature\n",
            encoding="utf-8",
        )
        (git_dir / "HEAD").write_text(
            "ref: refs/heads/feature/nested\n",
            encoding="utf-8",
        )

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            assert app._status_bar is not None
            app._cwd = str(nested)

            await app._refresh_git_branch()

            assert app._status_bar.branch == "feature/nested"

    async def test_refresh_git_branch_uses_inline_filesystem_fast_path(
        self, tmp_path: Path
    ) -> None:
        """Common branch reads should avoid the thread-offloaded fallback."""
        repo = tmp_path / "repo"
        git_dir = repo / ".git"
        git_dir.mkdir(parents=True)
        (git_dir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8")

        app = DeepAgentsApp()
        status_bar = MagicMock()
        app._status_bar = status_bar
        app._cwd = str(repo)

        with patch(
            "deepagents_code.app.asyncio.to_thread",
            new=AsyncMock(side_effect=AssertionError("unexpected thread hop")),
        ):
            await app._refresh_git_branch()

        assert status_bar.branch == "feature"

    async def test_cleanup_does_not_wait_for_git_branch_refresh(self) -> None:
        """Queue cleanup should not block on the subprocess fallback refresh."""
        app = DeepAgentsApp()
        refresh_started = asyncio.Event()
        release_refresh = asyncio.Event()
        drain_mock = AsyncMock()
        queue_mock = AsyncMock()

        async def block_refresh(_cwd: str) -> None:
            refresh_started.set()
            await release_refresh.wait()

        # Force the subprocess fallback path so the test can observe whether
        # cleanup awaits the background task.
        app._refresh_git_branch_subprocess_fallback = (  # ty: ignore
            block_refresh
        )
        app._maybe_drain_deferred = drain_mock  # ty: ignore
        app._process_next_from_queue = queue_mock  # ty: ignore
        app._shell_running = True
        app._shell_worker = MagicMock()
        app._shell_worker.is_cancelled = False
        app._shell_process = None

        with patch(
            "deepagents_code.app.read_git_branch_from_filesystem",
            return_value=None,
        ):
            await app._cleanup_shell_task()
            await asyncio.wait_for(refresh_started.wait(), timeout=1)

        drain_mock.assert_awaited_once()
        queue_mock.assert_awaited_once()

        release_refresh.set()
        refresh_task = app._git_branch_refresh_task
        if refresh_task is not None:
            await refresh_task

    async def test_run_shell_task_starts_branch_refresh_before_render(self) -> None:
        """Successful shell runs should overlap branch refresh with rendering."""
        app = DeepAgentsApp()
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"hello\n", b""))
        mock_proc.returncode = 0
        mock_proc.pid = 12345
        refresh_mock = MagicMock()
        drain_mock = AsyncMock()
        queue_mock = AsyncMock()

        def assert_refresh_started(message: object) -> None:
            if type(message).__name__ == "AssistantMessage":
                assert refresh_mock.call_count == 1

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = refresh_mock  # ty: ignore
            app._maybe_drain_deferred = drain_mock  # ty: ignore
            app._process_next_from_queue = queue_mock  # ty: ignore

            with (
                patch(
                    "asyncio.create_subprocess_shell",
                    return_value=mock_proc,
                ),
                patch(
                    "deepagents_code.app.AssistantMessage.write_initial_content",
                    new=AsyncMock(),
                ),
                patch.object(
                    app,
                    "_mount_message",
                    AsyncMock(side_effect=assert_refresh_started),
                ),
            ):
                await app._run_shell_task("echo hi")

        refresh_mock.assert_called_once_with()
        drain_mock.assert_awaited_once()
        queue_mock.assert_awaited_once()

    async def test_messages_queued_during_shell(self) -> None:
        """Messages should be queued while shell command runs."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._shell_running = True

            app.post_message(ChatInput.Submitted("queued msg", "normal"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "queued msg"

    async def test_queue_drains_after_shell_completes(self) -> None:
        """Pending messages should drain after _cleanup_shell_task."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._shell_running = True
            app._shell_worker = MagicMock()
            app._shell_worker.is_cancelled = False
            app._shell_process = None

            # Enqueue a message
            app._pending_messages.append(
                QueuedMessage(text="after shell", mode="normal")
            )

            await app._cleanup_shell_task()
            await pilot.pause()

            # Message should have been processed (mounted as UserMessage)
            user_msgs = app.query(UserMessage)
            assert any(w._content == "after shell" for w in user_msgs)

    async def test_interrupted_shows_message(self) -> None:
        """Cancelled worker should show 'Command interrupted'."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._shell_running = True
            mock_worker = MagicMock()
            mock_worker.is_cancelled = True
            app._shell_worker = mock_worker
            # Process still set means it was interrupted mid-flight
            mock_proc = MagicMock()
            mock_proc.returncode = None
            app._shell_process = mock_proc

            await app._cleanup_shell_task()
            await pilot.pause()

            app_msgs = app.query(AppMessage)
            assert any("Command interrupted" in str(w._content) for w in app_msgs)

    async def test_timeout_kills_and_shows_error(self) -> None:
        """Timeout in _run_shell_task should kill process and show error."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            mock_proc = AsyncMock()
            mock_proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError)
            mock_proc.returncode = None
            mock_proc.pid = 12345
            mock_proc.wait = AsyncMock()

            with (
                patch(
                    "asyncio.create_subprocess_shell",
                    return_value=mock_proc,
                ),
                patch("os.killpg"),
                patch("os.getpgid", return_value=12345),
            ):
                await app._run_shell_task("sleep 999")
                await pilot.pause()

            assert app._shell_process is None
            error_msgs = app.query(ErrorMessage)
            assert any("timed out" in w._content for w in error_msgs)
            buffered = app._pending_shell_messages
            assert buffered[0].content == "!sleep 999"
            assert "timed out" in buffered[1].content

    async def test_incognito_timeout_feedback_is_not_model_visible(self) -> None:
        """Incognito timeout feedback should stay out of user/assistant records."""
        from deepagents_code.widgets.message_store import MessageType

        app = DeepAgentsApp()
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError)
        mock_proc.returncode = None
        mock_proc.pid = 12345
        mock_proc.wait = AsyncMock()
        mock_proc.terminate = MagicMock()
        mock_proc.kill = MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()

            with patch(
                "asyncio.create_subprocess_shell",
                return_value=mock_proc,
            ):
                await app._run_shell_task("echo secret", incognito=True)
                await pilot.pause()

            messages = app._message_store.get_all_messages()
            assert any(
                msg.type == MessageType.ERROR and "timed out" in msg.content
                for msg in messages
            )
            assert not any(
                msg.type in {MessageType.USER, MessageType.ASSISTANT}
                and "secret" in msg.content
                for msg in messages
            )

    async def test_posix_killpg_called(self) -> None:
        """On POSIX, _kill_shell_process should use os.killpg with SIGTERM."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            mock_proc = AsyncMock()
            mock_proc.returncode = None
            mock_proc.pid = 42
            mock_proc.wait = AsyncMock()
            app._shell_process = mock_proc

            with (
                patch("deepagents_code.app.sys") as mock_sys,
                patch("os.killpg") as mock_killpg,
                patch("os.getpgid", return_value=42) as mock_getpgid,
            ):
                mock_sys.platform = "linux"
                await app._kill_shell_process()

            mock_getpgid.assert_called_once_with(42)
            mock_killpg.assert_called_once_with(42, signal.SIGTERM)

    async def test_sigkill_escalation(self) -> None:
        """SIGKILL should be sent when SIGTERM times out."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            mock_proc = AsyncMock()
            mock_proc.returncode = None
            mock_proc.pid = 42
            mock_proc.wait = AsyncMock(side_effect=asyncio.TimeoutError)
            mock_proc.kill = MagicMock()
            app._shell_process = mock_proc

            with (
                patch("deepagents_code.app.sys") as mock_sys,
                patch("os.killpg") as mock_killpg,
                patch("os.getpgid", return_value=42),
            ):
                mock_sys.platform = "linux"
                await app._kill_shell_process()

            # First call: SIGTERM, second call: SIGKILL
            assert mock_killpg.call_count == 2
            mock_killpg.assert_any_call(42, signal.SIGTERM)
            mock_killpg.assert_any_call(42, signal.SIGKILL)

    async def test_no_op_when_no_shell_running(self) -> None:
        """Ctrl+C with no shell command running should fall through to quit hint."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            assert not app._shell_running
            app.action_quit_or_interrupt()

            assert app._quit_pending is True

    async def test_oserror_shows_error_message(self) -> None:
        """OSError from create_subprocess_shell should display error."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            with patch(
                "asyncio.create_subprocess_shell",
                side_effect=OSError("Permission denied"),
            ):
                await app._run_shell_task("forbidden")
                await pilot.pause()

            assert app._shell_process is None
            error_msgs = app.query(ErrorMessage)
            assert any("Permission denied" in w._content for w in error_msgs)
            buffered = app._pending_shell_messages
            assert buffered[0].content == "!forbidden"
            assert "Permission denied" in buffered[1].content

    async def test_handle_shell_command_sets_running_state(self) -> None:
        """_handle_shell_command should set _shell_running and spawn worker."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            with patch.object(app, "run_worker") as mock_rw:
                mock_rw.return_value = MagicMock()
                await app._handle_shell_command("echo hi")

            assert app._shell_running is True
            assert app._shell_worker is not None
            mock_rw.assert_called_once()
            # Close the unawaited coroutine to suppress RuntimeWarning
            coro = mock_rw.call_args[0][0]
            coro.close()

    async def test_process_message_routes_incognito_shell_command(self) -> None:
        """`shell_incognito` mode should strip `!!` and mark the shell run."""
        app = DeepAgentsApp()
        handler = AsyncMock()
        app._handle_shell_command = handler  # ty: ignore

        await app._process_message("!!echo secret", "shell_incognito")

        handler.assert_awaited_once_with("echo secret", incognito=True)

    async def test_incognito_shell_command_does_not_mount_header(self) -> None:
        """Incognito shell commands should not echo the command before output."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            with patch.object(app, "run_worker") as mock_rw:
                mock_rw.return_value = MagicMock()
                await app._handle_shell_command("echo secret", incognito=True)

            messages = app._message_store.get_all_messages()
            assert not any(
                "incognito shell command" in msg.content or "echo secret" in msg.content
                for msg in messages
            )

            # Close the unawaited coroutine to suppress RuntimeWarning.
            coro = mock_rw.call_args[0][0]
            coro.close()

    async def test_incognito_shell_output_is_app_message(self) -> None:
        """Incognito shell output should avoid assistant transcript records."""
        from deepagents_code.widgets.message_store import MessageType

        app = DeepAgentsApp()
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"secret\n", b""))
        mock_proc.returncode = 0
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with (
                patch(
                    "asyncio.create_subprocess_shell",
                    return_value=mock_proc,
                ),
                patch(
                    "deepagents_code.app.AssistantMessage.write_initial_content",
                    new=AsyncMock(),
                ) as write_mock,
            ):
                await app._run_shell_task("echo secret", incognito=True)
                await pilot.pause()

        messages = app._message_store.get_all_messages()
        assert any(
            msg.type == MessageType.APP and "secret" in msg.content for msg in messages
        )
        assert not any(
            msg.type in {MessageType.USER, MessageType.ASSISTANT}
            and "secret" in msg.content
            for msg in messages
        )
        write_mock.assert_not_awaited()

    async def test_incognito_nonzero_exit_keeps_stderr_out_of_model(self) -> None:
        """A failing incognito command must not leak stderr to model records."""
        from deepagents_code.widgets.message_store import MessageType

        app = DeepAgentsApp()
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"", b"secret leak"))
        mock_proc.returncode = 1
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with patch(
                "asyncio.create_subprocess_shell",
                return_value=mock_proc,
            ):
                await app._run_shell_task("falsey", incognito=True)
                await pilot.pause()

        messages = app._message_store.get_all_messages()
        assert not any(
            msg.type in {MessageType.USER, MessageType.ASSISTANT}
            and "secret leak" in msg.content
            for msg in messages
        )

    async def test_non_incognito_shell_buffers_for_model_context(self) -> None:
        """A `!` command/output is buffered, not written immediately."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"

        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"hello world\n", b""))
        mock_proc.returncode = 0
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with patch(
                "asyncio.create_subprocess_shell",
                return_value=mock_proc,
            ):
                await app._run_shell_task("echo hello world", incognito=False)
                await pilot.pause()

        # Deferred: nothing written to graph state until the next user send.
        app._agent.aupdate_state.assert_not_awaited()
        buffered = app._pending_shell_messages
        assert isinstance(buffered[0], HumanMessage)
        assert buffered[0].content == "!echo hello world"
        assert isinstance(buffered[1], AIMessage)
        assert "hello world" in buffered[1].content

    async def test_pending_shell_flushed_on_next_user_send(self) -> None:
        """Buffered `!` output is written to graph state on the next send."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"
        app._ui_adapter = MagicMock()
        app._session_state = MagicMock()
        app._pending_shell_messages = [
            HumanMessage(content="!echo hi"),
            AIMessage(content="```\nhi\n```"),
        ]

        async with app.run_test() as pilot:
            await pilot.pause()

            with patch.object(app, "run_worker") as mock_rw:
                mock_rw.return_value = MagicMock()
                await app._send_to_agent("what did that print?")
                coro = mock_rw.call_args[0][0]
                coro.close()

        app._agent.aupdate_state.assert_awaited_once()
        call = app._agent.aupdate_state.await_args
        assert call is not None
        assert call.args[0]["configurable"]["thread_id"] == "thread-123"
        sent = call.args[1]["messages"]
        assert sent[0].content == "!echo hi"
        # Buffer is drained so it is not replayed onto a later turn.
        assert app._pending_shell_messages == []

    async def test_pending_shell_first_message_uses_session_thread(self) -> None:
        """A first-message `!` command should flush to the new session thread."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._ui_adapter = MagicMock()
        app._pending_shell_messages = [
            HumanMessage(content="!echo before-chat"),
            AIMessage(content="```\nbefore-chat\n```"),
        ]

        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._session_state is not None
            app._lc_thread_id = None
            app._session_state.thread_id = "thread-first"

            with patch.object(app, "run_worker") as mock_rw:
                mock_rw.return_value = MagicMock()
                await app._send_to_agent("what did that print?")
                coro = mock_rw.call_args[0][0]
                coro.close()

        app._agent.aupdate_state.assert_awaited_once()
        call = app._agent.aupdate_state.await_args
        assert call is not None
        assert call.args[0]["configurable"]["thread_id"] == "thread-first"
        assert app._lc_thread_id == "thread-first"
        assert app._pending_shell_messages == []

    async def test_pending_shell_flush_ensures_remote_thread_first(self) -> None:
        """Server mode must register a fresh thread before flushing shell output."""
        from langchain_core.messages import AIMessage, HumanMessage

        from deepagents_code.remote_client import RemoteAgent

        calls: list[str] = []
        remote = MagicMock(spec=RemoteAgent)
        remote.aensure_thread = AsyncMock(
            side_effect=lambda _config: calls.append("ensure")
        )
        agent = MagicMock()
        agent.aupdate_state = AsyncMock(
            side_effect=lambda _config, _values: calls.append("update")
        )

        app = DeepAgentsApp(agent=agent, thread_id="thread-remote")
        app._pending_shell_messages = [
            HumanMessage(content="!pwd"),
            AIMessage(content="```\n/tmp/project\n```"),
        ]

        with patch.object(app, "_remote_agent", return_value=remote):
            await app._flush_pending_shell_messages()

        remote.aensure_thread.assert_awaited_once_with(
            {"configurable": {"thread_id": "thread-remote"}}
        )
        agent.aupdate_state.assert_awaited_once()
        assert calls == ["ensure", "update"]
        assert app._pending_shell_messages == []

    async def test_incognito_shell_output_not_buffered(self) -> None:
        """A `!!` command/output must never be buffered for the model."""
        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"

        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"secret\n", b""))
        mock_proc.returncode = 0
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with (
                patch(
                    "asyncio.create_subprocess_shell",
                    return_value=mock_proc,
                ),
                patch(
                    "deepagents_code.app.AssistantMessage.write_initial_content",
                    new=AsyncMock(),
                ),
            ):
                await app._run_shell_task("echo secret", incognito=True)
                await pilot.pause()

        assert app._pending_shell_messages == []
        app._agent.aupdate_state.assert_not_awaited()

    async def test_startup_command_output_not_buffered(self) -> None:
        """`--startup-cmd` output is local setup output, not model context."""
        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"

        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"secret-startup\n", b""))
        mock_proc.returncode = 0
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with patch(
                "asyncio.create_subprocess_shell",
                return_value=mock_proc,
            ):
                await app._run_startup_command("echo secret-startup")
                await pilot.pause()

        assert app._pending_shell_messages == []
        app._agent.aupdate_state.assert_not_awaited()

    async def test_non_incognito_shell_nonzero_exit_buffered(self) -> None:
        """A failing `!` command should buffer its exit code for the model."""
        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"

        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"", b"boom"))
        mock_proc.returncode = 2
        mock_proc.pid = 12345

        async with app.run_test() as pilot:
            await pilot.pause()

            app._schedule_git_branch_refresh = MagicMock()  # ty: ignore
            app._maybe_drain_deferred = AsyncMock()  # ty: ignore
            app._process_next_from_queue = AsyncMock()  # ty: ignore

            with patch(
                "asyncio.create_subprocess_shell",
                return_value=mock_proc,
            ):
                await app._run_shell_task("falsey", incognito=False)
                await pilot.pause()

        buffered = app._pending_shell_messages
        assert "boom" in buffered[1].content
        assert "exited with code 2" in buffered[1].content

    async def test_clear_messages_drops_pending_shell_buffer(self) -> None:
        """`_clear_messages` must drop buffered `!` output (no cross-thread leak)."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._pending_shell_messages = [
            HumanMessage(content="!echo secret"),
            AIMessage(content="```\nsecret\n```"),
        ]

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._clear_messages()

        assert app._pending_shell_messages == []

    async def test_clear_messages_drops_active_user_message(self) -> None:
        """`_clear_messages` must drop the tracked in-flight prompt reference.

        The widget is removed from the DOM during clear, so the pointer must
        not outlive it — otherwise a later interrupt would dim a detached
        widget the user can no longer see.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            user_message = UserMessage("running prompt")
            messages = app.query_one("#messages", Container)
            await messages.mount(user_message)
            app._active_user_message = user_message
            await pilot.pause()

            await app._clear_messages()

            assert app._active_user_message is None

    async def test_pending_shell_flush_failure_drops_buffer(self) -> None:
        """A checkpoint-write failure must not raise and must clear the buffer."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock(
            side_effect=RuntimeError("checkpoint down")
        )
        app._lc_thread_id = "thread-123"
        app._pending_shell_messages = [
            HumanMessage(content="!echo hi"),
            AIMessage(content="```\nhi\n```"),
        ]

        # Must not propagate; buffer is dropped so stale output is not replayed.
        await app._flush_pending_shell_messages()

        app._agent.aupdate_state.assert_awaited_once()
        assert app._pending_shell_messages == []

    async def test_pending_shell_flush_without_agent_retains_buffer(self) -> None:
        """With no agent/thread, the buffer is kept for a later send, not dropped."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = None
        app._lc_thread_id = None
        app._session_state = None
        buffered = [
            HumanMessage(content="!echo hi"),
            AIMessage(content="```\nhi\n```"),
        ]
        app._pending_shell_messages = list(buffered)

        await app._flush_pending_shell_messages()

        # Retained verbatim for a later flush once an agent/thread exists.
        assert app._pending_shell_messages == buffered

    async def test_pending_shell_flush_precedes_agent_worker(self) -> None:
        """The `!` flush must land before the user-message turn is spawned."""
        from langchain_core.messages import AIMessage, HumanMessage

        app = DeepAgentsApp()
        app._agent = MagicMock()
        app._agent.aupdate_state = AsyncMock()
        app._lc_thread_id = "thread-123"
        app._ui_adapter = MagicMock()
        app._session_state = MagicMock()
        app._pending_shell_messages = [
            HumanMessage(content="!echo hi"),
            AIMessage(content="```\nhi\n```"),
        ]

        async with app.run_test() as pilot:
            await pilot.pause()

            manager = MagicMock()
            with patch.object(app, "run_worker") as mock_rw:
                mock_rw.return_value = MagicMock()
                manager.attach_mock(app._agent.aupdate_state, "flush")
                manager.attach_mock(mock_rw, "spawn")
                await app._send_to_agent("what did that print?")
                coro = mock_rw.call_args[0][0]
                coro.close()

        # Flush the `!` pair into state before spawning the turn, so the model
        # sees the shell output ahead of this turn's user message.
        names = [c[0] for c in manager.mock_calls if c[0] in {"flush", "spawn"}]
        assert names == ["flush", "spawn"]

    async def test_buffer_shell_appends_in_command_order(self) -> None:
        """Multiple `!` commands buffer as ordered Human/AI pairs."""
        app = DeepAgentsApp()
        app._buffer_shell_for_model_context("cmd-a", "out-a", 0)
        app._buffer_shell_for_model_context("cmd-b", "out-b", 0)

        contents = [m.content for m in app._pending_shell_messages]
        assert contents[0] == "!cmd-a"
        assert "out-a" in contents[1]
        assert contents[2] == "!cmd-b"
        assert "out-b" in contents[3]

    async def test_buffer_shell_empty_output_uses_placeholder(self) -> None:
        """Empty output is buffered as `(no output)` so the pair is never blank."""
        app = DeepAgentsApp()
        app._buffer_shell_for_model_context("true", "", 0)

        assert "(no output)" in app._pending_shell_messages[1].content

    async def test_buffer_shell_zero_exit_omits_status_suffix(self) -> None:
        """A successful `!` command must not annotate an exit code."""
        app = DeepAgentsApp()
        app._buffer_shell_for_model_context("echo ok", "ok", 0)

        assert "exited with code" not in app._pending_shell_messages[1].content

    async def test_unknown_input_mode_does_not_dispatch_to_agent(self) -> None:
        """An unrecognized mode must surface an error rather than reach the LLM.

        Regression guard for the privacy invariant: a typo or stale mode
        literal must never silently fall through to `_handle_user_message`.
        """
        from deepagents_code.widgets.message_store import MessageType

        app = DeepAgentsApp()
        user_handler = AsyncMock()
        shell_handler = AsyncMock()
        app._handle_user_message = user_handler  # ty: ignore
        app._handle_shell_command = shell_handler  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()

            await app._process_message("!!echo secret", "shell_incognto")  # ty: ignore
            await pilot.pause()

            user_handler.assert_not_awaited()
            shell_handler.assert_not_awaited()

            messages = app._message_store.get_all_messages()
            assert any(
                msg.type == MessageType.ERROR and "unknown input mode" in msg.content
                for msg in messages
            )

    async def test_kill_noop_when_already_exited(self) -> None:
        """_kill_shell_process should no-op if process already exited."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            mock_proc = AsyncMock()
            mock_proc.returncode = 0
            mock_proc.pid = 42
            app._shell_process = mock_proc

            with patch("os.killpg") as mock_killpg:
                await app._kill_shell_process()

            mock_killpg.assert_not_called()
            mock_proc.terminate.assert_not_called()

    async def test_end_to_end_escape_during_shell(self) -> None:
        """Esc during a running shell worker should cancel execution."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Simulate a running shell state with a mock worker
            app._shell_running = True
            mock_worker = MagicMock()
            app._shell_worker = mock_worker

            await pilot.press("escape")
            await pilot.pause()

            mock_worker.cancel.assert_called_once()


class TestAppArgumentHints:
    """Full-app regressions for slash-command argument hints."""

    async def test_hint_clears_after_command_submission(self) -> None:
        """Submitting a slash command clears the inline argument hint."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            chat = app.query_one(ChatInput)
            assert chat._text_area is not None

            chat._text_area.insert("/")
            await pilot.pause()
            await pilot.pause()
            chat._text_area.insert("remember ")
            await pilot.pause()

            assert chat.mode == "command"
            assert chat._text_area.argument_hint == "[context]"
            assert chat._text_area.render_line(0).text.rstrip() == "remember [context]"

            await pilot.press("enter")
            await pilot.pause()

            assert chat.mode == "normal"
            assert chat._text_area.text == ""
            assert chat._text_area.argument_hint == ""

    async def test_hint_clears_after_backspace_mode_exit(self) -> None:
        """Backspace mode exit clears the hint in the mounted app."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            chat = app.query_one(ChatInput)
            assert chat._text_area is not None

            chat._text_area.insert("/")
            await pilot.pause()
            await pilot.pause()
            chat._text_area.insert("remember ")
            await pilot.pause()

            assert chat.mode == "command"
            assert chat._text_area.argument_hint == "[context]"
            assert chat._text_area.render_line(0).text.rstrip() == "remember [context]"

            for _ in "remember ":
                await pilot.press("left")
            await pilot.pause()
            assert chat._text_area.cursor_location == (0, 0)
            assert chat._text_area.render_line(0).text.rstrip() == "remember [context]"

            await pilot.press("backspace")
            await pilot.pause()

            assert chat.mode == "normal"
            assert chat._text_area.text == "remember "
            assert chat._text_area.argument_hint == ""


class TestInterruptApprovalPriority:
    """Tests for escape interrupt priority when HITL approval is pending."""

    async def test_escape_rejects_approval_before_canceling_worker(self) -> None:
        """When both HITL approval and worker are active, reject approval first."""
        app = DeepAgentsApp()
        approval = MagicMock()
        worker = MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()

            app._pending_approval_widget = approval
            app._agent_running = True
            app._agent_worker = worker

            app.action_interrupt()

        approval.action_select_reject.assert_called_once()
        worker.cancel.assert_not_called()

    async def test_escape_pops_queue_before_cancelling_worker(self) -> None:
        """Escape pops queued messages (LIFO) before cancelling the worker."""
        app = DeepAgentsApp()
        worker = MagicMock()
        queued_w1 = MagicMock()
        queued_w2 = MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()

            app._pending_approval_widget = None
            app._agent_running = True
            app._agent_worker = worker
            app._pending_messages.append(QueuedMessage(text="q1", mode="normal"))
            app._pending_messages.append(QueuedMessage(text="q2", mode="normal"))
            app._queued_widgets.append(queued_w1)
            app._queued_widgets.append(queued_w2)

            # First ESC pops last queued message, does not cancel worker
            app.action_interrupt()
            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "q1"
            queued_w2.remove.assert_called_once()
            queued_w1.remove.assert_not_called()
            worker.cancel.assert_not_called()

            # Second ESC pops remaining message
            app.action_interrupt()
            assert len(app._pending_messages) == 0
            queued_w1.remove.assert_called_once()
            worker.cancel.assert_not_called()

            # Third ESC finally cancels the worker
            app.action_interrupt()
            worker.cancel.assert_called_once()

    async def test_escape_rejects_approval_when_no_worker(self) -> None:
        """Approval rejection works even without an active agent worker."""
        app = DeepAgentsApp()
        approval = MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()

            app._pending_approval_widget = approval
            app._agent_running = False
            app._agent_worker = None

            app.action_interrupt()

        approval.action_select_reject.assert_called_once()

    async def test_ctrl_c_rejects_approval_before_canceling_worker(self) -> None:
        """Ctrl+C should also reject approval before canceling worker."""
        app = DeepAgentsApp()
        approval = MagicMock()
        worker = MagicMock()

        async with app.run_test() as pilot:
            await pilot.pause()

            app._pending_approval_widget = approval
            app._agent_running = True
            app._agent_worker = worker

            app.action_quit_or_interrupt()

        approval.action_select_reject.assert_called_once()
        worker.cancel.assert_not_called()
        assert app._quit_pending is False


class TestIsUserTyping:
    """Unit tests for `_is_user_typing()` threshold logic."""

    def test_returns_false_when_never_typed(self) -> None:
        """Should return False if _last_typed_at is None."""
        app = DeepAgentsApp()
        assert app._is_user_typing() is False

    def test_returns_true_within_threshold(self) -> None:
        """Should return True right after a keystroke."""
        app = DeepAgentsApp()
        app._last_typed_at = time.monotonic()
        assert app._is_user_typing() is True

    def test_returns_false_after_threshold(self) -> None:
        """Should return False once the idle threshold has elapsed."""
        app = DeepAgentsApp()
        app._last_typed_at = time.monotonic() - (_TYPING_IDLE_THRESHOLD_SECONDS + 0.1)
        assert app._is_user_typing() is False

    def test_boundary_just_within_threshold(self) -> None:
        """Should return True when just inside the threshold window."""
        app = DeepAgentsApp()
        app._last_typed_at = time.monotonic() - (_TYPING_IDLE_THRESHOLD_SECONDS - 0.1)
        assert app._is_user_typing() is True


class TestRequestApprovalBranching:
    """_request_approval should show a placeholder when the user is typing."""

    async def test_placeholder_mounted_when_typing(self) -> None:
        """If the user is typing, a Static placeholder is mounted instead of menu."""
        app = DeepAgentsApp(agent=MagicMock())
        # Simulate recent typing
        app._last_typed_at = time.monotonic()

        mounted_classes: list[str] = []

        async def fake_mount_before_queued(  # noqa: RUF029
            _container: object, widget: object
        ) -> None:
            if isinstance(widget, Static):
                mounted_classes.append(" ".join(widget.classes))

        app._mount_before_queued = fake_mount_before_queued  # ty: ignore

        # Prevent actual worker from running; we just want to check branching.
        run_worker_calls: list[object] = []

        def _stub_worker(coro: object, **_: object) -> MagicMock:
            # Consume the coroutine immediately to suppress RuntimeWarning.
            if inspect.iscoroutine(coro):
                coro.close()
            run_worker_calls.append(coro)
            return MagicMock()

        app.run_worker = _stub_worker  # ty: ignore

        dummy_container = MagicMock()
        app.query_one = MagicMock(return_value=dummy_container)  # ty: ignore

        action_requests = [
            {"name": "write_file", "args": {"path": "/tmp/x.txt", "content": "hi"}}
        ]
        future = asyncio.get_running_loop().create_future()

        with patch.object(asyncio, "get_running_loop") as mock_loop:
            mock_loop.return_value.create_future.return_value = future
            returned = await app._request_approval(action_requests, None)

        assert returned is future
        assert any("approval-placeholder" in cls for cls in mounted_classes), (
            f"Expected 'approval-placeholder' in mounted widget classes,"
            f" got {mounted_classes}"
        )
        assert len(run_worker_calls) == 1, (
            "run_worker should have been called once for the deferred swap"
        )

    async def test_placeholder_mount_failure_falls_back_to_menu(self) -> None:
        """If placeholder mount fails, the ApprovalMenu is shown directly."""
        from deepagents_code.widgets.approval import ApprovalMenu

        app = DeepAgentsApp(agent=MagicMock())
        app._last_typed_at = time.monotonic()

        mounted_types: list[type] = []

        call_count = 0

        async def failing_then_ok_mount(  # noqa: RUF029
            _container: object, widget: object
        ) -> None:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                msg = "simulated mount failure"
                raise RuntimeError(msg)
            mounted_types.append(type(widget))

        app._mount_before_queued = failing_then_ok_mount  # ty: ignore
        app.call_after_refresh = MagicMock()  # ty: ignore

        dummy_container = MagicMock()
        app.query_one = MagicMock(return_value=dummy_container)  # ty: ignore

        action_requests = [
            {"name": "write_file", "args": {"path": "/tmp/z.txt", "content": "hi"}}
        ]
        future = asyncio.get_running_loop().create_future()

        with patch.object(asyncio, "get_running_loop") as mock_loop:
            mock_loop.return_value.create_future.return_value = future
            returned = await app._request_approval(action_requests, None)

        assert returned is future
        # Placeholder mount (1st call) fails, fallback menu mount (2nd call)
        # succeeds. The menu is now mounted and the future awaits user input.
        assert ApprovalMenu in mounted_types, (
            f"Expected ApprovalMenu fallback mount, got {mounted_types}"
        )

    async def test_menu_mounted_directly_when_not_typing(self) -> None:
        """If the user is NOT typing, the ApprovalMenu is mounted directly."""
        from deepagents_code.widgets.approval import ApprovalMenu

        app = DeepAgentsApp(agent=MagicMock())
        app._last_typed_at = None

        mounted_types: list[type] = []

        async def fake_mount_before_queued(  # noqa: RUF029
            _container: object, widget: object
        ) -> None:
            mounted_types.append(type(widget))

        app._mount_before_queued = fake_mount_before_queued  # ty: ignore
        app.call_after_refresh = MagicMock()  # ty: ignore

        dummy_container = MagicMock()
        app.query_one = MagicMock(return_value=dummy_container)  # ty: ignore

        action_requests = [
            {"name": "write_file", "args": {"path": "/tmp/y.txt", "content": "hi"}}
        ]
        future = asyncio.get_running_loop().create_future()

        with patch.object(asyncio, "get_running_loop") as mock_loop:
            mock_loop.return_value.create_future.return_value = future
            returned = await app._request_approval(action_requests, None)

        assert returned is future
        assert ApprovalMenu in mounted_types, (
            f"Expected ApprovalMenu to be mounted, got {mounted_types}"
        )


class TestDeferredShowApproval:
    """_deferred_show_approval should swap placeholder once idle."""

    async def test_swaps_placeholder_for_menu_after_idle(self) -> None:
        """Once typing stops, placeholder is removed and menu is mounted."""
        from deepagents_code.widgets.approval import ApprovalMenu

        app = DeepAgentsApp(agent=MagicMock())
        app._last_typed_at = time.monotonic()

        placeholder = MagicMock(spec=Static)
        placeholder.is_attached = True
        remove_called = False

        async def fake_remove() -> None:  # noqa: RUF029
            nonlocal remove_called
            remove_called = True

        placeholder.remove = fake_remove

        action_requests = [{"name": "write_file", "args": {}}]
        future = asyncio.get_running_loop().create_future()
        menu = ApprovalMenu(action_requests[0])
        menu.set_future(future)

        mount_called = False

        async def fake_mount_approval(  # noqa: RUF029
            m: ApprovalMenu,  # noqa: ARG001
            f: asyncio.Future[dict[str, str]],  # noqa: ARG001
        ) -> None:
            nonlocal mount_called
            mount_called = True

        app._mount_approval_widget = fake_mount_approval  # ty: ignore

        async def stop_typing() -> None:
            await asyncio.sleep(0.05)
            app._last_typed_at = None

        typing_task = asyncio.create_task(stop_typing())
        await app._deferred_show_approval(placeholder, menu, future)
        await typing_task

        assert remove_called, "placeholder.remove() should have been called"
        assert mount_called, "_mount_approval_widget should have been called"

    async def test_bails_if_placeholder_detached_and_cancels_future(self) -> None:
        """If placeholder is detached, worker cancels the future and exits."""
        from deepagents_code.widgets.approval import ApprovalMenu

        app = DeepAgentsApp(agent=MagicMock())
        app._last_typed_at = None

        placeholder = MagicMock(spec=Static)
        placeholder.is_attached = False

        mount_called = False

        async def fake_mount_approval(  # noqa: RUF029
            m: ApprovalMenu,  # noqa: ARG001
            f: asyncio.Future[dict[str, str]],  # noqa: ARG001
        ) -> None:
            nonlocal mount_called
            mount_called = True

        app._mount_approval_widget = fake_mount_approval  # ty: ignore

        action_requests = [{"name": "shell", "args": {"command": "ls"}}]
        future = asyncio.get_running_loop().create_future()
        menu = ApprovalMenu(action_requests[0])
        menu.set_future(future)

        await app._deferred_show_approval(placeholder, menu, future)

        assert not mount_called, "_mount_approval_widget should NOT have been called"
        assert future.cancelled(), "future should have been cancelled"
        assert app._pending_approval_widget is None
        assert app._approval_placeholder is None

    async def test_timeout_shows_approval_after_deadline(self) -> None:
        """If the user types continuously past the deadline, menu is shown anyway."""
        from deepagents_code.widgets.approval import ApprovalMenu

        app = DeepAgentsApp(agent=MagicMock())
        # Simulate user typing *forever* by keeping _last_typed_at fresh
        app._last_typed_at = time.monotonic()

        placeholder = MagicMock(spec=Static)
        placeholder.is_attached = True

        remove_called = False

        async def fake_remove() -> None:  # noqa: RUF029
            nonlocal remove_called
            remove_called = True

        placeholder.remove = fake_remove

        mount_called = False

        async def fake_mount_approval(  # noqa: RUF029
            m: ApprovalMenu,  # noqa: ARG001
            f: asyncio.Future[dict[str, str]],  # noqa: ARG001
        ) -> None:
            nonlocal mount_called
            mount_called = True

        app._mount_approval_widget = fake_mount_approval  # ty: ignore

        action_requests = [{"name": "write_file", "args": {}}]
        future = asyncio.get_running_loop().create_future()
        menu = ApprovalMenu(action_requests[0])
        menu.set_future(future)

        # Patch the timeout to be tiny so the test doesn't actually wait 30s
        with patch("deepagents_code.app._DEFERRED_APPROVAL_TIMEOUT_SECONDS", 0.05):
            await app._deferred_show_approval(placeholder, menu, future)

        assert remove_called, "placeholder.remove() should have been called"
        assert mount_called, (
            "_mount_approval_widget should have been called after timeout"
        )


class TestOnChatInputTyping:
    """on_chat_input_typing should set _last_typed_at."""

    def test_sets_last_typed_at(self) -> None:
        """Calling on_chat_input_typing records a recent monotonic time."""
        app = DeepAgentsApp()
        assert app._last_typed_at is None

        event = MagicMock()
        before = time.monotonic()
        app.on_chat_input_typing(event)
        after = time.monotonic()

        assert app._last_typed_at is not None
        assert before <= app._last_typed_at <= after

    def test_updates_on_subsequent_calls(self) -> None:
        """Each call should update _last_typed_at to a newer timestamp."""
        app = DeepAgentsApp()
        event = MagicMock()

        app.on_chat_input_typing(event)
        first = app._last_typed_at

        app.on_chat_input_typing(event)
        second = app._last_typed_at

        assert second is not None
        assert first is not None
        assert second >= first


class TestOnApprovalMenuDecidedCleanup:
    """on_approval_menu_decided should defensively clean up placeholders."""

    async def test_removes_attached_placeholder(self) -> None:
        """An attached placeholder should be removed and nulled."""
        app = DeepAgentsApp(agent=MagicMock())

        placeholder = MagicMock(spec=Static)
        placeholder.is_attached = True
        remove_called = False

        async def fake_remove() -> None:  # noqa: RUF029
            nonlocal remove_called
            remove_called = True

        placeholder.remove = fake_remove
        app._approval_placeholder = placeholder
        app._pending_approval_widget = None

        event = MagicMock()
        app._chat_input = None
        await app.on_approval_menu_decided(event)

        assert remove_called
        assert app._approval_placeholder is None

    async def test_nulls_detached_placeholder(self) -> None:
        """A detached placeholder should be nulled without calling remove."""
        app = DeepAgentsApp(agent=MagicMock())

        placeholder = MagicMock(spec=Static)
        placeholder.is_attached = False
        app._approval_placeholder = placeholder
        app._pending_approval_widget = None

        event = MagicMock()
        app._chat_input = None
        await app.on_approval_menu_decided(event)

        assert app._approval_placeholder is None
        placeholder.remove.assert_not_called()

    async def test_no_placeholder_works_normally(self) -> None:
        """When no placeholder exists, handler proceeds without error."""
        app = DeepAgentsApp(agent=MagicMock())
        app._approval_placeholder = None
        app._pending_approval_widget = None

        event = MagicMock()
        app._chat_input = None
        await app.on_approval_menu_decided(event)

        assert app._approval_placeholder is None


class TestActionOpenEditor:
    """Tests for the external editor action."""

    async def test_updates_text_on_successful_edit(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        text_area = MagicMock()
        text_area.text = "original"
        chat_input = MagicMock()
        chat_input._text_area = text_area
        app._chat_input = chat_input

        with (
            patch.object(app, "suspend"),
            patch("deepagents_code.editor.open_in_editor", return_value="edited"),
        ):
            await app.action_open_editor()

        assert text_area.text == "edited"
        chat_input.focus_input.assert_called_once()

    async def test_no_update_when_editor_returns_none(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        text_area = MagicMock()
        text_area.text = "original"
        chat_input = MagicMock()
        chat_input._text_area = text_area
        app._chat_input = chat_input

        with (
            patch.object(app, "suspend"),
            patch("deepagents_code.editor.open_in_editor", return_value=None),
        ):
            await app.action_open_editor()

        assert text_area.text == "original"
        chat_input.focus_input.assert_called_once()

    async def test_early_return_when_chat_input_is_none(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        app._chat_input = None

        # Should not raise
        await app.action_open_editor()

    async def test_early_return_when_text_area_is_none(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        chat_input = MagicMock()
        chat_input._text_area = None
        app._chat_input = chat_input

        await app.action_open_editor()

    async def test_notifies_on_exception(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        text_area = MagicMock()
        text_area.text = ""
        chat_input = MagicMock()
        chat_input._text_area = text_area
        app._chat_input = chat_input

        with (
            patch.object(app, "suspend"),
            patch(
                "deepagents_code.editor.open_in_editor",
                side_effect=RuntimeError("boom"),
            ),
            patch.object(app, "notify") as mock_notify,
        ):
            await app.action_open_editor()

        mock_notify.assert_called_once()
        assert "failed" in mock_notify.call_args[0][0].lower()
        chat_input.focus_input.assert_called_once()


class TestEditorSlashCommand:
    """Test that /editor dispatches to action_open_editor."""

    async def test_editor_command_calls_action(self) -> None:
        app = DeepAgentsApp(agent=MagicMock())
        with patch.object(app, "action_open_editor", new_callable=AsyncMock) as mock:
            app._chat_input = MagicMock()
            await app._handle_command("/editor")
        mock.assert_awaited_once()


class TestFetchThreadHistoryData:
    """Verify _fetch_thread_history_data handles server-mode resume scenarios."""

    async def test_dict_messages_converted_to_message_objects(self) -> None:
        """Dict-based messages from server mode are deserialized before conversion."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        state = MagicMock()
        state.values = {
            "messages": [
                {"type": "human", "content": "hello", "id": "h1"},
                {
                    "type": "ai",
                    "content": "Hi there!",
                    "id": "a1",
                    "tool_calls": [],
                },
            ],
        }

        mock_agent = AsyncMock()
        mock_agent.aget_state.return_value = state

        app = DeepAgentsApp(agent=mock_agent, thread_id="t-1")
        payload = await app._fetch_thread_history_data("t-1")

        assert len(payload.messages) == 2
        assert isinstance(payload.messages[0], MessageData)
        assert payload.messages[0].type == MessageType.USER
        assert payload.messages[0].content == "hello"
        assert isinstance(payload.messages[1], MessageData)
        assert payload.messages[1].type == MessageType.ASSISTANT
        assert payload.messages[1].content == "Hi there!"

    async def test_server_mode_ensures_thread_before_fetching_state(self) -> None:
        """Server-mode history reads should fetch state through the remote server."""
        from langchain_core.messages import AIMessage, HumanMessage

        from deepagents_code.remote_client import RemoteAgent
        from deepagents_code.widgets.message_store import MessageType

        state = MagicMock()
        state.values = {
            "messages": [
                HumanMessage(content="hello", id="h1"),
                AIMessage(content="world", id="a1"),
            ]
        }

        mock_agent = MagicMock(spec=RemoteAgent)
        mock_agent.aensure_thread = AsyncMock()
        mock_agent.aget_state = AsyncMock(return_value=state)

        app = DeepAgentsApp(agent=mock_agent, thread_id="t-1")
        payload = await app._fetch_thread_history_data("t-1")

        mock_agent.aensure_thread.assert_awaited_once_with(
            {"configurable": {"thread_id": "t-1"}}
        )
        assert len(payload.messages) == 2
        assert payload.messages[0].type == MessageType.USER
        assert payload.messages[0].content == "hello"
        assert payload.messages[1].type == MessageType.ASSISTANT
        assert payload.messages[1].content == "world"

    async def test_server_mode_state_includes_context_tokens(self) -> None:
        """Server-mode history reads should preserve `_context_tokens` from state."""
        from langchain_core.messages import HumanMessage

        from deepagents_code.remote_client import RemoteAgent
        from deepagents_code.widgets.message_store import MessageType

        state = MagicMock()
        state.values = {
            "messages": [HumanMessage(content="hi", id="h1")],
            "_context_tokens": 5000,
        }

        mock_agent = MagicMock(spec=RemoteAgent)
        mock_agent.aensure_thread = AsyncMock()
        mock_agent.aget_state = AsyncMock(return_value=state)

        app = DeepAgentsApp(agent=mock_agent, thread_id="t-1")
        payload = await app._fetch_thread_history_data("t-1")

        mock_agent.aensure_thread.assert_awaited_once_with(
            {"configurable": {"thread_id": "t-1"}}
        )
        assert payload.context_tokens == 5000
        assert len(payload.messages) == 1
        assert payload.messages[0].type == MessageType.USER


class TestRemoteAgent:
    """Tests for DeepAgentsApp._remote_agent()."""

    def test_returns_instance_with_remote_agent(self) -> None:
        from deepagents_code.remote_client import RemoteAgent

        app = DeepAgentsApp()
        agent = RemoteAgent("http://test:0")
        app._agent = agent
        assert app._remote_agent() is agent

    def test_none_when_agent_is_none(self) -> None:
        app = DeepAgentsApp()
        assert app._remote_agent() is None

    def test_none_with_non_remote_agent(self) -> None:
        """Local Pregel-like agent returns None."""
        app = DeepAgentsApp()
        app._agent = MagicMock()
        assert app._remote_agent() is None

    def test_none_with_mock_spec_pregel(self) -> None:
        """MagicMock without RemoteAgent spec returns None."""
        app = DeepAgentsApp()
        app._agent = MagicMock(spec=[])
        assert app._remote_agent() is None


class TestTerminalBackgroundSync:
    """Tests for syncing Textual theme background to terminal background."""

    def test_initial_theme_sync_sets_terminal_background(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from deepagents_code import terminal_escape, theme

        calls: list[str] = []
        monkeypatch.setattr(
            terminal_escape,
            "set_terminal_background",
            lambda color: calls.append(color) or True,
        )

        app = DeepAgentsApp()
        # Force a known non-ANSI theme so the assertion is stable regardless
        # of the test runner's saved preference (which may be ansi-dark).
        app.theme = theme.DEFAULT_THEME
        app.sync_terminal_background()
        entry = theme.get_registry()[app.theme]

        assert calls[-1] == entry.colors.background

    def test_sync_terminal_background_uses_active_theme(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from deepagents_code import terminal_escape, theme

        calls: list[str] = []
        monkeypatch.setattr(
            terminal_escape,
            "set_terminal_background",
            lambda color: calls.append(color) or True,
        )
        app = DeepAgentsApp()
        calls.clear()

        app.theme = "langchain-light"
        app.sync_terminal_background()

        assert calls == [theme.LIGHT_COLORS.background]

    def test_sync_terminal_background_uses_custom_theme_entry(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from textual.theme import Theme as TextualTheme

        from deepagents_code import terminal_escape, theme

        app = DeepAgentsApp()
        calls: list[str] = []
        entry = theme.ThemeEntry(
            label="Custom Test",
            dark=True,
            colors=theme.DARK_COLORS,
            custom=True,
        )
        c = entry.colors
        app.register_theme(
            TextualTheme(
                name="custom-test",
                primary=c.primary,
                secondary=c.secondary,
                accent=c.accent,
                foreground=c.foreground,
                background=c.background,
                surface=c.surface,
                panel=c.panel,
                warning=c.warning,
                error=c.error,
                success=c.success,
                dark=entry.dark,
            )
        )
        monkeypatch.setattr(theme, "get_registry", lambda: {"custom-test": entry})
        monkeypatch.setattr(
            theme,
            "get_theme_colors",
            MagicMock(side_effect=AssertionError("custom themes use entry colors")),
        )
        monkeypatch.setattr(
            terminal_escape,
            "set_terminal_background",
            lambda color: calls.append(color) or True,
        )

        app.theme = "custom-test"
        app.sync_terminal_background()

        assert calls == [theme.DARK_COLORS.background]

    def test_sync_terminal_background_swallows_terminal_errors(
        self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
    ) -> None:
        from deepagents_code import terminal_escape

        app = DeepAgentsApp()
        app.theme = "langchain"  # force non-ANSI so the error path runs

        def _raise(_color: str) -> bool:
            msg = "terminal unavailable"
            raise RuntimeError(msg)

        monkeypatch.setattr(terminal_escape, "set_terminal_background", _raise)

        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            app.sync_terminal_background()

        assert "set_terminal_background raised unexpectedly" in caplog.text

    def test_sync_terminal_background_skips_ansi_dark(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from deepagents_code import terminal_escape

        calls: list[str] = []
        monkeypatch.setattr(
            terminal_escape,
            "set_terminal_background",
            lambda color: calls.append(color) or True,
        )

        app = DeepAgentsApp()
        calls.clear()
        app.theme = "ansi-dark"
        app.sync_terminal_background()

        assert calls == []

    def test_sync_terminal_background_skips_ansi_light(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from deepagents_code import terminal_escape

        calls: list[str] = []
        monkeypatch.setattr(
            terminal_escape,
            "set_terminal_background",
            lambda color: calls.append(color) or True,
        )

        app = DeepAgentsApp()
        calls.clear()
        app.theme = "ansi-light"
        app.sync_terminal_background()

        assert calls == []

    def test_exit_resets_terminal_background(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from deepagents_code import terminal_escape

        calls: list[bool] = []
        monkeypatch.setattr(
            terminal_escape,
            "reset_terminal_background",
            lambda: calls.append(True) or True,
        )
        app = DeepAgentsApp()

        with patch.object(App, "exit") as app_exit:
            app.exit()

        assert calls == [True]
        app_exit.assert_called_once()


class TestSlashCommandBypass:
    """Test that certain slash commands bypass the queue gate."""

    async def test_quit_bypasses_queue_when_agent_running(self) -> None:
        """/quit should exit immediately even when agent is running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "exit") as exit_mock:
                app.post_message(ChatInput.Submitted("/quit", "command"))
                await pilot.pause()

            exit_mock.assert_called_once()
            assert len(app._pending_messages) == 0

    async def test_quit_bypasses_queue_when_connecting(self) -> None:
        """/quit should exit immediately even when connecting."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            with patch.object(app, "exit") as exit_mock:
                app.post_message(ChatInput.Submitted("/quit", "command"))
                await pilot.pause()

            exit_mock.assert_called_once()
            assert len(app._pending_messages) == 0

    async def test_quit_bypasses_thread_switching(self) -> None:
        """/quit should exit even during a thread switch."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._thread_switching = True

            with patch.object(app, "exit") as exit_mock:
                app.post_message(ChatInput.Submitted("/quit", "command"))
                await pilot.pause()

            exit_mock.assert_called_once()

    async def test_q_alias_bypasses_queue(self) -> None:
        """/q alias should also bypass the queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "exit") as exit_mock:
                app.post_message(ChatInput.Submitted("/q", "command"))
                await pilot.pause()

            exit_mock.assert_called_once()
            assert len(app._pending_messages) == 0

    async def test_force_clear_bypasses_queue_when_agent_running(self) -> None:
        """/force-clear should process immediately when agent is running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/force-clear", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/force-clear", "command")
            assert len(app._pending_messages) == 0

    async def test_restart_bypasses_queue_when_agent_running(self) -> None:
        """/restart should process immediately when the agent is running.

        Guards the motivating behavior: as an `ALWAYS_IMMEDIATE` command,
        `/restart` must reach `_process_message` rather than being parked in
        the queue behind the in-flight work it is meant to recover from.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/restart", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/restart", "command")
            assert len(app._pending_messages) == 0

    async def test_external_command_uses_same_bypass_policy(self) -> None:
        """External command events should route through normal command policy."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(
                    ExternalInput(
                        ExternalEvent(
                            kind="command",
                            payload="/force-clear",
                            source="test",
                        )
                    )
                )
                await pilot.pause()

            pm.assert_called_once_with("/force-clear", "command")
            assert len(app._pending_messages) == 0

    async def test_external_prompt_queues_when_agent_running(self) -> None:
        """External prompt events should queue while the agent is busy."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(
                ExternalInput(
                    ExternalEvent(kind="prompt", payload="next task", source="test")
                )
            )
            await pilot.pause()

            assert list(app._pending_messages) == [
                QueuedMessage(text="next task", mode="normal")
            ]

    async def test_version_executes_during_connecting(self) -> None:
        """/version should process immediately when only connecting."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/version", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/version", "command")
            assert len(app._pending_messages) == 0

    async def test_about_alias_executes_during_connecting(self) -> None:
        """/about should process like the hidden alias for /version."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/about", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/about", "command")
            assert len(app._pending_messages) == 0

    async def test_version_queues_during_agent_running(self) -> None:
        """/version should still queue when agent is actively running."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("/version", "command"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "/version"

    async def test_model_no_args_opens_selector_during_agent_running(self) -> None:
        """/model (no args) should process immediately during agent run."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/model", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/model", "command")
            assert len(app._pending_messages) == 0

    async def test_model_no_args_opens_selector_during_connecting(self) -> None:
        """/model (no args) should process immediately during connecting."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/model", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/model", "command")

    async def test_model_with_args_still_queues(self) -> None:
        """/model <name> (with args) should still queue normally."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("/model gpt-4", "command"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "/model gpt-4"

    async def test_threads_opens_selector_during_agent_running(self) -> None:
        """/threads should process immediately during agent run."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/threads", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/threads", "command")
            assert len(app._pending_messages) == 0

    async def test_threads_opens_selector_during_connecting(self) -> None:
        """/threads should process immediately during connecting."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/threads", "command"))
                await pilot.pause()

            pm.assert_called_once_with("/threads", "command")

    async def test_threads_blocked_during_thread_switching(self) -> None:
        """/threads should NOT bypass the thread-switching guard."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._thread_switching = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/threads", "command"))
                await pilot.pause()

            pm.assert_not_called()
            assert len(app._pending_messages) == 0

    async def test_model_blocked_during_thread_switching(self) -> None:
        """/model should NOT bypass the thread-switching guard."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._thread_switching = True

            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(ChatInput.Submitted("/model", "command"))
                await pilot.pause()

            pm.assert_not_called()
            assert len(app._pending_messages) == 0


class TestBypassFrozensetDrift:
    """Ensure bypass frozensets stay in sync with _handle_command dispatch.

    Every slash command must appear in exactly one of the five policy
    frozensets (derived from `command_registry.COMMANDS`) AND in
    `_handle_command`. Adding a command to one without the other will fail
    these tests.
    """

    # Dynamic namespace prefixes handled via startswith() rather than
    # static command dispatch.  These are not registered in COMMANDS and
    # should be excluded from the drift check.
    _DYNAMIC_PREFIXES = frozenset({"/skill:"})

    @classmethod
    def _handled_commands(cls) -> set[str]:
        """Extract slash-command literals from `_handle_command` source."""
        import ast
        import inspect
        import textwrap

        source = textwrap.dedent(inspect.getsource(DeepAgentsApp._handle_command))
        tree = ast.parse(source)

        handled: set[str] = set()
        for node in ast.walk(tree):
            if isinstance(node, ast.Constant) and isinstance(node.value, str):
                val = node.value.strip()
                if val.startswith("/") and len(val) > 1:
                    handled.add(val)
        # Exclude dynamic namespace prefixes (e.g. /skill:*) and their
        # derivatives (e.g. /skill:<name> from help text).
        return {
            cmd
            for cmd in handled
            if not any(cmd.startswith(p) for p in cls._DYNAMIC_PREFIXES)
        }

    def test_all_bypass_commands_are_handled(self) -> None:
        """Every command in a bypass frozenset must appear in _handle_command."""
        from deepagents_code.command_registry import (
            ALWAYS_IMMEDIATE,
            BYPASS_WHEN_CONNECTING,
            IMMEDIATE_UI,
            SIDE_EFFECT_FREE,
        )

        handled = self._handled_commands()
        bypass = (
            ALWAYS_IMMEDIATE | BYPASS_WHEN_CONNECTING | IMMEDIATE_UI | SIDE_EFFECT_FREE
        )
        missing = bypass - handled
        assert not missing, (
            f"Bypass commands {missing} are not handled in _handle_command. "
            "Add a handler or remove from the bypass frozenset."
        )

    def test_all_handled_commands_are_classified(self) -> None:
        """Every command in _handle_command must be in a policy frozenset."""
        from deepagents_code.command_registry import ALL_CLASSIFIED

        handled = self._handled_commands()
        missing = handled - ALL_CLASSIFIED
        assert not missing, (
            f"Commands {missing} in _handle_command are not in any bypass "
            "or QUEUE_BOUND frozenset. Classify them explicitly."
        )


class TestDefaultAgentNameDrift:
    """Pin the canonical agent default and its public re-exports together.

    `_constants.DEFAULT_AGENT_NAME` is the single source of truth. This test
    asserts that every consumer (`agent.DEFAULT_AGENT_NAME`,
    `_server_config.DEFAULT_ASSISTANT_ID`, `app.DEFAULT_ASSISTANT_ID`)
    resolves back to it — guarding against a future refactor that
    re-introduces a hardcoded `"agent"` literal.
    """

    def test_all_default_agent_constants_match(self) -> None:
        """All consumers of the default identifier must point at `_constants`."""
        from deepagents_code import _constants, _server_config, agent, app

        canonical = _constants.DEFAULT_AGENT_NAME
        assert canonical == "agent"
        assert agent.DEFAULT_AGENT_NAME is canonical
        assert _server_config.DEFAULT_ASSISTANT_ID is canonical
        assert app.DEFAULT_ASSISTANT_ID is canonical


class TestInstallExtraAuthContinuation:
    """Test `/auth` reopening after installing provider extras."""

    async def test_reopens_auth_after_installed_extra_even_when_restart_fails(
        self,
    ) -> None:
        """`/auth` only needs the install to land before reopening the manager."""
        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=False)  # ty: ignore
        app._show_auth_manager = AsyncMock()  # ty: ignore

        with (
            patch("deepagents_code.app._extra_is_ready", return_value=True),
            patch("deepagents_code.model_config.clear_caches") as clear_caches,
        ):
            await app._install_provider_then_reopen_auth("baseten")

        app._install_extra.assert_awaited_once_with("baseten", auto_restart=True)  # ty: ignore
        clear_caches.assert_called_once_with()
        app._show_auth_manager.assert_awaited_once()  # ty: ignore

    async def test_does_not_reopen_auth_when_install_failed(self) -> None:
        """A failed install still leaves the user in chat with the surfaced error."""
        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=False)  # ty: ignore
        app._show_auth_manager = AsyncMock()  # ty: ignore

        with patch("deepagents_code.app._extra_is_ready", return_value=False):
            await app._install_provider_then_reopen_auth("baseten")

        app._install_extra.assert_awaited_once_with("baseten", auto_restart=True)  # ty: ignore
        app._show_auth_manager.assert_not_awaited()  # ty: ignore

    async def test_surfaces_hint_when_install_state_unverifiable(self) -> None:
        """An unknown post-install state points the user back to `/auth`.

        When the extra can't be introspected (`_extra_is_ready` returns `None`)
        the manager must not reopen, but the flow must not dead-end silently
        either — a message tells the user how to finish.
        """
        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=False)  # ty: ignore
        app._show_auth_manager = AsyncMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        with patch("deepagents_code.app._extra_is_ready", return_value=None):
            await app._install_provider_then_reopen_auth("baseten")

        app._show_auth_manager.assert_not_awaited()  # ty: ignore
        app._mount_message.assert_awaited_once()  # ty: ignore
        message = app._mount_message.await_args.args[0]  # ty: ignore
        assert "baseten" in message._content


class TestExtraIsReady:
    """Tests for the `_extra_is_ready` install-state probe."""

    def test_returns_true_when_extra_packages_present(self) -> None:
        """A fully installed extra reports ready."""
        from deepagents_code.extras_info import ExtraDependencyStatus

        status = ExtraDependencyStatus(
            name="groq", installed=(("langchain-groq", "1.0.0"),), missing=()
        )
        with patch(
            "deepagents_code.extras_info.get_optional_dependency_status",
            return_value=[status],
        ):
            assert _extra_is_ready("groq") is True

    def test_returns_false_when_extra_packages_missing(self) -> None:
        """An extra with missing packages reports not ready."""
        from deepagents_code.extras_info import ExtraDependencyStatus

        status = ExtraDependencyStatus(
            name="groq", installed=(), missing=("langchain-groq",)
        )
        with patch(
            "deepagents_code.extras_info.get_optional_dependency_status",
            return_value=[status],
        ):
            assert _extra_is_ready("groq") is False

    def test_returns_none_when_introspection_fails(self) -> None:
        """Unparseable extra metadata is an unknown state, not a negative one."""
        from deepagents_code.extras_info import ExtrasIntrospectionError

        with patch(
            "deepagents_code.extras_info.get_optional_dependency_status",
            side_effect=ExtrasIntrospectionError("broken metadata"),
        ):
            assert _extra_is_ready("groq") is None


class TestInstallExtraModelSwitch:
    """Test switching after installing model-provider extras."""

    async def test_install_extra_prompts_for_missing_auth_before_switch(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Freshly installed providers should use the normal auth prompt flow."""
        from deepagents_code.model_config import ProviderAuthState, ProviderAuthStatus
        from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult

        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=True)  # ty: ignore
        app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED)  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore
        monkeypatch.setattr(
            "deepagents_code.model_config.get_provider_auth_status",
            lambda provider: ProviderAuthStatus(
                state=ProviderAuthState.MISSING,
                provider=provider,
                env_var="BASETEN_API_KEY",
            ),
        )

        await app._install_extra_then_switch(
            "baseten",
            "baseten:moonshotai/Kimi-K2.6",
            extra_kwargs={"temperature": 0},
        )

        app._install_extra.assert_awaited_once_with("baseten", auto_restart=True)  # ty: ignore
        app._push_screen_wait.assert_awaited_once()  # ty: ignore
        screen = app._push_screen_wait.await_args.args[0]  # ty: ignore
        assert isinstance(screen, AuthPromptScreen)
        dispatch.assert_called_once_with(
            "baseten:moonshotai/Kimi-K2.6",
            extra_kwargs={"temperature": 0},
        )

    async def test_install_extra_failed_restart_does_not_prompt_auth_or_switch(
        self,
    ) -> None:
        """A model-selection install only continues after the server restarts."""
        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=False)  # ty: ignore
        app._push_screen_wait = AsyncMock()  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore

        await app._install_extra_then_switch(
            "baseten",
            "baseten:moonshotai/Kimi-K2.6",
        )

        app._install_extra.assert_awaited_once_with("baseten", auto_restart=True)  # ty: ignore
        app._push_screen_wait.assert_not_awaited()  # ty: ignore
        dispatch.assert_not_called()

    async def test_install_extra_cancelled_auth_does_not_switch(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Cancelling the post-install auth prompt leaves the model unchanged."""
        from deepagents_code.model_config import ProviderAuthState, ProviderAuthStatus
        from deepagents_code.widgets.auth import AuthResult

        app = DeepAgentsApp()
        app._install_extra = AsyncMock(return_value=True)  # ty: ignore
        app._push_screen_wait = AsyncMock(return_value=AuthResult.CANCELLED)  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore
        monkeypatch.setattr(
            "deepagents_code.model_config.get_provider_auth_status",
            lambda provider: ProviderAuthStatus(
                state=ProviderAuthState.MISSING,
                provider=provider,
                env_var="BASETEN_API_KEY",
            ),
        )

        await app._install_extra_then_switch(
            "baseten",
            "baseten:moonshotai/Kimi-K2.6",
        )

        app._push_screen_wait.assert_awaited_once()  # ty: ignore
        dispatch.assert_not_called()
        # The switch is abandoned, but the message confirms the extra stayed
        # installed and points the user at how to switch later — not a silent
        # no-op after the install + restart.
        mounted = " ".join(
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        )
        assert "Installed" in mounted
        assert "/auth" in mounted
        assert "/model" in mounted

    async def test_install_extra_auto_restart_skips_restart_for_deferred_startup(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Deferred startup loads installed provider extras on its first launch."""
        from deepagents_code import config as config_mod, update_check

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: False)
        monkeypatch.setattr(
            update_check, "create_update_log_path", lambda: tmp_path / "install.log"
        )
        monkeypatch.setattr(
            update_check, "install_extra_command", lambda extra: f"uv install {extra}"
        )
        monkeypatch.setattr(
            update_check,
            "perform_install_extra",
            AsyncMock(return_value=(True, "")),
        )

        app = DeepAgentsApp()
        app._ensure_restart_prompt_loaded = MagicMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._reload_configuration_for_restart = AsyncMock(return_value=True)  # ty: ignore
        app._restart_after_install = AsyncMock(return_value=False)  # ty: ignore
        app._server_proc = None
        app._server_kwargs = {"model_name": "openai:gpt-5.5"}
        app._server_startup_deferred = True

        result = await app._install_extra("baseten", auto_restart=True)

        assert result is True
        app._reload_configuration_for_restart.assert_awaited_once()  # ty: ignore
        app._restart_after_install.assert_not_awaited()  # ty: ignore
        mounted = [
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        ]
        assert not any("couldn't restart" in text.lower() for text in mounted)

    async def test_install_extra_auto_restart_no_owned_server_recommends_relaunch(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A remote server cannot be loaded with `/restart` after install."""
        from deepagents_code import config as config_mod, update_check

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: False)
        monkeypatch.setattr(
            update_check, "create_update_log_path", lambda: tmp_path / "install.log"
        )
        monkeypatch.setattr(
            update_check, "install_extra_command", lambda extra: f"uv install {extra}"
        )
        monkeypatch.setattr(
            update_check,
            "perform_install_extra",
            AsyncMock(return_value=(True, "")),
        )

        app = DeepAgentsApp()
        app._ensure_restart_prompt_loaded = MagicMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._restart_after_install = AsyncMock(return_value=False)  # ty: ignore
        app._server_proc = None
        app._server_kwargs = None

        result = await app._install_extra("baseten", auto_restart=True)

        assert result is False
        app._restart_after_install.assert_awaited_once_with("baseten")  # ty: ignore
        mounted = " ".join(
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        )
        assert "Relaunch dcode" in mounted
        assert "/restart" not in mounted

    async def test_install_extra_auto_restart_fallback_on_failed_restart(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A successful install that cannot auto-restart guides the user."""
        from deepagents_code import config as config_mod, update_check

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: False)
        monkeypatch.setattr(
            update_check, "create_update_log_path", lambda: tmp_path / "install.log"
        )
        monkeypatch.setattr(
            update_check, "install_extra_command", lambda extra: f"uv install {extra}"
        )
        monkeypatch.setattr(
            update_check,
            "perform_install_extra",
            AsyncMock(return_value=(True, "")),
        )

        app = DeepAgentsApp()
        app._ensure_restart_prompt_loaded = MagicMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._restart_after_install = AsyncMock(return_value=False)  # ty: ignore
        app._server_kwargs = {"model_name": "openai:gpt-5.5"}

        result = await app._install_extra("baseten", auto_restart=True)

        assert result is False
        app._restart_after_install.assert_awaited_once_with("baseten")  # ty: ignore
        mounted = [
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        ]
        assert any("couldn't restart the server" in text.lower() for text in mounted)

    async def test_install_extra_auto_restart_success_returns_true(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A successful install + auto-restart returns True with no fallback copy."""
        from deepagents_code import config as config_mod, update_check

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: False)
        monkeypatch.setattr(
            update_check, "create_update_log_path", lambda: tmp_path / "install.log"
        )
        monkeypatch.setattr(
            update_check, "install_extra_command", lambda extra: f"uv install {extra}"
        )
        monkeypatch.setattr(
            update_check,
            "perform_install_extra",
            AsyncMock(return_value=(True, "")),
        )

        app = DeepAgentsApp()
        app._ensure_restart_prompt_loaded = MagicMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore
        app._restart_after_install = AsyncMock(return_value=True)  # ty: ignore

        result = await app._install_extra("baseten", auto_restart=True)

        assert result is True
        app._restart_after_install.assert_awaited_once_with("baseten")  # ty: ignore
        mounted = [
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        ]
        assert not any("couldn't restart" in text.lower() for text in mounted)

    async def test_prompt_model_auth_not_needed_when_credentials_present(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A non-blocking provider switches without showing the auth prompt."""
        from deepagents_code.model_config import ProviderAuthState, ProviderAuthStatus

        app = DeepAgentsApp()
        app._push_screen_wait = AsyncMock()  # ty: ignore
        monkeypatch.setattr(
            "deepagents_code.model_config.get_provider_auth_status",
            lambda provider: ProviderAuthStatus(
                state=ProviderAuthState.NOT_REQUIRED,
                provider=provider,
                detail="local provider",
            ),
        )

        result = await app._prompt_model_auth_if_needed("ollama:llama3")

        assert result is True
        app._push_screen_wait.assert_not_awaited()  # ty: ignore

    async def test_prompt_model_auth_skips_when_provider_unresolved(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """An unresolvable provider proceeds without prompting for credentials."""
        from deepagents_code import config as config_mod
        from deepagents_code.model_config import ModelSpec

        app = DeepAgentsApp()
        app._push_screen_wait = AsyncMock()  # ty: ignore
        monkeypatch.setattr(ModelSpec, "try_parse", staticmethod(lambda _spec: None))
        monkeypatch.setattr(config_mod, "detect_provider", lambda _spec: None)

        result = await app._prompt_model_auth_if_needed("not-a-real-spec")

        assert result is True
        app._push_screen_wait.assert_not_awaited()  # ty: ignore

    async def test_install_extra_editable_install_returns_false(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """An editable install can't be upgraded, so `_install_extra` returns False."""
        from deepagents_code import config as config_mod

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: True)

        app = DeepAgentsApp()
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._install_extra("baseten") is False

    async def test_install_extra_failed_resolution_returns_false(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A failed `uv` resolution surfaces an error and returns False.

        `_install_extra_then_switch` gates the model switch on this `bool`, so
        the contract is locked here rather than only through mocked callers.
        """
        from deepagents_code import config as config_mod, update_check

        monkeypatch.setattr(config_mod, "_is_editable_install", lambda: False)
        monkeypatch.setattr(
            update_check, "create_update_log_path", lambda: tmp_path / "install.log"
        )
        monkeypatch.setattr(
            update_check, "install_extra_command", lambda extra: f"uv install {extra}"
        )
        monkeypatch.setattr(
            update_check,
            "perform_install_extra",
            AsyncMock(return_value=(False, "resolver: conflict")),
        )

        app = DeepAgentsApp()
        app._ensure_restart_prompt_loaded = MagicMock()  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._install_extra("baseten", auto_restart=True) is False
        mounted = " ".join(
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        )
        assert "Install failed" in mounted


class TestHandleModelSelection:
    """Tests for the model-selector result router."""

    async def test_install_extra_then_switch_when_pending_extra(self) -> None:
        """A confirmed install routes through install-then-switch."""
        app = DeepAgentsApp()
        app.call_later = MagicMock()  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore
        screen = SimpleNamespace(pending_install_extra="baseten")

        app._handle_model_selection(
            screen,  # ty: ignore
            ("baseten:moonshotai/Kimi-K2.6", "baseten"),
            extra_kwargs={"temperature": 0},
        )

        dispatch.assert_not_called()
        app.call_later.assert_called_once()  # ty: ignore
        scheduled = app.call_later.call_args.args[0]  # ty: ignore
        # Bound methods compare equal (same instance + function) but each
        # attribute access yields a fresh object, so `==`, not `is`.
        assert scheduled.func == app._install_extra_then_switch
        assert scheduled.args == ("baseten", "baseten:moonshotai/Kimi-K2.6")
        assert scheduled.keywords == {"extra_kwargs": {"temperature": 0}}

    async def test_plain_switch_when_no_pending_extra(self) -> None:
        """No pending extra dispatches a normal model switch."""
        app = DeepAgentsApp()
        app.call_later = MagicMock()  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore
        screen = SimpleNamespace(pending_install_extra=None)

        app._handle_model_selection(screen, ("openai:gpt-5.5", "openai"))  # ty: ignore

        app.call_later.assert_not_called()  # ty: ignore
        dispatch.assert_called_once_with("openai:gpt-5.5", extra_kwargs=None)

    async def test_cancelled_selection_is_noop(self) -> None:
        """A `None` result neither switches nor installs."""
        app = DeepAgentsApp()
        app.call_later = MagicMock()  # ty: ignore
        dispatch = MagicMock()
        app._dispatch_model_switch = dispatch  # ty: ignore
        screen = SimpleNamespace(pending_install_extra="baseten")

        app._handle_model_selection(screen, None)  # ty: ignore

        app.call_later.assert_not_called()  # ty: ignore
        dispatch.assert_not_called()


class TestRestartAfterInstall:
    """Tests for the shared post-install server restart helper."""

    async def test_no_owned_server_returns_false_silently(self) -> None:
        """Remote/not-yet-started servers cannot auto-restart."""
        app = DeepAgentsApp()
        app._server_proc = None
        app._server_kwargs = None
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._restart_after_install("baseten") is False
        app._mount_message.assert_not_awaited()  # ty: ignore

    async def test_busy_server_returns_false(self) -> None:
        """An in-flight agent run blocks auto-restart."""
        app = DeepAgentsApp()
        app._server_proc = object()
        app._server_kwargs = {}
        app._agent_running = True
        app._connecting = False
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._restart_after_install("baseten") is False
        app._mount_message.assert_not_awaited()  # ty: ignore

    async def test_successful_restart_returns_true_and_reports(self) -> None:
        """A reload + respawn that both succeed returns True and confirms."""
        app = DeepAgentsApp()
        app._server_proc = object()
        app._server_kwargs = {}
        app._agent_running = False
        app._connecting = False
        app._reload_configuration_for_restart = AsyncMock(return_value=True)  # ty: ignore
        app._restart_server_manual = AsyncMock(return_value=True)  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._restart_after_install("baseten") is True
        mounted = [
            str(c.args[0]._content)
            for c in app._mount_message.await_args_list  # ty: ignore
        ]
        assert any("Restart complete." in text for text in mounted)

    async def test_failed_respawn_returns_false(self) -> None:
        """A respawn failure returns False so callers don't switch into it."""
        app = DeepAgentsApp()
        app._server_proc = object()
        app._server_kwargs = {}
        app._agent_running = False
        app._connecting = False
        app._reload_configuration_for_restart = AsyncMock(return_value=True)  # ty: ignore
        app._restart_server_manual = AsyncMock(return_value=False)  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        assert await app._restart_after_install("baseten") is False

    @pytest.mark.parametrize(
        ("proc", "kwargs", "deferred", "error", "expected"),
        [
            # No server yet + a pending spawn: a fresh startup loads the extra.
            (None, {"model_name": "x"}, True, None, True),
            (None, {"model_name": "x"}, False, "startup failed", True),
            # No pending spawn to ride on.
            (None, {"model_name": "x"}, False, None, False),
            # A live server exists, so a real restart is needed (not "unneeded").
            (object(), {"model_name": "x"}, True, None, False),
            # Remote server (no kwargs): nothing local to spawn.
            (None, None, True, None, False),
        ],
    )
    def test_restart_after_install_is_unneeded(
        self,
        proc: object | None,
        kwargs: dict[str, str] | None,
        deferred: bool,
        error: str | None,
        expected: bool,
    ) -> None:
        """The predicate is true only when a pending fresh startup will load it."""
        app = DeepAgentsApp()
        app._server_proc = proc  # ty: ignore
        app._server_kwargs = kwargs  # ty: ignore
        app._server_startup_deferred = deferred
        app._server_startup_error = error

        assert app._restart_after_install_is_unneeded() is expected


class TestDispatchModelSwitch:
    """Tests for the defer-vs-immediate model switch dispatcher."""

    @pytest.mark.parametrize(
        ("flag", "should_notify"),
        [
            ("_agent_running", True),
            ("_shell_running", True),
            # A bare reconnect (e.g. the transient restart during
            # install-then-switch) defers but stays silent — the toast would be
            # misleading since the switch drains automatically once ready.
            ("_connecting", False),
        ],
    )
    async def test_defers_for_each_busy_flag(
        self, flag: str, should_notify: bool
    ) -> None:
        """Each busy signal defers the switch; only real work toasts."""
        app = DeepAgentsApp()
        app._agent_running = False
        app._shell_running = False
        app._connecting = False
        setattr(app, flag, True)
        app._defer_action = MagicMock()  # ty: ignore
        app.call_later = MagicMock()  # ty: ignore
        app.notify = MagicMock()  # ty: ignore

        app._dispatch_model_switch("openai:gpt-5.5")

        app._defer_action.assert_called_once()  # ty: ignore
        app.call_later.assert_not_called()  # ty: ignore
        assert app.notify.call_count == (1 if should_notify else 0)  # ty: ignore

    async def test_switches_immediately_when_idle(self) -> None:
        """An idle app schedules the switch directly."""
        app = DeepAgentsApp()
        app._agent_running = False
        app._shell_running = False
        app._connecting = False
        app._defer_action = MagicMock()  # ty: ignore
        app.call_later = MagicMock()  # ty: ignore

        app._dispatch_model_switch("openai:gpt-5.5")

        app._defer_action.assert_not_called()  # ty: ignore
        app.call_later.assert_called_once()  # ty: ignore

    async def test_defers_switch_while_busy(self) -> None:
        """A busy app queues the switch and notifies the user."""
        app = DeepAgentsApp()
        app._agent_running = True
        app._shell_running = False
        app._connecting = False
        app._defer_action = MagicMock()  # ty: ignore
        app.call_later = MagicMock()  # ty: ignore
        app.notify = MagicMock()  # ty: ignore

        app._dispatch_model_switch("openai:gpt-5.5")

        app._defer_action.assert_called_once()  # ty: ignore
        app.notify.assert_called_once()  # ty: ignore
        app.call_later.assert_not_called()  # ty: ignore

    async def test_toasts_when_busy_and_connecting(self) -> None:
        """In-flight work toasts even while also reconnecting.

        Guards against collapsing the toast guard into `not self._connecting`:
        a reconnect overlapping genuine work (the install-then-switch restart
        landing mid-task) must still notify the user.
        """
        app = DeepAgentsApp()
        app._agent_running = True
        app._shell_running = False
        app._connecting = True
        app._defer_action = MagicMock()  # ty: ignore
        app.call_later = MagicMock()  # ty: ignore
        app.notify = MagicMock()  # ty: ignore

        app._dispatch_model_switch("openai:gpt-5.5")

        app._defer_action.assert_called_once()  # ty: ignore
        app.notify.assert_called_once()  # ty: ignore
        app.call_later.assert_not_called()  # ty: ignore

    async def test_defers_silently_while_only_connecting(self) -> None:
        """A reconnect-only defer queues the switch without a toast."""
        app = DeepAgentsApp()
        app._agent_running = False
        app._shell_running = False
        app._connecting = True
        app._defer_action = MagicMock()  # ty: ignore
        app.call_later = MagicMock()  # ty: ignore
        app.notify = MagicMock()  # ty: ignore

        app._dispatch_model_switch("openai:gpt-5.5")

        app._defer_action.assert_called_once()  # ty: ignore
        app.notify.assert_not_called()  # ty: ignore
        app.call_later.assert_not_called()  # ty: ignore


class TestDeferredActions:
    """Test deferred action queueing and draining."""

    async def test_deferred_actions_drain_after_agent_cleanup(self) -> None:
        """Deferred actions should execute when agent task completes."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def action() -> None:  # noqa: RUF029
                executed.append("ran")

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=action)
            )
            app._agent_running = True

            # Simulate agent finishing
            await app._cleanup_agent_task()

            assert executed == ["ran"]
            assert len(app._deferred_actions) == 0

    async def test_deferred_actions_drain_after_shell_cleanup(self) -> None:
        """Deferred actions should execute when shell task completes."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def action() -> None:  # noqa: RUF029
                executed.append("ran")

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=action)
            )
            app._shell_running = True

            await app._cleanup_shell_task()

            assert executed == ["ran"]
            assert len(app._deferred_actions) == 0

    async def test_deferred_actions_not_drained_while_connecting(self) -> None:
        """Deferred actions should NOT drain if still connecting."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def action() -> None:  # noqa: RUF029
                executed.append("ran")

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=action)
            )
            app._agent_running = True
            app._connecting = True

            await app._cleanup_agent_task()

            assert executed == []
            assert len(app._deferred_actions) == 1

    async def test_deferred_actions_cleared_on_interrupt(self) -> None:
        """Deferred actions should be cleared when queue is discarded."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            async def action() -> None:
                pass

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=action)
            )
            app._discard_queue()

            assert len(app._deferred_actions) == 0

    async def test_deferred_actions_cleared_on_server_failure(self) -> None:
        """Deferred actions should be cleared when server startup fails."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            async def action() -> None:
                pass

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=action)
            )
            app._connecting = True

            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError("test"))
            )

            assert len(app._deferred_actions) == 0

    async def test_server_failure_stores_error(self) -> None:
        """Server startup error should be stored for _send_to_agent fallback."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True

            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError("exit code 3"))
            )

            assert app._server_startup_error == "RuntimeError: exit code 3"
            assert app._connecting is False

    async def test_server_failure_trims_multiline_error_to_headline(self) -> None:
        """Multi-line errors (e.g. `wait_for_server_healthy`'s log tail) are trimmed.

        Guards against regressing the `_format_startup_error` behaviour that
        keeps the banner readable when the server subprocess embeds thousands
        of log chars in its `RuntimeError` message.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            log_dump = "line " * 1000
            message = f"Server process exited with code 3\n{log_dump}"

            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError(message))
            )

            stored = app._server_startup_error
            assert stored is not None
            assert "\n" not in stored
            assert "Server process exited with code 3" in stored
            assert len(stored) < 400

    async def test_server_failure_truncated_headline_notes_debug_rerun(self) -> None:
        """A clipped headline appends a re-run hint when no debug log is active.

        The full error (e.g. the `interpreter_ptc` available-tools list) only
        reaches disk when a debug file handler is installed; absent one the note
        tells the user how to capture it on the next run.
        """
        import logging

        from deepagents_code.widgets.messages import ErrorMessage

        # Strip any installed debug handler so the helper reports "no log file"
        # deterministically — a developer may run the suite with
        # `DEEPAGENTS_CODE_DEBUG` exported, which installs one at import.
        package_logger = logging.getLogger("deepagents_code")
        stripped = [
            h
            for h in package_logger.handlers
            if getattr(h, "_deepagents_code_debug_handler", False)
        ]
        for h in stripped:
            package_logger.removeHandler(h)
        try:
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()
                # Single-line message long enough to overflow the headline limit.
                long_message = "Unknown tool names: " + ", ".join(
                    f"tool_{i}" for i in range(80)
                )
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=RuntimeError(long_message))
                )
                await pilot.pause()

                widget = app._startup_failure_widget
                assert isinstance(widget, ErrorMessage)
                rendered = str(widget._content)
                assert "error truncated" in rendered
                assert "DEEPAGENTS_CODE_DEBUG=1" in rendered
                # The note stays on the displayed message, not in stored state.
                assert app._server_startup_error is not None
                assert "error truncated" not in app._server_startup_error
        finally:
            for h in stripped:
                package_logger.addHandler(h)

    async def test_server_failure_truncated_headline_points_to_debug_file(
        self, tmp_path
    ) -> None:
        """The note points at the path of the handler that was actually installed.

        The pointer reflects the file handler attached by
        `configure_debug_logging`, not the `DEEPAGENTS_CODE_DEBUG` env value: a
        var set only after import (e.g. via a project `.env`) never installs a
        handler, so the note must never advertise a file that was never created.
        """
        import logging

        from deepagents_code._debug import configure_debug_logging
        from deepagents_code._env_vars import DEBUG, DEBUG_FILE
        from deepagents_code.widgets.messages import ErrorMessage

        package_logger = logging.getLogger("deepagents_code")
        log_path = tmp_path / "custom_debug.log"
        pre_existing = list(package_logger.handlers)
        with patch.dict(
            os.environ,
            {DEBUG: "1", DEBUG_FILE: str(log_path)},
            clear=False,
        ):
            configure_debug_logging(package_logger)
        added = [h for h in package_logger.handlers if h not in pre_existing]
        try:
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()
                long_message = "Unknown tool names: " + ", ".join(
                    f"tool_{i}" for i in range(80)
                )
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=RuntimeError(long_message))
                )
                await pilot.pause()

                widget = app._startup_failure_widget
                assert isinstance(widget, ErrorMessage)
                rendered = str(widget._content)
                assert "error truncated" in rendered
                assert str(log_path) in rendered
        finally:
            for h in added:
                h.close()
                package_logger.removeHandler(h)

    async def test_server_failure_short_headline_omits_truncation_note(self) -> None:
        """A headline within the limit renders no truncation note."""
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError("short boom"))
            )
            await pilot.pause()

            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            assert "error truncated" not in str(widget._content)

    async def test_server_failure_long_mcp_config_error_omits_truncation_note(
        self,
    ) -> None:
        """A long `MCPConfigError` never gets a truncation note.

        That branch sets `_server_startup_error` to the full (untruncated)
        message and leaves `headline_truncated` False, so the note logic must
        not fire even when the rendered text far exceeds the headline limit.
        Locks the branch against a refactor that hoists the truncation check.
        """
        from deepagents_code.mcp_tools import MCPConfigError
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            long_message = "Invalid MCP config at /tmp/x.json: " + "x" * 400
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=MCPConfigError(long_message))
            )
            await pilot.pause()

            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            assert "error truncated" not in str(widget._content)

    async def test_server_failure_truncation_note_boundary(self) -> None:
        """The note fires only when the headline strictly exceeds the limit.

        `_startup_error_headline` prepends `"RuntimeError: "` (14 chars), so a
        286-char body yields a 300-char headline (no note) and a 287-char body
        yields 301 (note) — pinning the `>` vs `>=` boundary against off-by-one.
        """
        from deepagents_code.app import _STARTUP_ERROR_HEADLINE_LIMIT
        from deepagents_code.widgets.messages import ErrorMessage

        prefix_len = len("RuntimeError: ")
        at_limit = "a" * (_STARTUP_ERROR_HEADLINE_LIMIT - prefix_len)
        over_limit = "a" * (_STARTUP_ERROR_HEADLINE_LIMIT - prefix_len + 1)

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError(at_limit))
            )
            await pilot.pause()
            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            assert "error truncated" not in str(widget._content)

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError(over_limit))
            )
            await pilot.pause()
            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            assert "error truncated" in str(widget._content)

    async def test_server_failure_truncated_headline_uses_default_debug_path(
        self,
    ) -> None:
        """With `DEBUG_FILE` unset, a handler at the default path is named.

        Guards the `DEFAULT_DEBUG_FILE` fallback — a regression in the default
        resolution would otherwise go uncaught. The handler is installed at the
        default path so `installed_debug_log_path` reports it.
        """
        import logging

        from deepagents_code._debug import configure_debug_logging
        from deepagents_code._env_vars import DEBUG, DEBUG_FILE, DEFAULT_DEBUG_FILE
        from deepagents_code.widgets.messages import ErrorMessage

        package_logger = logging.getLogger("deepagents_code")
        pre_existing = list(package_logger.handlers)
        with patch.dict(os.environ, {DEBUG: "1"}, clear=False):
            os.environ.pop(DEBUG_FILE, None)
            configure_debug_logging(package_logger)
        added = [h for h in package_logger.handlers if h not in pre_existing]
        try:
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()
                long_message = "Unknown tool names: " + ", ".join(
                    f"tool_{i}" for i in range(80)
                )
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=RuntimeError(long_message))
                )
                await pilot.pause()

                widget = app._startup_failure_widget
                assert isinstance(widget, ErrorMessage)
                assert DEFAULT_DEBUG_FILE in str(widget._content)
        finally:
            for h in added:
                h.close()
                package_logger.removeHandler(h)

    async def test_server_failure_mcp_config_error_omits_class_prefix(self) -> None:
        """`MCPConfigError` banner shows the path and reason without class prefix."""
        from deepagents_code.mcp_tools import MCPConfigError

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            message = "Invalid MCP config at /tmp/x.json: bad shape"
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=MCPConfigError(message))
            )

            assert app._server_startup_error == message
            assert "MCPConfigError:" not in app._server_startup_error

    async def test_server_failure_empty_error_falls_back_to_class_name(self) -> None:
        """A whitespace-only exception message falls back to the class name."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=RuntimeError("   "))
            )

            assert app._server_startup_error == "RuntimeError: RuntimeError"

    async def test_server_failure_missing_package_records_recovery_hint(
        self,
    ) -> None:
        """`MissingProviderPackageError` stashes the exception and renders a hint.

        Asserts both that the slot carries the exception itself (so the hint
        builder gets named `.provider`/`.package` access) and that the hint
        text actually lands in the mounted `ErrorMessage` widget — catching a
        regression where the `elif` branch is dropped or `provider`/`package`
        are swapped.
        """
        from deepagents_code.model_config import MissingProviderPackageError
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            # `_server_kwargs is not None` gates the hint — emulate a startup
            # path where the user has a model selected.
            app._server_kwargs = {"model_name": "fireworks:fake"}
            app._connecting = True

            error = MissingProviderPackageError(
                "Missing package for provider 'fireworks'. Install: /install fireworks",
                provider="fireworks",
                package="langchain-fireworks",
            )
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=error)
            )
            # Mount runs as a fire-and-forget task; let it land.
            await pilot.pause()

            stashed = app._server_startup_missing_provider_package
            assert stashed is error
            assert stashed.provider == "fireworks"
            assert stashed.package == "langchain-fireworks"
            # Credentials slot is a different recovery path; should stay clear.
            assert app._server_startup_missing_credentials_provider is None

            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            rendered = str(widget._content)
            # `fireworks` is a known extra → /install hint, not the raw uv form.
            assert "/install fireworks" in rendered
            assert "/model fireworks:<model>" in rendered

    async def test_server_failure_missing_vertexai_package_uses_declared_extra(
        self,
    ) -> None:
        """Startup hint should resolve extras from missing packages."""
        from deepagents_code.model_config import MissingProviderPackageError
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_kwargs = {"model_name": "google_vertexai:fake"}
            app._connecting = True

            error = MissingProviderPackageError(
                "Missing package for provider 'google_vertexai'. "
                "Install: /install vertex",
                provider="google_vertexai",
                package="langchain-google-vertexai",
            )
            with patch(
                "deepagents_code.extras_info.extra_for_package",
                return_value="vertex",
            ) as mock_extra_for_package:
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=error)
                )
                await pilot.pause()

            mock_extra_for_package.assert_called_once_with("langchain-google-vertexai")
            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            rendered = str(widget._content)
            assert "/install vertex" in rendered
            assert "/install google-vertexai" not in rendered
            assert "/model google_vertexai:<model>" in rendered

    async def test_server_failure_missing_unknown_package_shows_uv_command(
        self,
    ) -> None:
        """Manual fallback should include the default uv tool command."""
        from deepagents_code.model_config import MissingProviderPackageError
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_kwargs = {"model_name": "custom_provider:fake"}
            app._connecting = True

            error = MissingProviderPackageError(
                "Missing package for provider 'custom_provider'.",
                provider="custom_provider",
                package="langchain-custom_provider",
            )
            with (
                patch(
                    "deepagents_code.extras_info.extra_for_package",
                    return_value=None,
                ),
                patch(
                    "deepagents_code.extras_info.installed_extra_names",
                    return_value=set(),
                ),
            ):
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=error)
                )
                await pilot.pause()

            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            rendered = str(widget._content)
            assert (
                "uv tool install -U deepagents-code --with langchain-custom_provider"
                in rendered
            )
            assert "/model custom_provider:<model>" in rendered

    async def test_server_failure_unknown_package_introspection_failure_manual(
        self,
    ) -> None:
        """Unreadable extras metadata degrades the hint to a manual instruction.

        Exercises the `ExtrasIntrospectionError` arm so a corrupted-metadata
        environment still surfaces an actionable hint rather than crashing the
        failure-rendering path.
        """
        from deepagents_code.extras_info import ExtrasIntrospectionError
        from deepagents_code.model_config import MissingProviderPackageError
        from deepagents_code.widgets.messages import ErrorMessage

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_kwargs = {"model_name": "custom_provider:fake"}
            app._connecting = True

            error = MissingProviderPackageError(
                "Missing package for provider 'custom_provider'.",
                provider="custom_provider",
                package="langchain-custom_provider",
            )
            with (
                patch(
                    "deepagents_code.extras_info.extra_for_package",
                    return_value=None,
                ),
                patch(
                    "deepagents_code.extras_info.installed_extra_names",
                    side_effect=ExtrasIntrospectionError("metadata unreadable"),
                ),
            ):
                app.on_deep_agents_app_server_start_failed(
                    DeepAgentsApp.ServerStartFailed(error=error)
                )
                await pilot.pause()

            widget = app._startup_failure_widget
            assert isinstance(widget, ErrorMessage)
            rendered = str(widget._content)
            assert (
                "install the `langchain-custom_provider` package manually" in rendered
            )
            assert "uv tool install" not in rendered
            assert "/model custom_provider:<model>" in rendered
        """`_retry_startup_with_model` must clear the package recovery slot.

        Mirrors the credentials-slot reset directly above it. A regression
        that drops the reset would leave a stale `/install` hint visible
        after a successful retry, or render the wrong hint on the next failure.
        """
        from deepagents_code.model_config import (
            MissingProviderPackageError,
            ProviderAuthState,
            ProviderAuthStatus,
        )

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_kwargs = {"model_name": "fireworks:fake"}
            app._server_startup_error = "stale"
            app._server_startup_missing_provider_package = MissingProviderPackageError(
                "stale",
                provider="fireworks",
                package="langchain-fireworks",
            )
            app._server_startup_missing_credentials_provider = "stale"
            app.query_one = MagicMock(side_effect=NoMatches("any"))  # ty: ignore
            app.run_worker = MagicMock()  # ty: ignore

            with patch(
                "deepagents_code.model_config.get_provider_auth_status",
                return_value=ProviderAuthStatus(
                    state=ProviderAuthState.UNKNOWN,
                    provider="anthropic",
                    detail="credentials unknown",
                ),
            ):
                await app._retry_startup_with_model("anthropic:claude-opus-4-7")

            assert app._server_startup_missing_provider_package is None
            assert app._server_startup_missing_credentials_provider is None
            assert app._server_startup_error is None

            # A `/model` retry is a mid-session reconnect, not an initial
            # connect: both flags are set and the status bar reads
            # "reconnecting" while the rebuilt server comes up.
            assert app._connecting is True
            assert app._reconnecting is True
            assert app._status_bar is not None
            assert app._status_bar.connection_state == "reconnecting"

    async def test_server_failure_missing_credentials_clears_package_slot(
        self,
    ) -> None:
        """A `MissingCredentialsError` failure must clear the package slot.

        Guards the mutual-exclusion invariant: the two recovery hints route
        through separate `if`/`elif` branches, so stale state from a prior
        retry-failure must never bleed into the next failure type.
        """
        from deepagents_code.model_config import (
            MissingCredentialsError,
            MissingProviderPackageError,
        )

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_kwargs = {"model_name": "openai:gpt-4o"}
            # Seed a prior package failure so we can assert the next failure
            # clears it.
            app._server_startup_missing_provider_package = MissingProviderPackageError(
                "stale",
                provider="fireworks",
                package="langchain-fireworks",
            )
            app._connecting = True

            error = MissingCredentialsError(
                "OPENAI_API_KEY is not set",
                provider="openai",
                env_var="OPENAI_API_KEY",
            )
            app.on_deep_agents_app_server_start_failed(
                DeepAgentsApp.ServerStartFailed(error=error)
            )

            assert app._server_startup_missing_provider_package is None
            assert app._server_startup_missing_credentials_provider == "openai"

    async def test_failing_deferred_action_does_not_block_others(self) -> None:
        """A failing deferred action should not prevent subsequent ones."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def bad_action() -> None:  # noqa: RUF029
                msg = "boom"
                raise RuntimeError(msg)

            async def good_action() -> None:  # noqa: RUF029
                executed.append("ok")

            app._deferred_actions.append(
                DeferredAction(kind="model_switch", execute=bad_action)
            )
            app._deferred_actions.append(
                DeferredAction(kind="thread_switch", execute=good_action)
            )

            await app._drain_deferred_actions()

            assert executed == ["ok"]
            assert len(app._deferred_actions) == 0

    async def test_defer_action_deduplicates_by_kind(self) -> None:
        """Deferring two actions of the same kind keeps only the last."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def first() -> None:  # noqa: RUF029
                executed.append("first")

            async def second() -> None:  # noqa: RUF029
                executed.append("second")

            app._defer_action(DeferredAction(kind="model_switch", execute=first))
            app._defer_action(DeferredAction(kind="model_switch", execute=second))

            assert len(app._deferred_actions) == 1
            await app._drain_deferred_actions()
            assert executed == ["second"]

    async def test_can_bypass_queue_version_only_connecting(self) -> None:
        """/version bypasses only during connection, not agent/shell."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            # Connecting only → bypass
            app._connecting = True
            app._agent_running = False
            app._shell_running = False
            assert app._can_bypass_queue("/version") is True

            # Agent running (even if connecting) → no bypass
            app._agent_running = True
            assert app._can_bypass_queue("/version") is False

            # Shell running (even if connecting) → no bypass
            app._agent_running = False
            app._shell_running = True
            assert app._can_bypass_queue("/version") is False

            # Not connecting → no bypass
            app._connecting = False
            app._shell_running = False
            assert app._can_bypass_queue("/version") is False

    async def test_can_bypass_queue_about_matches_version(self) -> None:
        """/about follows the same connection-only bypass policy as /version."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._connecting = True
            assert app._can_bypass_queue("/about") is True

            app._agent_running = True
            assert app._can_bypass_queue("/about") is False

    async def test_can_bypass_queue_bare_model_bypasses(self) -> None:
        """Bare /model should bypass the queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._can_bypass_queue("/model") is True
            assert app._can_bypass_queue("/threads") is True

    async def test_can_bypass_queue_model_with_args_no_bypass(self) -> None:
        """/model with args should NOT bypass (direct switch must queue)."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._can_bypass_queue("/model gpt-4") is False
            assert app._can_bypass_queue("/model --default foo") is False

    async def test_model_with_args_still_queues(self) -> None:
        """/model gpt-4 should be queued when busy, not bypass."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True

            app.post_message(ChatInput.Submitted("/model gpt-4", "command"))
            await pilot.pause()

            assert len(app._pending_messages) == 1
            assert app._pending_messages[0].text == "/model gpt-4"

    async def test_side_effect_free_bypasses_queue(self) -> None:
        """SIDE_EFFECT_FREE commands bypass the queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            for cmd in ("/changelog", "/copy", "/docs", "/feedback", "/mcp"):
                assert app._can_bypass_queue(cmd) is True

    async def test_queued_commands_do_not_bypass(self) -> None:
        """QUEUED commands must not bypass the queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            for cmd in ("/help", "/clear", "/tokens"):
                assert app._can_bypass_queue(cmd) is False

    async def test_can_bypass_queue_empty_string(self) -> None:
        """Empty string should not bypass the queue."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._can_bypass_queue("") is False

    async def test_defer_action_mixed_kinds_preserves_ordering(self) -> None:
        """Deferring mixed kinds keeps ordering; same-kind replaces in place."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            executed: list[str] = []

            async def first_model() -> None:  # noqa: RUF029
                executed.append("first_model")

            async def thread_fn() -> None:  # noqa: RUF029
                executed.append("thread")

            async def second_model() -> None:  # noqa: RUF029
                executed.append("second_model")

            app._defer_action(DeferredAction(kind="model_switch", execute=first_model))
            app._defer_action(DeferredAction(kind="thread_switch", execute=thread_fn))
            app._defer_action(DeferredAction(kind="model_switch", execute=second_model))

            assert len(app._deferred_actions) == 2
            assert app._deferred_actions[0].kind == "thread_switch"
            assert app._deferred_actions[1].kind == "model_switch"

            await app._drain_deferred_actions()
            assert executed == ["thread", "second_model"]


class TestBuildModelSwitchErrorBody:
    """Tests for `_build_model_switch_error_body` link-aware formatting."""

    def test_unknown_provider_error_returns_content_with_clickable_link(self) -> None:
        """`UnknownProviderError` produces a `Content` body with a `link` span."""
        from textual.content import Content

        from deepagents_code.app import _build_model_switch_error_body
        from deepagents_code.model_config import (
            PROVIDERS_DOCS_URL,
            UnknownProviderError,
        )

        exc = UnknownProviderError(model_spec="mystery-model")
        body = _build_model_switch_error_body(exc)
        assert isinstance(body, Content)
        links = [
            getattr(span.style, "link", None)
            for span in body.spans
            if getattr(span.style, "link", None)
        ]
        assert links == [PROVIDERS_DOCS_URL]
        # Both the model spec and the URL appear in the rendered text.
        assert "mystery-model" in body.plain
        assert PROVIDERS_DOCS_URL in body.plain

    def test_other_exception_returns_plain_string(self) -> None:
        """Non-`UnknownProviderError` exceptions render as a plain string body."""
        from deepagents_code.app import _build_model_switch_error_body

        body = _build_model_switch_error_body(ValueError("boom"))
        assert body == "Failed to switch model: boom"


class TestServerStartupError:
    """Test error messages when the server fails to start."""

    async def test_send_to_agent_silent_when_server_error_set(self) -> None:
        """`_send_to_agent` does not mount anything when a startup error is set.

        `on_deep_agents_app_server_start_failed` is the single source of truth
        for the failure surface; the send path used to duplicate the
        `ErrorMessage` per submit attempt and was collapsed.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._server_startup_error = (
                "RuntimeError: Server process exited with code 3"
            )

            await app._send_to_agent("hello")
            await pilot.pause()

            assert len(app.query(ErrorMessage)) == 0
            assert len(app.query(AppMessage)) == 0

    async def test_send_to_agent_shows_generic_when_no_server_error(self) -> None:
        """_send_to_agent should show the generic AppMessage when no server error."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            await app._send_to_agent("hello")
            await pilot.pause()

            msgs = app.query(AppMessage)
            assert len(msgs) == 1
            assert msgs[0]._content == "Agent not configured for this session."


class TestHasConversationMessages:
    """Tests for _has_conversation_messages guard."""

    async def test_returns_false_when_no_agent(self) -> None:
        """Should return False when the agent is not initialised."""
        app = DeepAgentsApp()
        async with app.run_test():
            assert app._agent is None
            assert await app._has_conversation_messages() is False

    async def test_returns_false_when_state_empty(self) -> None:
        """Should return False when state has no values."""
        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = {}
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is False

    async def test_returns_false_when_no_thread_id(self) -> None:
        """Should return False when no thread id is resolved yet."""
        app = DeepAgentsApp()
        async with app.run_test():
            agent = AsyncMock()
            app._agent = agent
            app._lc_thread_id = None

            assert await app._has_conversation_messages() is False

    async def test_returns_false_when_only_system_messages(self) -> None:
        """Should return False when messages list has no HumanMessage."""
        from langchain_core.messages import SystemMessage

        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = {"messages": [SystemMessage(content="sys")]}
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is False

    async def test_returns_true_when_human_message_present(self) -> None:
        """Should return True when at least one HumanMessage exists."""
        from langchain_core.messages import HumanMessage

        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = {"messages": [HumanMessage(content="hi")]}
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is True

    async def test_returns_true_when_human_message_is_dict(self) -> None:
        """Should detect human messages returned as raw dicts (RemoteGraph)."""
        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = {"messages": [{"type": "human", "content": "hi"}]}
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is True

    async def test_returns_false_when_only_non_human_dicts(self) -> None:
        """Should not treat every raw dict as human; non-human dicts are False."""
        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = {"messages": [{"type": "ai", "content": "hello"}]}
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is False

    async def test_returns_true_on_aget_state_exception(self) -> None:
        """Should return True on transient errors so /remember is not blocked."""
        app = DeepAgentsApp()
        async with app.run_test():
            agent = AsyncMock()
            agent.aget_state = AsyncMock(side_effect=RuntimeError("connection lost"))
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is True

    async def test_returns_false_when_state_values_is_none(self) -> None:
        """Should return False when state.values is None."""
        app = DeepAgentsApp()
        async with app.run_test():
            state = MagicMock()
            state.values = None
            agent = AsyncMock()
            agent.aget_state = AsyncMock(return_value=state)
            app._agent = agent
            app._lc_thread_id = "t1"

            assert await app._has_conversation_messages() is False


class TestRememberRequiresMessages:
    """Ensure /remember early-returns when no conversation exists."""

    async def test_remember_no_messages_shows_early_return(self) -> None:
        """/remember should mount an AppMessage and skip the skill."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            with patch.object(app, "_has_conversation_messages", return_value=False):
                await app._handle_command("/remember")
                await pilot.pause()

            msgs = app.query(AppMessage)
            assert len(msgs) == 1
            assert "Nothing to remember yet" in str(msgs[0]._content)

    async def test_remember_with_messages_delegates_to_skill(self) -> None:
        """/remember should delegate to _handle_skill_command when messages exist."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            with (
                patch.object(app, "_has_conversation_messages", return_value=True),
                patch.object(app, "_handle_skill_command") as mock_skill,
            ):
                await app._handle_command("/remember")
                await pilot.pause()

            mock_skill.assert_called_once_with("/skill:remember")


class TestSwitchAgentGuards:
    """Guards in `_switch_agent` before the restart worker is launched."""

    async def test_noop_when_same_agent(self) -> None:
        """Switching to the already-active agent should do nothing."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=MagicMock(),
        )
        async with app.run_test():
            with patch.object(app, "run_worker") as worker:
                app._switch_agent("coder")
            worker.assert_not_called()
            assert app._agent_switching is False

    async def test_rejects_remote_server_mode(self) -> None:
        """Without a local `server_proc` the CLI can't restart the agent."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs=None,
            server_proc=None,
        )
        async with app.run_test():
            notifications: list[str] = []
            with (
                patch.object(
                    app, "notify", side_effect=lambda m, **_: notifications.append(m)
                ),
                patch.object(app, "run_worker") as worker,
            ):
                app._switch_agent("researcher")
            worker.assert_not_called()
            assert any("remote server" in m for m in notifications)
            assert app._assistant_id == "coder"
            assert app._agent_switching is False

    async def test_defers_while_local_server_is_connecting(self) -> None:
        """Local startup should queue the swap instead of warning as remote."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=None,
        )

        async with app.run_test():
            notifications: list[str] = []
            with (
                patch.object(
                    app, "notify", side_effect=lambda m, **_: notifications.append(m)
                ),
                patch.object(app, "run_worker") as worker,
            ):
                app._switch_agent("researcher")

            worker.assert_not_called()
            assert len(app._deferred_actions) == 1
            action = app._deferred_actions[0]
            assert action.kind == "agent_switch"
            assert any("connection completes" in m for m in notifications)
            assert all("remote server" not in m for m in notifications)
            assert app._assistant_id == "coder"
            assert app._agent_switching is False

            with patch.object(app, "_switch_agent") as switch:
                await action.execute()
            switch.assert_called_once_with("researcher")

    async def test_rejects_while_agent_running(self) -> None:
        """Mid-run swaps are rejected so in-flight streams aren't orphaned."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=MagicMock(),
        )
        app._agent_running = True
        async with app.run_test():
            notifications: list[str] = []
            with (
                patch.object(
                    app, "notify", side_effect=lambda m, **_: notifications.append(m)
                ),
                patch.object(app, "run_worker") as worker,
            ):
                app._switch_agent("researcher")
            worker.assert_not_called()
            assert any("task is running" in m for m in notifications)

    async def test_rejects_reentry_while_switching(self) -> None:
        """A second switch while the first is in flight is a no-op."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=MagicMock(),
        )
        app._agent_switching = True
        async with app.run_test():
            with patch.object(app, "run_worker") as worker:
                app._switch_agent("researcher")
            worker.assert_not_called()
        # Reset the flag so the test app can tear down cleanly.
        app._agent_switching = False

    async def test_rejects_missing_agent_dir(self, tmp_path: Path) -> None:
        """Missing `~/.deepagents/<name>/` rejects before launching a worker."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=MagicMock(),
        )
        (tmp_path / "coder").mkdir()
        async with app.run_test():
            with (
                patch("deepagents_code.config.settings") as mock_settings,
                patch.object(app, "run_worker") as worker,
            ):
                mock_settings.user_deepagents_dir = tmp_path
                app._switch_agent("ghost")
            worker.assert_not_called()
            assert app._assistant_id == "coder"

    async def test_launches_worker_on_valid_switch(self, tmp_path: Path) -> None:
        """A valid switch enqueues the restart worker and sets the guard flag."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            server_kwargs={"assistant_id": "coder"},
            server_proc=MagicMock(),
        )
        (tmp_path / "researcher").mkdir()
        async with app.run_test():
            with (
                patch("deepagents_code.config.settings") as mock_settings,
                patch.object(app, "run_worker") as worker,
            ):
                mock_settings.user_deepagents_dir = tmp_path
                app._switch_agent("researcher")
            worker.assert_called_once()
            assert app._agent_switching is True
            # Close the coroutine that the mocked run_worker never awaited,
            # otherwise pytest emits "coroutine was never awaited".
            coro = worker.call_args.args[0]
            coro.close()
        app._agent_switching = False


class TestRestartServerForAgentSwap:
    """End-to-end wiring for the agent-swap worker."""

    @staticmethod
    def _make_app() -> tuple[DeepAgentsApp, MagicMock]:
        """Return `(app, server_proc_mock)`.

        Returning the mock directly keeps its `.url` / `.restart` /
        `.update_env` mutable attributes typed as `Any` (via `MagicMock`)
        so tests can reassign them without fighting `ServerProcess`'s
        static type.
        """
        server_proc = MagicMock()
        server_proc.update_env = MagicMock()
        server_proc.restart = AsyncMock()
        server_proc.url = "http://127.0.0.1:54321"
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id="old-thread",
            server_kwargs={"assistant_id": "coder"},
            server_proc=server_proc,
            defer_server_start=True,
        )
        return app, server_proc

    async def test_happy_path_rebuilds_agent_and_updates_identity(
        self,
    ) -> None:
        """Successful restart stages env, calls restart, and rewires client."""
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app, server_proc = self._make_app()
        # Seed an assistant message so the previous thread looks like it
        # produced agent-side output — the resume hint is gated on the
        # presence of a `ASSISTANT`/`TOOL`/`SKILL` entry, since those only
        # land in the store after a server round-trip wrote a checkpoint.
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content="hi there")
        )
        async with app.run_test() as pilot:
            await pilot.pause()

            # Simulate port rebind during restart (TIME_WAIT) so the test
            # catches any regression that reuses the old URL.
            def _rebind_port() -> None:
                server_proc.url = "http://127.0.0.1:60000"

            server_proc.restart = AsyncMock(side_effect=_rebind_port)

            mounted: list[object] = []
            spawned_workers: list[tuple[object, dict]] = []
            real_run_worker = app.run_worker

            def _run_worker_spy(coro_or_fn: Any, **kwargs: Any) -> Any:  # noqa: ANN401  # test spy — accepts whatever run_worker does
                spawned_workers.append((coro_or_fn, kwargs))
                # Close the coroutine so pytest doesn't warn about it being
                # unawaited — we only care that the worker was scheduled.
                if hasattr(coro_or_fn, "close"):
                    coro_or_fn.close()
                return real_run_worker(asyncio.sleep(0))

            with (
                patch(
                    "deepagents_code.model_config.save_recent_agent",
                    return_value=True,
                ) as save_mock,
                patch.object(app, "_mount_message", side_effect=mounted.append),
                patch.object(app, "run_worker", side_effect=_run_worker_spy),
            ):
                await app._restart_server_for_agent_swap("researcher")

            server_proc.update_env.assert_called_once_with(
                DEEPAGENTS_CODE_SERVER_ASSISTANT_ID="researcher"
            )
            server_proc.restart.assert_awaited_once()
            assert app._assistant_id == "researcher"
            # Picker switch is explicit user choice — both the session id
            # and the persisted default should advance together.
            assert app._default_assistant_id == "researcher"
            assert app._server_kwargs is not None
            assert app._server_kwargs["assistant_id"] == "researcher"
            assert app._agent is not None
            # RemoteAgent must target the URL that `server_proc.url` holds
            # *after* restart, not the original.
            assert app._agent._url == "http://127.0.0.1:60000"  # ty: ignore
            assert app._connecting is False
            assert app._reconnecting is False
            assert app._agent_switching is False
            assert app._lc_thread_id != "old-thread"

            save_mock.assert_called_once_with("researcher")

            # Skill-discovery worker launched with the dedicated group.
            groups = {kw.get("group") for _, kw in spawned_workers}
            assert "agent-switch-skill-discovery" in groups

            # Confirmation + resume-hint messages reached the user.
            plain = [str(getattr(m, "_content", m)) for m in mounted]
            assert any("Switched to researcher" in s for s in plain)
            assert any("dcode -r old-thread" in s and "to resume" in s for s in plain)

    async def test_no_resume_hint_when_previous_thread_has_no_agent_output(
        self,
    ) -> None:
        """Untouched thread (no agent output) skips the resume hint.

        An agent switch immediately after launch has a thread ID but no
        server-side checkpoint, so `-r <thread>` would fail to resume
        anything. Don't surface a hint that points at an empty thread.
        """
        app, _server_proc = self._make_app()
        assert app._message_store.total_count == 0  # sanity check

        mounted: list[object] = []
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.model_config.save_recent_agent",
                    return_value=True,
                ),
                patch.object(app, "_mount_message", side_effect=mounted.append),
                patch.object(app, "run_worker", side_effect=_closing_run_worker_mock),
            ):
                await app._restart_server_for_agent_swap("researcher")

        plain = [str(getattr(m, "_content", m)) for m in mounted]
        assert any("Switched to researcher" in s for s in plain)
        assert not any("to resume" in s for s in plain)

    async def test_no_resume_hint_when_only_local_user_messages(self) -> None:
        """Local-only slash commands don't count as agent-side activity.

        Flows like `/update` and `!shell` mount a `UserMessage` widget but
        never call the server, so no checkpoint exists. A `USER`-only store
        must not trigger the resume hint.
        """
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app, _server_proc = self._make_app()
        app._message_store.append(MessageData(type=MessageType.USER, content="/update"))

        mounted: list[object] = []
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.model_config.save_recent_agent",
                    return_value=True,
                ),
                patch.object(app, "_mount_message", side_effect=mounted.append),
                patch.object(app, "run_worker", side_effect=_closing_run_worker_mock),
            ):
                await app._restart_server_for_agent_swap("researcher")

        plain = [str(getattr(m, "_content", m)) for m in mounted]
        assert any("Switched to researcher" in s for s in plain)
        assert not any("to resume" in s for s in plain)

    async def test_no_resume_hint_when_no_previous_thread(self) -> None:
        """Fresh session (no previous thread) skips the resume hint."""
        server_proc = MagicMock()
        server_proc.update_env = MagicMock()
        server_proc.restart = AsyncMock()
        server_proc.url = "http://127.0.0.1:54321"
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="coder",
            thread_id=None,
            server_kwargs={"assistant_id": "coder"},
            server_proc=server_proc,
            defer_server_start=True,
        )
        mounted: list[object] = []
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.model_config.save_recent_agent",
                    return_value=True,
                ),
                patch.object(app, "_mount_message", side_effect=mounted.append),
                patch.object(app, "run_worker", side_effect=_closing_run_worker_mock),
            ):
                await app._restart_server_for_agent_swap("researcher")

        plain = [str(getattr(m, "_content", m)) for m in mounted]
        assert any("Switched to researcher" in s for s in plain)
        assert not any("to resume" in s for s in plain)

    async def test_swap_save_failure_notifies_after_confirmation(self) -> None:
        """A failed `save_recent_agent` after a swap must surface a toast.

        Locks two invariants:
            1. The notify is wired with `markup=False` and `severity="warning"`.
                `markup=False` is load-bearing — the message contains a
                semicolon and stray commas, and the Toast renderer would
                crash if markup parsing were enabled.
            2. The "Switched to X" confirmation lands BEFORE the warning
                notify. Otherwise the toast hovers next to a green
                success line, making the causality unreadable.
        """
        from deepagents_code.widgets.message_store import MessageData, MessageType

        app, _server_proc = self._make_app()
        app._message_store.append(
            MessageData(type=MessageType.ASSISTANT, content="hi there")
        )

        order: list[str] = []
        mounted: list[object] = []

        def record_mount(msg: object) -> None:
            mounted.append(msg)
            content_str = str(getattr(msg, "_content", msg))
            if "Switched to" in content_str:
                order.append("confirmation")

        def record_notify(*args: Any, **kwargs: Any) -> None:
            if kwargs.get("severity") == "warning" and "config" in str(args[0]).lower():
                order.append("notify")

        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.model_config.save_recent_agent",
                    return_value=False,
                ),
                patch.object(
                    app, "_mount_message", AsyncMock(side_effect=record_mount)
                ),
                patch.object(app, "run_worker", side_effect=_closing_run_worker_mock),
                patch.object(app, "notify", side_effect=record_notify) as notify_mock,
            ):
                await app._restart_server_for_agent_swap("researcher")

        # Confirmation message reached the user.
        plain = [str(getattr(m, "_content", m)) for m in mounted]
        assert any("Switched to researcher" in s for s in plain)

        # The save-failure warning notify fired with the right kwargs.
        warning_calls = [
            notify_call
            for notify_call in notify_mock.call_args_list
            if notify_call.kwargs.get("severity") == "warning"
        ]
        assert warning_calls, (
            f"expected a warning notify; got {notify_mock.call_args_list}"
        )
        for notify_call in warning_calls:
            assert notify_call.kwargs.get("markup") is False
            assert "agent" in str(notify_call.args[0]).lower()

        # Confirmation must precede the notify in the observed sequence.
        assert order == ["confirmation", "notify"], (
            f"confirmation must precede notify; got {order}"
        )

    async def test_failure_rolls_back_identity_and_posts_failed(
        self,
    ) -> None:
        """If restart raises, identity reverts and the banner failure fires."""
        app, server_proc = self._make_app()
        boom = RuntimeError("langgraph dev exited")
        server_proc.restart = AsyncMock(side_effect=boom)
        posted: list[object] = []
        with patch.object(app, "post_message", side_effect=lambda m: posted.append(m)):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._restart_server_for_agent_swap("researcher")

        assert app._assistant_id == "coder"
        # Both ids roll back together; a failed swap must not leave the
        # persisted default pointing at an agent the user never reached.
        assert app._default_assistant_id == "coder"
        assert app._server_kwargs is not None
        assert app._server_kwargs["assistant_id"] == "coder"
        assert app._agent is None
        assert app._agent_switching is False
        # The ServerStartFailed handler sets _connecting=False on message
        # dispatch, but the worker itself must also clear it synchronously
        # before posting so any code reading the flag in between sees the
        # correct value.
        assert app._connecting is False
        # The failure path must reset `_reconnecting` in lock-step so the flags
        # never strand in the meaningless `(_connecting=False, _reconnecting=True)`
        # state the `_reconnecting` docstring warns against.
        assert app._reconnecting is False
        failures = [m for m in posted if isinstance(m, DeepAgentsApp.ServerStartFailed)]
        assert len(failures) == 1
        assert failures[0].error is boom


class TestResolveResumeThread:
    """Resume-thread inference must not pollute the persisted default agent."""

    @staticmethod
    def _make_app(assistant_id: str = "agent") -> DeepAgentsApp:
        # `server_kwargs=None` so the auto-mounted `_start_server_background`
        # worker doesn't fire and consume `_resume_thread_intent` before the
        # test gets to call `_resolve_resume_thread` directly.
        return DeepAgentsApp(
            agent=MagicMock(),
            assistant_id=assistant_id,
            server_kwargs=None,
            server_proc=None,
        )

    async def test_specific_thread_resume_leaves_default_alone(self) -> None:
        """`-r <thread>` from a different agent updates session id only."""
        app = self._make_app("agent")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "thread-from-coder"
            with (
                patch(
                    "deepagents_code.sessions.thread_exists",
                    AsyncMock(return_value=True),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value="coder"),
                ),
            ):
                await app._resolve_resume_thread()

            assert app._assistant_id == "coder"
            # The default — and therefore what `[agents].recent` will be
            # written as at startup — must reflect user choice, not whatever
            # agent happened to own the resumed thread.
            assert app._default_assistant_id == "agent"

    async def test_explicit_a_blocks_specific_thread_override(self) -> None:
        """`-a coder -r <thread>` keeps both ids on `coder` regardless of thread agent.

        Locks the gate at `_resolve_resume_thread`'s `elif` branch
        (`if self._assistant_id == default_agent`): explicit `-a` suppresses
        the agent inference, so the thread's owner ("researcher" here) is
        never queried and neither id changes.
        """
        app = self._make_app("coder")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "thread-from-researcher"
            get_thread_agent_mock = AsyncMock(return_value="researcher")
            with (
                patch(
                    "deepagents_code.sessions.thread_exists",
                    AsyncMock(return_value=True),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    get_thread_agent_mock,
                ),
            ):
                await app._resolve_resume_thread()

            assert app._assistant_id == "coder"
            assert app._default_assistant_id == "coder"
            get_thread_agent_mock.assert_not_called()

    async def test_most_recent_resume_leaves_default_alone(self) -> None:
        """`-r` (no thread id) must not redefine the default either."""
        app = self._make_app("agent")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "__MOST_RECENT__"
            with (
                patch(
                    "deepagents_code.sessions.get_most_recent",
                    AsyncMock(return_value="recent-thread"),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value="coder"),
                ),
            ):
                await app._resolve_resume_thread()

            assert app._assistant_id == "coder"
            assert app._default_assistant_id == "agent"

    async def test_resume_enables_model_adoption_flag(self) -> None:
        """Resuming an existing thread arms the one-shot model-adoption flag."""
        app = self._make_app("agent")

        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._should_adopt_resumed_model is False
            app._resume_thread_intent = "some-thread"
            with (
                patch(
                    "deepagents_code.sessions.thread_exists",
                    AsyncMock(return_value=True),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value=None),
                ),
            ):
                await app._resolve_resume_thread()

            assert app._should_adopt_resumed_model is True

    async def test_explicit_model_suppresses_adoption_flag(self) -> None:
        """`--model` wins over a resumed thread's persisted model."""
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="agent",
            server_kwargs=None,
            server_proc=None,
            model_explicitly_set=True,
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "some-thread"
            with (
                patch(
                    "deepagents_code.sessions.thread_exists",
                    AsyncMock(return_value=True),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value=None),
                ),
            ):
                await app._resolve_resume_thread()

            assert app._should_adopt_resumed_model is False

    async def test_no_previous_thread_leaves_adoption_flag_unset(self) -> None:
        """Falling back to a fresh thread must not arm model adoption."""
        app = self._make_app("agent")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "__MOST_RECENT__"
            with patch(
                "deepagents_code.sessions.get_most_recent",
                AsyncMock(return_value=None),
            ):
                await app._resolve_resume_thread()

            assert app._should_adopt_resumed_model is False

    async def test_most_recent_resume_arms_adoption_flag(self) -> None:
        """`-r` (most recent) resolving to a thread also arms model adoption.

        Covers the `__MOST_RECENT__` arming site, distinct from the explicit
        `-r <id>` branch.
        """
        app = self._make_app("agent")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "__MOST_RECENT__"
            with (
                patch(
                    "deepagents_code.sessions.get_most_recent",
                    AsyncMock(return_value="recent-thread"),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value=None),
                ),
            ):
                await app._resolve_resume_thread()

            assert app._should_adopt_resumed_model is True


def _missing_dep_entry(
    tool: str = "ripgrep",
    *,
    install_command: str | None = None,
    url: str | None = None,
) -> PendingNotification:
    from deepagents_code.notifications import (
        ActionId,
        MissingDepPayload,
        NotificationAction,
        PendingNotification,
    )

    return PendingNotification(
        key=f"dep:{tool}",
        title=f"{tool} missing",
        body=f"Install {tool}.",
        actions=(
            NotificationAction(ActionId.SUPPRESS, "Don't show", primary=True),
            NotificationAction(ActionId.COPY_INSTALL, "Copy install command"),
            NotificationAction(ActionId.OPEN_WEBSITE, "Open install guide"),
        ),
        payload=MissingDepPayload(tool=tool, install_command=install_command, url=url),
    )


def _update_entry(latest: str = "2.0.0") -> PendingNotification:
    from deepagents_code.notifications import (
        ActionId,
        NotificationAction,
        PendingNotification,
        UpdateAvailablePayload,
    )

    return PendingNotification(
        key="update:available",
        title="Update available",
        body=f"v{latest} is available.",
        actions=(
            NotificationAction(ActionId.INSTALL, "Install now", primary=True),
            NotificationAction(ActionId.SKIP_ONCE, "Remind me next launch"),
            NotificationAction(ActionId.SKIP_VERSION, "Skip this version"),
        ),
        payload=UpdateAvailablePayload(
            latest=latest, upgrade_cmd="uv tool upgrade deepagents-code"
        ),
    )


def test_build_update_notification_uses_release_and_installed_age_copy() -> None:
    """Update notices separate latest-release age from installed-version age."""
    from deepagents_code.app import DeepAgentsApp

    notification = DeepAgentsApp._build_update_notification(
        latest="2.0.0",
        cli_version="1.0.0",
        release_age=" (released 3d ago)",
        installed_age=" (8 days old)",
        upgrade_cmd="uv tool upgrade deepagents-code",
    )

    assert notification.body == (
        "v2.0.0 is available (released 3d ago).\n"
        "Currently installed: 1.0.0 (8 days old).\n"
        "Your session will not be interrupted."
    )
    assert notification.title == "Update available"


class TestNotificationCenterIntegration:
    """App-level wiring between the notifications registry and the modal."""

    @pytest.fixture(autouse=True)
    def _quiet_startup_workers(self) -> Iterator[None]:
        """Silence the registry-populating startup workers.

        `_check_optional_tools_background` would otherwise replace
        test-constructed notifications by key (the host's real install
        hint differs from what tests assert), and `_check_for_updates`
        would race PyPI. These tests manage the registry themselves
        and only want to exercise the dispatcher / modal wiring.
        """
        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=[],
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            yield

    async def test_ctrl_n_with_empty_registry_emits_toast(self) -> None:
        """ctrl+n with nothing pending notifies and doesn't push a modal."""
        from deepagents_code.notifications import NotificationRegistry

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._notice_registry = NotificationRegistry()

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.press("ctrl+n")
            await pilot.pause()
            assert not isinstance(app.screen, ModalScreen)

        assert any("No pending notifications" in m for m in notified)

    async def test_ctrl_n_over_modal_toasts_close_hint(self) -> None:
        """ctrl+n while a modal is open surfaces a hint instead of stacking."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._notice_registry.add(_missing_dep_entry())

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        class _Dummy(ModalScreen[None]):
            def compose(self) -> ComposeResult:
                yield Static("modal")

        async with app.run_test() as pilot:
            await pilot.pause()
            app.push_screen(_Dummy())
            await pilot.pause()
            await pilot.press("ctrl+n")
            await pilot.pause()

        assert any("Close the current dialog" in m for m in notified)

    async def test_ctrl_n_with_pending_opens_modal(self) -> None:
        """ctrl+n pushes the NotificationCenterScreen when entries exist."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._notice_registry.add(_missing_dep_entry())

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.press("ctrl+n")
            await pilot.pause()
            assert isinstance(app.screen, NotificationCenterScreen)
            await pilot.press("escape")
            await pilot.pause()
            assert not isinstance(app.screen, NotificationCenterScreen)

    async def test_open_center_dismisses_bound_toasts_keeps_others(self) -> None:
        """Opening the center dismisses registered toasts, leaves unrelated ones."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notification = _missing_dep_entry()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._notify_actionable(notification, severity="warning", timeout=30)
            app.notify("something unrelated", severity="error", timeout=30)
            await pilot.pause()

            bound_identity = app._notice_registry.toast_identity_for(notification.key)
            assert bound_identity is not None
            identities_before = {n.identity for n in app._notifications}
            assert bound_identity in identities_before

            app._open_notification_center()
            await pilot.pause()

            identities_after = {n.identity for n in app._notifications}
            assert bound_identity not in identities_after
            # The unrelated error toast stays up.
            assert len(identities_after) == 1
            # Registry entry persists; only the toast binding is cleared.
            assert app._notice_registry.get(notification.key) is not None
            assert app._notice_registry.toast_identity_for(notification.key) is None

    async def test_open_center_dismisses_all_bound_toasts(self) -> None:
        """Multiple actionable toasts are all dismissed when the center opens."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        ripgrep = _missing_dep_entry("ripgrep")
        tavily = _missing_dep_entry("tavily")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._notify_actionable(ripgrep, severity="warning", timeout=30)
            app._notify_actionable(tavily, severity="warning", timeout=30)
            await pilot.pause()

            ripgrep_identity = app._notice_registry.toast_identity_for(ripgrep.key)
            tavily_identity = app._notice_registry.toast_identity_for(tavily.key)
            assert ripgrep_identity is not None
            assert tavily_identity is not None

            app._open_notification_center()
            await pilot.pause()

            identities_after = {n.identity for n in app._notifications}
            assert ripgrep_identity not in identities_after
            assert tavily_identity not in identities_after
            assert app._notice_registry.toast_identity_for(ripgrep.key) is None
            assert app._notice_registry.toast_identity_for(tavily.key) is None
            # Registry entries persist.
            assert app._notice_registry.get(ripgrep.key) is not None
            assert app._notice_registry.get(tavily.key) is not None

    async def test_dismiss_registered_toasts_noop_when_no_bound(self) -> None:
        """_dismiss_registered_toasts leaves unbound toasts untouched."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        async with app.run_test() as pilot:
            await pilot.pause()
            app.notify("unrelated info", severity="information", timeout=30)
            app.notify("unrelated error", severity="error", timeout=30)
            await pilot.pause()

            identities_before = {n.identity for n in app._notifications}
            assert len(identities_before) == 2

            app._dismiss_registered_toasts()
            await pilot.pause()

            identities_after = {n.identity for n in app._notifications}
            assert identities_after == identities_before

    async def test_suppress_action_removes_entry_and_persists(self) -> None:
        """Selecting 'suppress' calls suppress_warning and removes the entry."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        with patch(
            "deepagents_code.model_config.suppress_warning",
            return_value=True,
        ) as mock_suppress:
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(entry.key, ActionId.SUPPRESS)
                await pilot.pause()

        mock_suppress.assert_called_once_with("ripgrep")
        assert app._notice_registry.get("dep:ripgrep") is None

    async def test_enter_api_key_saved_removes_entry_and_notifies(self) -> None:
        """Saving a service key clears the notice and confirms the restart."""
        from deepagents_code.notifications import ActionId
        from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry("tavily", url="https://tavily.com")
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED)  # ty: ignore
            messages: list[str] = []
            app.notify = lambda message, **_: messages.append(message)  # ty: ignore
            await app._dispatch_notification_action(entry.key, ActionId.ENTER_API_KEY)
            await pilot.pause()

        # The prompt is opened for the service's canonical env var ...
        app._push_screen_wait.assert_awaited_once()  # ty: ignore
        screen = app._push_screen_wait.await_args.args[0]  # ty: ignore
        assert isinstance(screen, AuthPromptScreen)
        assert screen._provider == "tavily"
        assert screen._env_var == "TAVILY_API_KEY"
        # ... and on save the stale notice is gone and the user is told to restart.
        assert app._notice_registry.get("dep:tavily") is None
        assert any("Restart to apply." in m for m in messages)

    async def test_enter_api_key_unknown_service_is_a_noop(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """ENTER_API_KEY on a non-service tool logs and opens nothing."""
        import logging

        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry("ripgrep")
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            app._push_screen_wait = AsyncMock()  # ty: ignore
            with caplog.at_level(logging.WARNING):
                await app._dispatch_notification_action(
                    entry.key, ActionId.ENTER_API_KEY
                )
            await pilot.pause()

        app._push_screen_wait.assert_not_awaited()  # ty: ignore
        # Non-service tool: nothing opened, entry untouched, dev-facing log only.
        assert app._notice_registry.get("dep:ripgrep") is entry
        assert "Unknown action_id" in caplog.text

    async def test_suppress_message_reloads_center_in_place(self) -> None:
        """Posting NotificationSuppressRequested refreshes the open center."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
            NotificationSuppressRequested,
            _NotificationRow,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        dep = _missing_dep_entry("ripgrep")
        tavily = _missing_dep_entry("tavily")
        app._notice_registry.add(dep)
        app._notice_registry.add(tavily)

        with patch(
            "deepagents_code.model_config.suppress_warning",
            return_value=True,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                app._open_notification_center()
                await pilot.pause()
                center = app.screen
                assert isinstance(center, NotificationCenterScreen)
                assert len(center.query(_NotificationRow)) == 2

                center.post_message(NotificationSuppressRequested("dep:ripgrep"))
                await pilot.pause()

                # Suppressed entry gone; center stayed open on the rest.
                assert isinstance(app.screen, NotificationCenterScreen)
                keys = [r.notification.key for r in app.screen.query(_NotificationRow)]
                assert keys == ["dep:tavily"]
                assert app._notice_registry.get("dep:ripgrep") is None

    async def test_suppress_failure_while_center_open_keeps_rows_intact(self) -> None:
        """suppress_warning=False with center open leaves all rows visible."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
            NotificationSuppressRequested,
            _NotificationRow,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        dep = _missing_dep_entry("ripgrep")
        tavily = _missing_dep_entry("tavily")
        app._notice_registry.add(dep)
        app._notice_registry.add(tavily)

        with patch(
            "deepagents_code.model_config.suppress_warning",
            return_value=False,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                app._open_notification_center()
                await pilot.pause()
                center = app.screen
                assert isinstance(center, NotificationCenterScreen)

                center.post_message(NotificationSuppressRequested("dep:ripgrep"))
                await pilot.pause()

                # Entry stays; center stays open with both rows reachable.
                assert isinstance(app.screen, NotificationCenterScreen)
                keys = [r.notification.key for r in app.screen.query(_NotificationRow)]
                assert keys == ["dep:ripgrep", "dep:tavily"]
                assert app._notice_registry.get("dep:ripgrep") is dep

    async def test_suppress_last_entry_closes_center(self) -> None:
        """Suppressing the only remaining entry dismisses the center."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
            NotificationSuppressRequested,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry("ripgrep")
        app._notice_registry.add(entry)

        with patch(
            "deepagents_code.model_config.suppress_warning",
            return_value=True,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                app._open_notification_center()
                await pilot.pause()
                assert isinstance(app.screen, NotificationCenterScreen)

                app.screen.post_message(NotificationSuppressRequested("dep:ripgrep"))
                await pilot.pause()

                assert not isinstance(app.screen, NotificationCenterScreen)

    async def test_suppress_action_failure_keeps_entry_and_warns(self) -> None:
        """When suppress_warning returns False, the entry stays and a warning toasts."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch(
            "deepagents_code.model_config.suppress_warning",
            return_value=False,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(entry.key, ActionId.SUPPRESS)
                await pilot.pause()

        assert app._notice_registry.get("dep:ripgrep") is entry
        assert any("Could not save notification preference" in m for m in notified)

    async def test_suppress_skips_persistence_in_debug_mode(self) -> None:
        """SUPPRESS with DEEPAGENTS_CODE_DEBUG_NOTIFICATIONS set skips persistence."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with (
            patch.dict(
                os.environ,
                {"DEEPAGENTS_CODE_DEBUG_NOTIFICATIONS": "1"},
                clear=False,
            ),
            patch(
                "deepagents_code.model_config.suppress_warning",
            ) as mock_suppress,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(entry.key, ActionId.SUPPRESS)
                await pilot.pause()

        mock_suppress.assert_not_called()
        assert app._notice_registry.get("dep:ripgrep") is None
        assert any("debug mode" in m for m in notified)

    async def test_copy_install_action_copies_command(self) -> None:
        """COPY_INSTALL copies the payload command to the clipboard."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry(install_command="brew install ripgrep")
        app._notice_registry.add(entry)

        copied: list[str] = []

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "copy_to_clipboard", side_effect=copied.append):
                await app._dispatch_notification_action(
                    entry.key, ActionId.COPY_INSTALL
                )
                await pilot.pause()

        assert copied == ["brew install ripgrep"]

    async def test_copy_install_without_command_warns(self) -> None:
        """COPY_INSTALL with no install_command posts a warning toast."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            await app._dispatch_notification_action(entry.key, ActionId.COPY_INSTALL)
            await pilot.pause()

        assert any("No install command" in m for m in notified)

    async def test_open_website_action_opens_url(self) -> None:
        """OPEN_WEBSITE invokes webbrowser.open with the payload URL."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry(url="https://tavily.com")
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch("webbrowser.open", return_value=True) as mock_open:
                await app._dispatch_notification_action(
                    entry.key, ActionId.OPEN_WEBSITE
                )
                await pilot.pause()

        mock_open.assert_called_once_with("https://tavily.com")

    async def test_open_website_on_ripgrep_entry_routes_to_ripgrep_url(self) -> None:
        """Dispatching OPEN_WEBSITE on the ripgrep entry opens _RIPGREP_URL."""
        from deepagents_code.main import _RIPGREP_URL, build_missing_tool_notification
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        with patch(
            "deepagents_code.main._ripgrep_install_hint",
            return_value="brew install ripgrep",
        ):
            entry = build_missing_tool_notification("ripgrep")
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch("webbrowser.open", return_value=True) as mock_open:
                await app._dispatch_notification_action(
                    entry.key, ActionId.OPEN_WEBSITE
                )
                await pilot.pause()

        mock_open.assert_called_once_with(_RIPGREP_URL)

    async def test_open_website_failure_warns_with_url(self) -> None:
        """When webbrowser.open returns False, warn and include the URL."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry(url="https://tavily.com")
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch("webbrowser.open", return_value=False):
                await app._dispatch_notification_action(
                    entry.key, ActionId.OPEN_WEBSITE
                )
                await pilot.pause()

        assert any(
            "Could not open a browser" in m and "https://tavily.com" in m
            for m in notified
        )

    async def test_install_success_removes_entry(self) -> None:
        """Successful install removes the entry and toasts restart hint."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.update_check.perform_upgrade",
                new=AsyncMock(return_value=(True, "Updated deepagents-code")),
            ):
                await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
                await pilot.pause()

        assert app._notice_registry.get("update:available") is None

    async def test_debug_update_install_does_not_run_upgrade(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Debug update modal can exercise Install now without changing packages."""
        from deepagents_code._env_vars import DEBUG_UPDATE
        from deepagents_code.notifications import ActionId
        from deepagents_code.widgets.update_progress import UpdateProgressScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            monkeypatch.setenv(DEBUG_UPDATE, "1")
            with patch(
                "deepagents_code.update_check.perform_upgrade",
                new=AsyncMock(return_value=(True, "Updated deepagents-code")),
            ) as mock_upgrade:
                with patch(
                    "deepagents_code.app.asyncio.sleep",
                    new=AsyncMock(),
                ):
                    await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
                await pilot.pause()
                assert isinstance(app.screen, UpdateProgressScreen)

        mock_upgrade.assert_not_called()
        assert app._notice_registry.get("update:available") is None
        assert not any("Mock update complete" in m for m in notified)

    async def test_install_failure_removes_entry_and_toasts_manual(self) -> None:
        """Failed install removes the stale entry and surfaces the manual command."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.update_check.perform_upgrade",
                new=AsyncMock(return_value=(False, "ERROR: network unreachable")),
            ):
                await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
                await pilot.pause()

        assert app._notice_registry.get("update:available") is None
        assert any("Run manually" in m for m in notified)
        assert any("network unreachable" in m for m in notified)

    async def test_install_immediate_failure_updates_mounted_modal(self) -> None:
        """Immediate install failures still render the completed modal state."""
        from deepagents_code.config import get_glyphs
        from deepagents_code.notifications import ActionId
        from deepagents_code.widgets.update_progress import UpdateProgressScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)

        def fail_immediately(
            **kwargs: Any,
        ) -> tuple[bool, str]:
            assert kwargs["progress"] is not None
            assert kwargs["log_path"] is not None
            assert isinstance(app.screen, UpdateProgressScreen)
            assert app.screen._status_widget is not None
            assert app.screen._tail_widget is not None
            return False, "brew: command not found"

        async with app.run_test() as pilot:
            await pilot.pause()
            with patch(
                "deepagents_code.update_check.perform_upgrade",
                new=AsyncMock(side_effect=fail_immediately),
            ):
                await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
                await pilot.pause()

            assert isinstance(app.screen, UpdateProgressScreen)
            status = app.screen.query(Static).filter(".up-status").first()
            details = app.screen.query(Static).filter(".up-details").first()
            spinner = app.screen.query(Static).filter(".up-spinner").first()
            assert "Update failed. Try manually:" in str(status.render())
            assert details.display is True
            assert str(spinner.render()) == get_glyphs().checkmark

    async def test_update_skip_once_clears_notified_marker(self) -> None:
        """'Remind me next launch' calls clear_update_notified and removes the entry."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)
        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch(
            "deepagents_code.update_check.clear_update_notified",
        ) as mock_clear:
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(entry.key, ActionId.SKIP_ONCE)
                await pilot.pause()

        mock_clear.assert_called_once()
        assert app._notice_registry.get("update:available") is None
        assert any("remind you next launch" in m for m in notified)

    async def test_update_skip_version_marks_notified_for_latest(self) -> None:
        """'Skip this version' marks the version notified and removes the entry."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry(latest="3.1.4")
        app._notice_registry.add(entry)
        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch(
            "deepagents_code.update_check.mark_update_notified",
        ) as mock_mark:
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(
                    entry.key, ActionId.SKIP_VERSION
                )
                await pilot.pause()

        mock_mark.assert_called_once_with("3.1.4")
        assert app._notice_registry.get("update:available") is None
        assert any("Skipped v3.1.4" in m for m in notified)

    async def test_dispatcher_handler_exception_surfaces_action_label(self) -> None:
        """A handler raising OSError produces a warning toast naming the action."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch(
            "deepagents_code.model_config.suppress_warning",
            side_effect=OSError("permission denied"),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(entry.key, ActionId.SUPPRESS)
                await pilot.pause()

        assert any("Don't show" in m and "permission denied" in m for m in notified)

    async def test_notify_actionable_binds_toast_identity(self) -> None:
        """_notify_actionable registers the toast identity for click routing."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notification = _missing_dep_entry()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._notify_actionable(notification, severity="warning", timeout=30)
            await pilot.pause()

        entry = app._notice_registry.get("dep:ripgrep")
        assert entry is not None
        identity = app._notice_registry.toast_identity_for("dep:ripgrep")
        assert identity is not None
        assert app._notice_registry.is_actionable_toast(identity)

    def test_toast_identity_returns_identity_when_attribute_present(self) -> None:
        """_toast_identity reads identity off the internal _notification attr."""
        from deepagents_code.app import _toast_identity

        toast = MagicMock()
        toast._notification = MagicMock(identity="toast-identity-123")
        assert _toast_identity(toast) == "toast-identity-123"

    def test_toast_identity_returns_none_when_attribute_missing(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Missing _notification (Textual rename) returns None and warns once."""
        import logging

        from deepagents_code.app import _toast_identity

        toast_without = MagicMock(spec=[])
        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            result = _toast_identity(toast_without)
        assert result is None

    async def test_open_notification_center_after_notify_actionable(self) -> None:
        """_open_notification_center surfaces entries posted via _notify_actionable."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notification = _missing_dep_entry()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._notify_actionable(notification, severity="warning", timeout=30)
            await pilot.pause()

            entry = app._notice_registry.get(notification.key)
            assert entry is not None
            identity = app._notice_registry.toast_identity_for(notification.key)
            assert identity is not None
            assert app._notice_registry.is_actionable_toast(identity)

            app._open_notification_center()
            await pilot.pause()
            assert isinstance(app.screen, NotificationCenterScreen)

    async def test_background_worker_registers_missing_tool_entries(self) -> None:
        """_check_optional_tools_background populates the registry via the factory."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=["ripgrep"],
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.main._ripgrep_install_hint",
                return_value="brew install ripgrep",
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        entry = app._notice_registry.get("dep:ripgrep")
        assert entry is not None
        identity = app._notice_registry.toast_identity_for("dep:ripgrep")
        assert identity is not None

    async def test_tool_toasts_suppressed_when_update_modal_pending(self) -> None:
        """When the update modal is pending, missing-dep toasts are silent.

        Entry is still added to the registry so ctrl+n surfaces it
        after the update modal is dismissed; only the toast is skipped
        so the update modal's `clear_notifications` call doesn't cause
        a visible flicker at startup. Suppression is keyed on
        `_update_modal_pending`, not `_update_available`.
        """
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._update_available = (True, "9.9.9")
        app._update_modal_pending.set()
        app._update_check_done.set()

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=["ripgrep"],
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.main._ripgrep_install_hint",
                return_value="brew install ripgrep",
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        entry = app._notice_registry.get("dep:ripgrep")
        assert entry is not None
        assert app._notice_registry.toast_identity_for("dep:ripgrep") is None

    async def test_tool_toasts_fire_when_update_detected_but_throttled(self) -> None:
        """Detected-but-throttled update leaves missing-dep toasts firing.

        Regression for the bug where suppression was gated on "update
        detected" rather than "modal will open". A returning user with
        a recently notified update (throttled) must still see missing
        tool warnings.
        """
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        # Update was detected but the modal will NOT open.
        app._update_available = (True, "9.9.9")
        app._update_check_done.set()
        # Crucially, _update_modal_pending stays clear.

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=["ripgrep"],
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.main._ripgrep_install_hint",
                return_value="brew install ripgrep",
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        entry = app._notice_registry.get("dep:ripgrep")
        assert entry is not None
        assert app._notice_registry.toast_identity_for("dep:ripgrep") is not None

    async def test_update_check_skips_editable_install(self) -> None:
        """Editable installs skip update detection and never queue the modal."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=True),
            patch("deepagents_code.update_check.is_update_available") as available,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_for_updates()
                await pilot.pause()

        available.assert_not_called()
        assert app._notice_registry.get("update:available") is None
        assert app._update_available == (False, None)
        assert not app._update_modal_pending.is_set()

    async def test_update_check_ignores_stale_notice_after_in_place_upgrade(
        self,
    ) -> None:
        """Skip stale notices after an in-place upgrade."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch(
                "deepagents_code.config._is_editable_install",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.is_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
            ) as auto_update_enabled,
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_for_updates()
                await pilot.pause()

        auto_update_enabled.assert_not_called()
        assert app._notice_registry.get("update:available") is None
        assert app._update_available == (False, None)
        assert not app._update_modal_pending.is_set()

    async def test_update_check_auto_opens_dedicated_modal(self) -> None:
        """A detected update auto-opens the dedicated update modal after first paint."""
        from deepagents_code.widgets.update_available import UpdateAvailableScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch(
                "deepagents_code.config._is_editable_install",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.is_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.should_notify_update",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.mark_update_notified",
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.format_installed_age_suffix",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.upgrade_command",
                return_value="uv tool upgrade deepagents-code",
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_for_updates()
                await pilot.pause()
                assert isinstance(app.screen, UpdateAvailableScreen)

    async def test_periodic_update_check_toasts_without_opening_modal(self) -> None:
        """Hourly rechecks surface updates without interrupting the session."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        bodies: list[str] = []
        original_notify_actionable = app._notify_actionable

        def capture_notify_actionable(
            entry: PendingNotification, **kwargs: Any
        ) -> None:
            bodies.append(f"{entry.body}\n\n{kwargs.get('action_hint', '')}")
            original_notify_actionable(entry, **kwargs)

        app._notify_actionable = capture_notify_actionable  # ty: ignore

        with (
            patch(
                "deepagents_code.config._is_editable_install",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.is_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.should_notify_update",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.mark_update_notified",
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.format_installed_age_suffix",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.upgrade_command",
                return_value="uv tool upgrade deepagents-code",
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_for_updates(periodic=True)
                await pilot.pause()

        entry = app._notice_registry.get("update:available")
        assert entry is not None
        assert any("session will not be interrupted" in body for body in bodies)
        assert any("Press ctrl+n to install." in body for body in bodies)

    async def test_open_update_available_modal_over_modal_toasts_hint(self) -> None:
        """Another modal already open: update modal is deferred with a hint toast."""
        from deepagents_code.widgets.update_available import UpdateAvailableScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)
        app._update_modal_pending.set()

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        class _Dummy(ModalScreen[None]):
            def compose(self) -> ComposeResult:
                yield Static("modal")

        async with app.run_test() as pilot:
            await pilot.pause()
            app.push_screen(_Dummy())
            await pilot.pause()
            app._open_update_available_modal(entry)
            await pilot.pause()
            assert not isinstance(app.screen, UpdateAvailableScreen)

        # Hint toast surfaced with ctrl+n pointer; pending event cleared so
        # subsequent missing-dep toasts aren't suppressed.
        assert any(
            "Update available" in m
            and "session will not be interrupted" in m
            and "ctrl+n" in m
            for m in notified
        )
        assert not app._update_modal_pending.is_set()
        assert app._notice_registry.get(entry.key) is entry

    async def test_update_modal_install_dispatches_action(self) -> None:
        """Picking 'Install now' in the dedicated modal routes to the dispatcher."""
        from deepagents_code.notifications import ActionId
        from deepagents_code.widgets.update_available import UpdateAvailableScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry(latest="3.1.4")
        app._notice_registry.add(entry)

        with patch.object(app, "_dispatch_notification_action") as mock_dispatch:
            mock_dispatch.return_value = asyncio.sleep(0)
            async with app.run_test() as pilot:
                await pilot.pause()
                app._open_update_available_modal(entry)
                await pilot.pause()
                assert isinstance(app.screen, UpdateAvailableScreen)
                await pilot.press("enter")
                await pilot.pause()

        mock_dispatch.assert_called_once_with(entry.key, ActionId.INSTALL)

    async def test_update_modal_shift_tab_moves_to_changelog(self) -> None:
        """App-level shift+tab priority binding routes to the modal's move_up."""
        from deepagents_code.widgets.update_available import (
            UpdateAvailableScreen,
            _ChangelogOption,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _update_entry()
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            app._open_update_available_modal(entry)
            await pilot.pause()
            screen = app.screen
            assert isinstance(screen, UpdateAvailableScreen)
            await pilot.press("shift+tab")
            await pilot.pause()
            assert isinstance(screen._options[screen._selected], _ChangelogOption)

    async def test_notification_center_shift_tab_moves_cursor_up(self) -> None:
        """App-level shift+tab routes to NotificationCenterScreen.move_up."""
        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entries = [_missing_dep_entry("ripgrep"), _missing_dep_entry("tavily")]

        async with app.run_test() as pilot:
            await pilot.pause()
            screen = NotificationCenterScreen(entries)
            app.push_screen(screen)
            await pilot.pause()
            assert screen._selected == 0
            await pilot.press("shift+tab")
            await pilot.pause()
            # Wraps from row 0 to the last row; auto_approve stays off.
            assert screen._selected == len(entries) - 1
            assert app._auto_approve is False

    async def test_notification_detail_shift_tab_moves_cursor_up(self) -> None:
        """App-level shift+tab routes to NotificationDetailScreen.move_up."""
        from deepagents_code.widgets.notification_detail import NotificationDetailScreen

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry("ripgrep")

        async with app.run_test() as pilot:
            await pilot.pause()
            screen = NotificationDetailScreen(entry)
            app.push_screen(screen)
            await pilot.pause()
            start = screen._selected
            await pilot.press("shift+tab")
            await pilot.pause()
            assert screen._selected != start
            assert app._auto_approve is False

    async def test_toast_identity_warn_once_semantics(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Missing `_notification` logs a warning exactly once across calls."""
        import logging

        from deepagents_code import app as app_module
        from deepagents_code.app import _toast_identity

        # Reset the module-global one-shot flag so the test is deterministic.
        app_module._toast_internals_warned[0] = False

        toast_a = MagicMock(spec=[])
        toast_b = MagicMock(spec=[])
        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            assert _toast_identity(toast_a) is None
            assert _toast_identity(toast_b) is None

        warnings = [
            r for r in caplog.records if "toast-click routing" in r.message.lower()
        ]
        assert len(warnings) == 1
        # Reset so later tests see a clean flag.
        app_module._toast_internals_warned[0] = False

    async def test_toast_identity_missing_surfaces_user_toast(self) -> None:
        """First miss with an app supplied posts a one-shot user notification."""
        from deepagents_code import app as app_module
        from deepagents_code.app import _toast_identity

        app_module._toast_internals_warned[0] = False
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        async with app.run_test() as pilot:
            await pilot.pause()
            toast = MagicMock(spec=[])
            assert _toast_identity(toast, app=app) is None
            await pilot.pause()

        assert any(
            "Toast click routing disabled" in m and "ctrl+n" in m for m in notified
        )
        app_module._toast_internals_warned[0] = False

    async def test_check_optional_tools_empty_registers_nothing(self) -> None:
        """No missing tools → registry stays empty, no toasts posted."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=[],
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        assert len(app._notice_registry) == 0

    async def test_check_optional_tools_oserror_logs_and_returns(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Filesystem errors during tool detection are absorbed."""
        import logging

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                side_effect=OSError("permission denied"),
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                with caplog.at_level(logging.DEBUG, logger="deepagents_code.app"):
                    await app._check_optional_tools_background()
                await pilot.pause()

        assert len(app._notice_registry) == 0
        assert any(
            "Failed to check for optional tools" in r.message for r in caplog.records
        )

    async def test_check_optional_tools_unexpected_exception_surfaces_toast(
        self,
    ) -> None:
        """Unexpected exceptions surface as a warning toast rather than vanishing."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                side_effect=RuntimeError("future refactor regression"),
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        assert any("Could not check optional tools" in m for m in notified)
        assert len(app._notice_registry) == 0

    async def test_check_optional_tools_posts_on_update_check_timeout(self) -> None:
        """Timeout on _update_check_done falls through and posts toasts anyway."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        # _update_check_done is deliberately never set;
        # _update_modal_pending stays clear.

        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                return_value=["ripgrep"],
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.main._ripgrep_install_hint",
                return_value="brew install ripgrep",
            ),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                new=AsyncMock(return_value=None),
            ),
            # Force TimeoutError immediately instead of waiting 5 seconds.
            patch(
                "asyncio.wait_for",
                new=AsyncMock(side_effect=TimeoutError),
            ),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._check_optional_tools_background()
                await pilot.pause()

        entry = app._notice_registry.get("dep:ripgrep")
        assert entry is not None
        assert app._notice_registry.toast_identity_for("dep:ripgrep") is not None

    async def test_inject_debug_notifications_populates_registry(self) -> None:
        """`_inject_debug_notifications` seeds missing-dep entries only.

        Also binds a toast identity for each entry — without that, the
        real surface (toast + clickable ctrl+n hint) would be invisible
        even though the registry is populated.
        """
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._inject_debug_notifications()
            await pilot.pause()

        keys = {e.key for e in app._notice_registry.list_all()}
        assert keys == {"dep:ripgrep", "dep:tavily"}
        # Toast identities must be bound so the entries actually surface.
        assert app._notice_registry.toast_identity_for("dep:ripgrep") is not None
        assert app._notice_registry.toast_identity_for("dep:tavily") is not None
        # Update modal must not be triggered by DEBUG_NOTIFICATIONS.
        assert not app._update_modal_pending.is_set()

    async def test_inject_debug_update_registers_entry_and_sets_pending(
        self,
    ) -> None:
        """`_inject_debug_update` registers the update entry and arms the modal."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        async with app.run_test() as pilot:
            await pilot.pause()
            app._inject_debug_update()
            await pilot.pause()

        keys = {e.key for e in app._notice_registry.list_all()}
        assert keys == {"update:available"}
        assert app._update_modal_pending.is_set()

    async def test_dispatcher_unknown_action_logs_warning(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Dispatching an unknown action_id logs rather than crashing."""
        import logging

        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry()
        app._notice_registry.add(entry)

        async with app.run_test() as pilot:
            await pilot.pause()
            with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
                await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
                await pilot.pause()

        # INSTALL is an update-action id — the missing-dep handler logs and no-ops.
        assert any(
            "Unknown action_id" in r.message and entry.key in r.message
            for r in caplog.records
        )
        # Entry still registered since nothing completed successfully.
        assert app._notice_registry.get(entry.key) is entry

    async def test_dispatcher_broad_catch_surfaces_unexpected_exception(self) -> None:
        """Non-OSError exceptions (e.g. RuntimeError) also surface a warning toast."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry(install_command="brew install ripgrep")
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch.object(
            app, "copy_to_clipboard", side_effect=RuntimeError("no clipboard backend")
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(
                    entry.key, ActionId.COPY_INSTALL
                )
                await pilot.pause()

        assert any(
            "Copy install" in m and "no clipboard backend" in m for m in notified
        )

    async def test_open_website_webbrowser_error_surfaces_url(self) -> None:
        """`webbrowser.Error` propagates as a warning toast with the URL."""
        from deepagents_code.notifications import ActionId

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        entry = _missing_dep_entry(url="https://tavily.com")
        app._notice_registry.add(entry)

        notified: list[str] = []
        original_notify = app.notify

        def capture_notify(message: str, **kwargs: Any) -> None:
            notified.append(message)
            original_notify(message, **kwargs)

        app.notify = capture_notify  # ty: ignore

        with patch(
            "webbrowser.open",
            side_effect=webbrowser.Error("no browser found"),
        ):
            async with app.run_test() as pilot:
                await pilot.pause()
                await app._dispatch_notification_action(
                    entry.key, ActionId.OPEN_WEBSITE
                )
                await pilot.pause()

        assert any(
            "Could not open a browser" in m and "https://tavily.com" in m
            for m in notified
        )

    async def test_toast_click_event_opens_notification_center(self) -> None:
        """Simulating `on_click` with a `_Toast` widget opens the notification center.

        Textual's test harness does not reliably mount toast widgets, so we
        drive the click dispatcher directly: register an actionable toast,
        fabricate a Click event carrying the matching `_Toast` identity,
        and assert `on_click` routes to `_open_notification_center`.
        """
        from textual.widgets._toast import Toast as _Toast

        from deepagents_code.widgets.notification_center import (
            NotificationCenterScreen,
        )

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notification = _missing_dep_entry()

        async with app.run_test() as pilot:
            await pilot.pause()
            app._notify_actionable(notification, severity="warning", timeout=60)
            await pilot.pause()

            identity = app._notice_registry.toast_identity_for(notification.key)
            assert identity is not None

            fake_toast = MagicMock(spec=_Toast)
            fake_toast._notification = MagicMock(identity=identity)
            fake_event = MagicMock()
            fake_event.widget = fake_toast

            app.on_click(fake_event)
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen, NotificationCenterScreen)


class TestFatalErrorRedaction:
    """`_fatal_error` must not leak local variables (which carry secrets).

    Locals on the `create_model` call path include resolved API keys in a
    `kwargs` dict. Textual's default rendering uses `show_locals=True`,
    which would print them. We disable locals unless `DEEPAGENTS_CODE_DEBUG`
    is set to a truthy token.
    """

    @staticmethod
    def _call_fatal_error(app: DeepAgentsApp) -> MagicMock:
        """Run `_fatal_error` with the rendering pipeline patched out.

        Returns the `Traceback` mock so callers can inspect its kwargs.
        """
        with (
            patch("rich.traceback.Traceback") as mock_traceback,
            patch("rich.segment.Segments"),
            patch.object(app, "console", MagicMock()),
            patch.object(app, "_close_messages_no_wait"),
            patch.object(app, "bell"),
        ):
            app._fatal_error()
        return mock_traceback

    def test_show_locals_disabled_by_default(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Default crash rendering hides locals so secrets don't reach stderr."""
        monkeypatch.delenv("DEEPAGENTS_CODE_DEBUG", raising=False)
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        mock_traceback = self._call_fatal_error(app)

        mock_traceback.assert_called_once()
        assert mock_traceback.call_args.kwargs["show_locals"] is False

    def test_show_locals_enabled_when_debug_env_truthy(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Truthy `DEEPAGENTS_CODE_DEBUG` re-enables locals for debugging."""
        monkeypatch.setenv("DEEPAGENTS_CODE_DEBUG", "1")
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        mock_traceback = self._call_fatal_error(app)

        assert mock_traceback.call_args.kwargs["show_locals"] is True

    @pytest.mark.parametrize("falsy", ["0", "false", "no", "", "False", "  "])
    def test_show_locals_disabled_for_falsy_strings(
        self, monkeypatch: pytest.MonkeyPatch, falsy: str
    ) -> None:
        """`DEEPAGENTS_CODE_DEBUG=0` (or other falsy strings) MUST NOT enable locals.

        Regression guard: an earlier `bool(os.environ.get(...))` check would
        have flipped to `True` for any non-empty string, leaking the API key
        whenever a user set the var to `"0"` or `"false"` thinking they were
        disabling the flag.
        """
        monkeypatch.setenv("DEEPAGENTS_CODE_DEBUG", falsy)
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        mock_traceback = self._call_fatal_error(app)

        assert mock_traceback.call_args.kwargs["show_locals"] is False

    def test_falls_back_to_super_on_import_failure(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """If the override's own imports fail, defer to Textual's default.

        Otherwise the user double-faults during a real crash and never sees
        any traceback at all.
        """
        monkeypatch.delenv("DEEPAGENTS_CODE_DEBUG", raising=False)
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch.dict("sys.modules", {"rich.traceback": None}),
            patch("textual.app.App._fatal_error") as super_fatal,
        ):
            app._fatal_error()

        super_fatal.assert_called_once()


class TestPrewarmDeferredImports:
    """Prewarming is a cache optimization and must never crash the app.

    When the installed package is replaced in place mid-session (e.g. a
    concurrent `uv tool upgrade deepagents-code`), a not-yet-imported module
    can be transiently absent on disk, so a deferred import raises
    `ModuleNotFoundError`. The prewarm worker must swallow that — the module
    cold-loads on first use instead — rather than surfacing a fatal traceback.
    """

    def test_prewarm_swallows_transient_missing_module(
        self,
        monkeypatch: pytest.MonkeyPatch,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """A deferred import failing mid-prewarm must not propagate.

        Simulates the in-place-upgrade race by pinning a deferred internal
        module to `None` in `sys.modules`, which makes Python's import machinery
        raise `ModuleNotFoundError` for it — the same class the production crash
        raised, and exactly what a transiently missing file would produce.
        """
        import sys

        # `None` in sys.modules makes `from ... import ...` raise
        # `ModuleNotFoundError` ("...halted; None in sys.modules"), mirroring a
        # module file that is momentarily gone during an in-place file swap.
        monkeypatch.setitem(
            sys.modules,
            "deepagents_code.widgets.approval",
            None,
        )

        # Sanity check: the underlying import sequence really does raise, so the
        # guard in `_prewarm_deferred_imports` is what keeps the app alive.
        with pytest.raises(ModuleNotFoundError):
            DeepAgentsApp._load_deferred_modules()

        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            # Must not raise — the worker entry point swallows the failure.
            DeepAgentsApp._prewarm_deferred_imports()

        assert any(
            "Import prewarm failed" in record.message for record in caplog.records
        ), "expected a WARNING when a deferred import fails"

    async def test_prewarm_worker_stays_green_on_missing_module(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """The guard must keep the background worker out of `WorkerFailed`.

        The production wiring runs `_prewarm_deferred_imports` inside a Textual
        worker via `asyncio.to_thread` (`run_worker` defaults to
        `exit_on_error=True`, so an uncaught worker exception is fatal). This
        exercises that same offload path under the missing-module race and
        asserts it completes normally, confirming the guard — not a downstream
        catch in `_await_prewarm_imports` — is what prevents the crash.
        """
        import sys

        monkeypatch.setitem(
            sys.modules,
            "deepagents_code.widgets.approval",
            None,
        )

        # Must not raise: the worker body completes instead of failing.
        await asyncio.to_thread(DeepAgentsApp._prewarm_deferred_imports)


class TestPrewarmAwait:
    """`_start_server_background` must wait for the prewarm worker first.

    The prewarm worker imports `deepagents`/LangChain in a separate thread
    via `asyncio.to_thread`. If `_start_server_background` triggers the
    same module graph from the event-loop thread before prewarm finishes,
    Python's per-module locks form a cycle and CPython raises
    `_DeadlockError` from the import system.
    """

    async def test_await_prewarm_imports_no_worker(self) -> None:
        """No-op when the prewarm worker handle isn't set."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        assert app._prewarm_worker is None
        await app._await_prewarm_imports()  # must not raise

    async def test_await_prewarm_imports_waits_for_worker(self) -> None:
        """Awaits `Worker.wait()` so the import-prewarm thread is fully done."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        worker = MagicMock()
        worker.wait = AsyncMock()
        app._prewarm_worker = worker

        await app._await_prewarm_imports()

        worker.wait.assert_awaited_once()

    async def test_await_prewarm_imports_swallows_worker_failure(self) -> None:
        """`WorkerFailed` is non-fatal; main path proceeds regardless."""
        from textual.worker import WorkerFailed

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        worker = MagicMock()
        worker.wait = AsyncMock(side_effect=WorkerFailed(RuntimeError("boom")))
        app._prewarm_worker = worker

        await app._await_prewarm_imports()  # must not raise

        worker.wait.assert_awaited_once()

    async def test_await_prewarm_imports_swallows_worker_cancelled(self) -> None:
        """`WorkerCancelled` is non-fatal; prewarm is a cache optimization.

        Distinct from `asyncio.CancelledError`: Textual's `Worker.wait()`
        raises `WorkerCancelled` (a plain `Exception`) when the awaited
        worker was cancelled. The caller — typically `_start_server_background`
        — must not propagate that and crash startup.
        """
        from textual.worker import WorkerCancelled

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        worker = MagicMock()
        worker.wait = AsyncMock(side_effect=WorkerCancelled("cancelled"))
        app._prewarm_worker = worker

        await app._await_prewarm_imports()  # must not raise

        worker.wait.assert_awaited_once()

    async def test_await_prewarm_imports_propagates_cancellation(self) -> None:
        """`CancelledError` MUST propagate so app shutdown isn't absorbed."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        worker = MagicMock()
        worker.wait = AsyncMock(side_effect=asyncio.CancelledError())
        app._prewarm_worker = worker

        with pytest.raises(asyncio.CancelledError):
            await app._await_prewarm_imports()

    async def test_start_server_background_awaits_prewarm_before_create_model(
        self,
    ) -> None:
        """Locks the call-order invariant that fixes the deadlock.

        A future refactor that moves the `await _await_prewarm_imports()`
        after `create_model` (or drops it) silently re-introduces the
        production crash. This is the only test that catches that.
        """
        from deepagents_code import config as cli_config

        call_order: list[str] = []

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-7"}
        app._server_kwargs = None
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        app._assistant_id = None

        async def record_prewarm() -> None:
            call_order.append("prewarm")
            await asyncio.sleep(0)  # yield so any out-of-order calls would land first

        def record_create_model(*_: Any, **__: Any) -> MagicMock:
            call_order.append("create_model")
            result = MagicMock()
            result.apply_to_settings = MagicMock()
            result.provider = "anthropic"
            result.model_name = "claude-opus-4-7"
            return result

        with (
            patch.object(app, "_await_prewarm_imports", side_effect=record_prewarm),
            patch.object(cli_config, "create_model", side_effect=record_create_model),
            patch("deepagents_code.model_config.save_recent_model"),
            patch.object(app, "post_message"),
            # `_start_server_background` continues past `create_model` into
            # server + MCP setup we don't care about for an ordering test.
            contextlib.suppress(Exception),
        ):
            await app._start_server_background()

        assert call_order[:2] == ["prewarm", "create_model"], (
            f"prewarm must precede create_model; got {call_order}"
        )

    async def test_start_server_background_persists_default_not_session_id(
        self,
    ) -> None:
        """`save_recent_agent` must receive the user-chosen default.

        Locks the parity invariant: when `-r` resume has overridden the
        session id but the user's default is unchanged, the next bare
        relaunch must still return to the default — not the resumed
        thread's owning agent. Without this assertion a future refactor
        that swaps the argument back to `_assistant_id` is invisible.
        """
        from deepagents_code import config as cli_config

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-7"}
        app._server_kwargs = None
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        # Simulate post-resume state: session ran in `coder`, but the user's
        # chosen default is `agent`.
        app._assistant_id = "coder"
        app._default_assistant_id = "agent"

        def fake_create_model(*_: Any, **__: Any) -> MagicMock:
            result = MagicMock()
            result.apply_to_settings = MagicMock()
            result.provider = "anthropic"
            result.model_name = "claude-opus-4-7"
            return result

        with (
            patch.object(app, "_await_prewarm_imports", AsyncMock()),
            patch.object(cli_config, "create_model", side_effect=fake_create_model),
            patch("deepagents_code.model_config.save_recent_model"),
            patch(
                "deepagents_code.model_config.save_recent_agent",
                return_value=True,
            ) as save_agent_mock,
            patch.object(app, "post_message"),
            contextlib.suppress(Exception),
        ):
            await app._start_server_background()

        save_agent_mock.assert_called_once_with("agent")

    async def test_start_server_background_persists_agent_before_create_model(
        self,
    ) -> None:
        """`save_recent_agent` must run BEFORE `create_model`.

        Locks the reorder that fixes the silent-persistence-loss bug:
        if `create_model` raises a `ModelConfigError` (e.g., missing API
        key), the user's intent to use this agent must already be
        persisted. A regression that moves the save back below
        `create_model` plus a credential miss silently drops the write
        with no test signal.
        """
        from deepagents_code import config as cli_config
        from deepagents_code.model_config import ModelConfigError

        call_order: list[str] = []

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-7"}
        app._server_kwargs = None
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        app._assistant_id = None
        app._default_assistant_id = "agent"

        def record_save_agent(name: str) -> bool:
            call_order.append(f"save_recent_agent:{name}")
            return True

        def record_create_model(*_: Any, **__: Any) -> MagicMock:
            call_order.append("create_model")
            msg = "no credentials"
            raise ModelConfigError(msg)

        with (
            patch.object(app, "_await_prewarm_imports", AsyncMock()),
            patch.object(cli_config, "create_model", side_effect=record_create_model),
            patch(
                "deepagents_code.model_config.save_recent_agent",
                side_effect=record_save_agent,
            ),
            patch("deepagents_code.model_config.save_recent_model"),
            patch.object(app, "post_message"),
            patch.object(app, "notify"),
        ):
            await app._start_server_background()

        # Save must have happened, and must precede create_model in the
        # call sequence — guarding the reorder fix.
        assert "save_recent_agent:agent" in call_order
        assert call_order.index("save_recent_agent:agent") < call_order.index(
            "create_model"
        ), f"save_recent_agent must precede create_model; got {call_order}"

    async def test_start_server_background_notifies_on_save_failure(self) -> None:
        """A failed startup save must surface a visible toast.

        The user explicitly suspected that recent-agent writes were
        silently dropping. Pair with the swap-path notify so both
        codepaths produce a user-visible signal on persistence failure
        rather than only a log line. `markup=False` is load-bearing —
        flipping it back to default `True` re-introduces the Toast
        `MarkupError` risk.
        """
        from deepagents_code import config as cli_config

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-7"}
        app._server_kwargs = None
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        app._assistant_id = None
        app._default_assistant_id = "agent"

        def fake_create_model(*_: Any, **__: Any) -> MagicMock:
            result = MagicMock()
            result.apply_to_settings = MagicMock()
            result.provider = "anthropic"
            result.model_name = "claude-opus-4-7"
            return result

        with (
            patch.object(app, "_await_prewarm_imports", AsyncMock()),
            patch.object(cli_config, "create_model", side_effect=fake_create_model),
            patch("deepagents_code.model_config.save_recent_model"),
            patch(
                "deepagents_code.model_config.save_recent_agent",
                return_value=False,
            ),
            patch.object(app, "post_message"),
            patch.object(app, "notify") as notify_mock,
            contextlib.suppress(Exception),
        ):
            await app._start_server_background()

        # At least one notify call must report the save failure with
        # markup disabled and warning severity.
        warning_calls = [
            notify_call
            for notify_call in notify_mock.call_args_list
            if notify_call.kwargs.get("severity") == "warning"
            and "agent" in str(notify_call.args[0]).lower()
            and "config" in str(notify_call.args[0]).lower()
        ]
        assert warning_calls, (
            f"expected a warning notify about agent save failure; got "
            f"{notify_mock.call_args_list}"
        )
        # markup=False is required so commas/brackets in the message
        # don't crash the Toast renderer (see CLAUDE.md guidance).
        for notify_call in warning_calls:
            assert notify_call.kwargs.get("markup") is False

    async def test_discover_skills_awaits_prewarm_before_thread_offload(
        self,
    ) -> None:
        """Locks the call-order invariant that prevents an import deadlock.

        Skill discovery and prewarm import overlapping parts of the Deep Agents
        graph in separate workers. A regression that drops the
        `await _await_prewarm_imports()` re-introduces the production crash;
        this is the only test that catches that ordering contract.
        """
        call_order: list[str] = []

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        async def record_prewarm() -> None:
            call_order.append("prewarm")
            await asyncio.sleep(0)

        def record_discover() -> tuple[list[Any], list[Any]]:
            call_order.append("discover")
            return [], []

        with (
            patch.object(app, "_await_prewarm_imports", side_effect=record_prewarm),
            patch.object(
                app, "_discover_skills_and_roots", side_effect=record_discover
            ),
        ):
            await app._discover_skills()

        assert call_order == ["prewarm", "discover"], (
            f"prewarm must precede skill discovery thread; got {call_order}"
        )

    async def test_discover_skills_prewarm_failure_warns_with_debug_hint(
        self,
    ) -> None:
        """A prewarm failure should use the discovery failure toast path."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch.object(
                app,
                "_await_prewarm_imports",
                AsyncMock(side_effect=RuntimeError("prewarm failed")),
            ),
            patch.object(app, "_discover_skills_and_roots") as discover_mock,
            patch.object(app, "notify") as notify_mock,
        ):
            ok = await app._discover_skills()

        assert ok is False
        discover_mock.assert_not_called()
        notify_mock.assert_called_once()
        message = notify_mock.call_args.args[0]
        assert "RuntimeError" in message
        assert "DEEPAGENTS_CODE_DEBUG=1" in message
        assert notify_mock.call_args.kwargs["severity"] == "warning"
        assert notify_mock.call_args.kwargs["markup"] is False

    async def test_invoke_skill_cache_miss_uses_import_gate(self) -> None:
        """Cache-miss skill discovery must use the process import gate."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        with (
            patch.object(
                app,
                "_discover_skills_and_roots_with_import_lock",
                return_value=([], []),
            ) as guarded_discover_mock,
            patch.object(app, "_discover_skills_and_roots") as raw_discover_mock,
            patch.object(app, "_mount_message", AsyncMock()),
        ):
            await app._invoke_skill("missing")

        guarded_discover_mock.assert_called_once()
        raw_discover_mock.assert_not_called()

    async def test_start_server_waits_for_skill_discovery_import_gate(
        self,
    ) -> None:
        """Startup model creation must not import during skill discovery."""
        from deepagents_code import config as cli_config

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = {"model_spec": "anthropic:claude-opus-4-8"}
        app._server_kwargs = None
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        app._assistant_id = None

        def fake_create_model(*_: Any, **__: Any) -> MagicMock:
            result = MagicMock()
            result.apply_to_settings = MagicMock()
            result.provider = "anthropic"
            result.model_name = "claude-opus-4-8"
            return result

        with (
            patch.object(app, "_await_prewarm_imports", AsyncMock()),
            patch.object(
                cli_config, "create_model", side_effect=fake_create_model
            ) as create_model_mock,
            patch("deepagents_code.model_config.save_recent_model"),
            patch.object(app, "post_message"),
        ):
            _DEEPAGENTS_IMPORT_LOCK.acquire()
            try:
                task = asyncio.create_task(app._start_server_background())
                await asyncio.sleep(0)
                assert create_model_mock.call_count == 0
            finally:
                _DEEPAGENTS_IMPORT_LOCK.release()
            with contextlib.suppress(Exception):
                await task

            create_model_mock.assert_called_once()


class TestHeaderAndTitle:
    """Header widget visibility and custom title overrides."""

    async def test_default_title_is_deep_agents(self) -> None:
        """Without overrides, `App.title` is the class-level `TITLE`."""
        app = DeepAgentsApp()
        assert app.title == "Deep Agents"

    async def test_custom_title_kwarg_sets_app_title(self) -> None:
        """The `title` kwarg overrides the default Textual `App.title`."""
        app = DeepAgentsApp(title="My Custom Agent")
        assert app.title == "My Custom Agent"

    async def test_custom_sub_title_kwarg_sets_app_sub_title(self) -> None:
        """The `sub_title` kwarg overrides the default `App.sub_title`."""
        app = DeepAgentsApp(sub_title="staging")
        assert app.sub_title == "staging"

    async def test_title_can_be_reassigned_at_runtime(self) -> None:
        """`App.title` is reactive and accepts runtime reassignment."""
        app = DeepAgentsApp(title="Initial")
        async with app.run_test() as pilot:
            await pilot.pause()
            app.title = "Updated"
            await pilot.pause()
            assert app.title == "Updated"

    async def test_header_hidden_by_default(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Without `DEEPAGENTS_CODE_SHOW_HEADER`, no `Header` widget is mounted."""
        monkeypatch.delenv("DEEPAGENTS_CODE_SHOW_HEADER", raising=False)
        from textual.widgets import Header

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert not app.query(Header)

    async def test_header_mounted_when_env_var_truthy(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Setting `DEEPAGENTS_CODE_SHOW_HEADER=1` mounts the `Header` widget."""
        monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_HEADER", "1")
        from textual.widgets import Header

        app = DeepAgentsApp(title="Custom")
        async with app.run_test() as pilot:
            await pilot.pause()
            headers = app.query(Header)
            assert len(headers) == 1

    async def test_header_not_mounted_when_env_var_falsy(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`DEEPAGENTS_CODE_SHOW_HEADER=0` keeps the header hidden."""
        monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_HEADER", "0")
        from textual.widgets import Header

        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert not app.query(Header)


class TestSandboxSubTitle:
    """sub_title reflects the active sandbox provider."""

    async def test_sandbox_sets_sub_title(self) -> None:
        """When a sandbox is active, sub_title shows the provider name."""
        app = DeepAgentsApp(server_kwargs={"sandbox_type": "daytona"})
        assert app.sub_title == "Sandbox: Daytona"

    async def test_sandbox_sub_title_proper_casing(self) -> None:
        """Provider display names use proper casing."""
        app = DeepAgentsApp(server_kwargs={"sandbox_type": "langsmith"})
        assert app.sub_title == "Sandbox: LangSmith"

        app2 = DeepAgentsApp(server_kwargs={"sandbox_type": "agentcore"})
        assert app2.sub_title == "Sandbox: AgentCore"

    async def test_explicit_sub_title_overrides_sandbox(self) -> None:
        """An explicitly passed sub_title is not overwritten by sandbox info."""
        app = DeepAgentsApp(sub_title="custom", server_kwargs={"sandbox_type": "modal"})
        assert app.sub_title == "custom"

    async def test_no_sandbox_leaves_sub_title_default(self) -> None:
        """Without a sandbox, sub_title remains at its Textual default."""
        app = DeepAgentsApp()
        assert app.sub_title == ""

    async def test_sandbox_none_does_not_set_sub_title(self) -> None:
        """The argparse default `'none'` is treated as no sandbox."""
        app = DeepAgentsApp(server_kwargs={"sandbox_type": "none"})
        assert app.sub_title == ""

    async def test_unknown_provider_uses_title_case_fallback(self) -> None:
        """An unrecognized provider falls back to `.title()` casing."""
        app = DeepAgentsApp(server_kwargs={"sandbox_type": "kubernetes"})
        assert app.sub_title == "Sandbox: Kubernetes"


class TestHandleExternalSignal:
    """Verify routing of `kind=signal` external events."""

    async def test_interrupt_calls_action_interrupt(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "action_interrupt") as action:
                await app._handle_external_signal("interrupt")
            action.assert_called_once_with()

    async def test_force_clear_routes_to_command_with_force_bypass(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "_submit_input", new_callable=AsyncMock) as submit:
                await app._handle_external_signal("force-clear")
            submit.assert_called_once_with("/force-clear", "command", force_bypass=True)

    async def test_unknown_signal_is_no_op(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch.object(app, "action_interrupt") as action,
                patch.object(app, "_submit_input", new_callable=AsyncMock) as submit,
            ):
                # Bypasses ExternalEvent's __post_init__ guard which would
                # otherwise reject this payload at the wire boundary.
                await app._handle_external_signal("intrupt")
            action.assert_not_called()
            submit.assert_not_called()


class TestExternalEventEnvGating:
    """`_maybe_start_external_event_source` env-var contract."""

    async def test_off_by_default(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._external_event_source is None
            assert app._external_event_source_task is None

    async def test_falsy_value_does_not_start(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET", "0")
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._external_event_source is None

    async def test_truthy_value_starts_listener(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        import shutil
        import tempfile

        # Use short-path tmp to avoid AF_UNIX path-length limit on macOS.
        socket_dir = tempfile.mkdtemp(dir="/tmp")
        try:
            monkeypatch.setenv("DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET", "1")
            monkeypatch.setenv(
                "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET_PATH",
                f"{socket_dir}/events.sock",
            )
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()
                assert app._external_event_source is not None
                assert app._external_event_source_task is not None
        finally:
            shutil.rmtree(socket_dir, ignore_errors=True)
        del tmp_path

    async def test_socket_file_removed_on_exit(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        import shutil
        import tempfile
        from pathlib import Path as _Path

        socket_dir = tempfile.mkdtemp(dir="/tmp")
        socket_path = _Path(socket_dir) / "events.sock"
        try:
            monkeypatch.setenv("DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET", "1")
            monkeypatch.setenv(
                "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET_PATH",
                str(socket_path),
            )
            app = DeepAgentsApp()
            async with app.run_test() as pilot:
                await pilot.pause()
                for _ in range(50):
                    if socket_path.exists():
                        break
                    await asyncio.sleep(0.01)
                assert socket_path.exists()
                app.exit()
                await pilot.pause()
            assert not socket_path.exists()
        finally:
            shutil.rmtree(socket_dir, ignore_errors=True)


class TestForceInterruptActiveWork:
    """Verify `_force_interrupt_active_work` cancels in-flight work."""

    async def test_cancels_agent_worker(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            worker = MagicMock()
            app._agent_worker = worker
            app._force_interrupt_active_work()
            worker.cancel.assert_called_once()

    async def test_cancels_shell_worker(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._shell_running = True
            worker = MagicMock()
            app._shell_worker = worker
            app._force_interrupt_active_work()
            worker.cancel.assert_called_once()

    async def test_rejects_pending_approval(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            widget = MagicMock()
            app._pending_approval_widget = widget
            app._force_interrupt_active_work()
            widget.action_select_reject.assert_called_once()

    async def test_cancels_pending_ask_user(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            widget = MagicMock()
            app._pending_ask_user_widget = widget
            app._force_interrupt_active_work()
            widget.action_cancel.assert_called_once()

    async def test_drops_queued_messages(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_messages.append(QueuedMessage(text="x", mode="normal"))
            app._force_interrupt_active_work()
            assert len(app._pending_messages) == 0

    async def test_widget_failure_is_logged_not_raised(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            widget = MagicMock()
            widget.action_select_reject.side_effect = AttributeError("boom")
            app._pending_approval_widget = widget
            # Must not raise: best-effort interruption.
            app._force_interrupt_active_work()


class TestExternalBypassFieldHonored:
    """`event.bypass` overrides queue when set on a prompt event."""

    async def test_prompt_with_bypass_skips_queue(self) -> None:
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()
            app._agent_running = True
            with patch.object(app, "_process_message", new_callable=AsyncMock) as pm:
                app.post_message(
                    ExternalInput(
                        ExternalEvent(
                            kind="prompt",
                            payload="urgent",
                            source="test",
                            bypass=BypassTier.ALWAYS,
                        )
                    )
                )
                await pilot.pause()
            pm.assert_called_once_with("urgent", "normal")
            assert len(app._pending_messages) == 0


# Local import for BypassTier in TestExternalBypassFieldHonored.
from deepagents_code.command_registry import BypassTier  # noqa: E402


class TestSetSpinnerTerminalProgress:
    """`_set_spinner` should drive the `OSC 9;4` terminal progress indicator."""

    @pytest.fixture(autouse=True)
    def _isolate_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Point the config path at an empty temp dir.

        Without this, `_load_terminal_progress_preference` reads the developer's
        real `~/.deepagents/config.toml` during `DeepAgentsApp.__init__`, so a
        local `[ui].terminal_progress = false` would silently flip the
        positive-path tests below to failing. Tests that need a specific config
        write to and re-point at this same path.
        """
        monkeypatch.setattr(
            "deepagents_code.model_config.DEFAULT_CONFIG_PATH",
            tmp_path / "config.toml",
        )

    async def test_status_triggers_indeterminate_progress(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A non-None spinner status should set indeterminate progress."""
        from deepagents_code import terminal_escape

        calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []

        def _record_set(*args: object, **kwargs: object) -> bool:
            calls.append(("set", args, dict(kwargs)))
            return True

        def _record_clear() -> bool:
            calls.append(("clear", (), {}))
            return True

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _record_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _record_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc")
        async with app.run_test() as pilot:
            await pilot.pause()
            calls.clear()
            await app._set_spinner("Thinking")
            await pilot.pause()

        assert any(
            entry[0] == "set"
            and entry[2].get("state")
            is terminal_escape.TerminalProgressState.INDETERMINATE
            for entry in calls
        )

    async def test_none_status_clears_progress(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Hiding the spinner should clear terminal progress."""
        from deepagents_code import terminal_escape

        calls: list[str] = []

        def _record_set(*_args: object, **_kwargs: object) -> bool:
            calls.append("set")
            return True

        def _record_clear() -> bool:
            calls.append("clear")
            return True

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _record_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _record_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc")
        async with app.run_test() as pilot:
            await pilot.pause()
            await app._set_spinner("Thinking")
            await pilot.pause()
            calls.clear()
            await app._set_spinner(None)
            await pilot.pause()

        assert "clear" in calls

    async def test_config_opt_out_suppresses_progress(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`[ui].terminal_progress = false` should suppress progress escapes."""
        from deepagents_code import terminal_escape

        config = tmp_path / "config.toml"
        config.write_text("[ui]\nterminal_progress = false\n", encoding="utf-8")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)

        calls: list[str] = []

        def _record_set(*_args: object, **_kwargs: object) -> bool:
            calls.append("set")
            return True

        def _record_clear() -> bool:
            calls.append("clear")
            return True

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _record_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _record_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc-disabled")
        async with app.run_test() as pilot:
            await pilot.pause()
            await app._set_spinner("Thinking")
            await pilot.pause()
            await app._set_spinner(None)
            await pilot.pause()

        assert calls == []

    async def test_config_opt_in_emits_progress(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`[ui].terminal_progress = true` should emit set and clear escapes."""
        from deepagents_code import terminal_escape

        config = tmp_path / "config.toml"
        config.write_text("[ui]\nterminal_progress = true\n", encoding="utf-8")
        monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)

        calls: list[str] = []

        def _record_set(*_args: object, **_kwargs: object) -> bool:
            calls.append("set")
            return True

        def _record_clear() -> bool:
            calls.append("clear")
            return True

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _record_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _record_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc-enabled")
        async with app.run_test() as pilot:
            await pilot.pause()
            calls.clear()
            await app._set_spinner("Thinking")
            await pilot.pause()
            await app._set_spinner(None)
            await pilot.pause()

        assert "set" in calls
        assert "clear" in calls

    async def test_consecutive_set_spinner_calls_keep_emitting(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Re-showing after a clear should re-emit indeterminate progress."""
        from deepagents_code import terminal_escape

        calls: list[str] = []

        def _record_set(*_args: object, **_kwargs: object) -> bool:
            calls.append("set")
            return True

        def _record_clear() -> bool:
            calls.append("clear")
            return True

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _record_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _record_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc-rep")
        async with app.run_test() as pilot:
            await pilot.pause()
            calls.clear()
            await app._set_spinner("Thinking")
            await app._set_spinner("Thinking")
            await app._set_spinner(None)
            await app._set_spinner("Thinking")
            await pilot.pause()

        assert calls.count("set") >= 3
        assert "clear" in calls

    async def test_set_spinner_swallows_terminal_escape_errors(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Unexpected exceptions from `terminal_escape` must not break the UI."""
        from deepagents_code import terminal_escape

        def _boom_set(*_args: object, **_kwargs: object) -> bool:
            msg = "boom"
            raise RuntimeError(msg)

        def _boom_clear() -> bool:
            msg = "boom"
            raise RuntimeError(msg)

        monkeypatch.setattr(terminal_escape, "set_terminal_progress", _boom_set)
        monkeypatch.setattr(terminal_escape, "clear_terminal_progress", _boom_clear)

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-osc-boom")
        async with app.run_test() as pilot:
            await pilot.pause()
            # Must not raise even though both calls explode.
            await app._set_spinner("Thinking")
            await pilot.pause()
            await app._set_spinner(None)
            await pilot.pause()


class TestMCPLoginCommand:
    """Tests for `/mcp login <server>` and the in-TUI login dispatch.

    Worker execution itself is exercised by `test_mcp_login_modal.py`; here
    we verify the guard behavior and command-parsing wiring on the app
    surface so that:

      - `/mcp login <name>` reaches `_start_mcp_login`.
      - Bare `/mcp` still opens the viewer.
      - Remote-server mode refuses login and tells the user.
      - Busy state defers login via `DeferredAction(kind="mcp_login")`.
      - The viewer's dismiss with a server name kicks off login.
    """

    async def test_mcp_login_command_routes_to_start_mcp_login(self) -> None:
        """`/mcp login notion` calls `_start_mcp_login` with the server name."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "_start_mcp_login") as start_login:
                await app._handle_command("/mcp login notion")
                await pilot.pause()
            start_login.assert_called_once_with("notion")

    async def test_mcp_login_without_server_prints_usage(self) -> None:
        """`/mcp login` (no server) shows a usage line."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "_start_mcp_login") as start_login:
                await app._handle_command("/mcp login")
                await pilot.pause()
            start_login.assert_not_called()
            assert any(
                "Usage: /mcp login" in str(w._content) for w in app.query(AppMessage)
            )

    async def test_bare_mcp_still_opens_viewer(self) -> None:
        """`/mcp` with no args opens the viewer modal."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "_show_mcp_viewer") as show_viewer:
                await app._handle_command("/mcp")
                await pilot.pause()
            show_viewer.assert_called_once()

    async def test_mcp_login_rejects_when_mcp_disabled(self) -> None:
        """Without `_mcp_preload_kwargs`, login is unavailable."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = None
            with patch.object(app, "notify") as notify:
                app._start_mcp_login("notion")
            assert notify.called
            message = notify.call_args.args[0]
            assert "MCP is disabled" in message

    async def test_mcp_login_rejects_remote_server_mode(self) -> None:
        """Remote-server mode (no app-owned subprocess) cannot restart."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = None
            with patch.object(app, "notify") as notify:
                app._start_mcp_login("notion")
            assert notify.called
            message = notify.call_args.args[0]
            assert "remote server" in message.lower()

    async def test_mcp_login_defers_while_agent_running(self) -> None:
        """Busy state queues the login via `DeferredAction(kind='mcp_login')`."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = {"some": "kwarg"}
            app._server_proc = MagicMock()
            app._agent_running = True
            try:
                with patch.object(app, "run_worker") as run_worker:
                    app._start_mcp_login("notion")
                run_worker.assert_not_called()
                assert any(a.kind == "mcp_login" for a in app._deferred_actions)
            finally:
                app._agent_running = False

    async def test_viewer_dismiss_with_server_name_starts_login(self) -> None:
        """Selecting an unauth server in the viewer kicks off `_start_mcp_login`."""
        from deepagents_code.mcp_tools import MCPServerInfo

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_server_info = [
                MCPServerInfo(
                    name="github",
                    transport="http",
                    status="unauthenticated",
                    error="needs re-authentication",
                ),
            ]
            with patch.object(app, "_start_mcp_login") as start_login:
                await app._show_mcp_viewer()
                await pilot.pause()
                viewer = app._active_mcp_viewer
                assert viewer is not None
                viewer.dismiss("github")
                await pilot.pause()
            start_login.assert_called_once_with("github")

    def test_optimistic_reenable_restores_pre_disable_server_info(self) -> None:
        """Re-enabling before reconnect restores the server's original viewer state."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo

        original = MCPServerInfo(
            name="filesystem",
            transport="stdio",
            tools=(MCPToolInfo(name="read_file", description="Read a file"),),
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])

        app._apply_optimistic_disabled_state("filesystem", disabled=True)
        assert app._mcp_server_info is not None
        assert app._mcp_server_info[0].status == "disabled"

        app._apply_optimistic_disabled_state("filesystem", disabled=False)

        assert app._mcp_server_info == [original]
        assert app._mcp_optimistic_original_server_info == {}

    def test_optimistic_reenable_started_disabled_points_to_ctrl_r(self) -> None:
        """F2 re-enable guidance should use the in-modal reconnect shortcut."""
        from deepagents_code.mcp_tools import MCPServerInfo

        original = MCPServerInfo(
            name="notion",
            transport="http",
            status="disabled",
            error="Disabled by user.",
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])

        app._apply_optimistic_disabled_state("notion", disabled=False)

        assert app._mcp_server_info is not None
        assert app._mcp_server_info[0].status == "disabled"
        assert app._mcp_server_info[0].error == "Re-enabled — press Ctrl+R to load."

    def test_optimistic_mcp_login_pending_state_relabels_only_target(self) -> None:
        """Deferred OAuth login updates the target without touching siblings."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
        from deepagents_code.widgets.welcome import WelcomeBanner

        ok = MCPServerInfo(
            name="filesystem",
            transport="stdio",
            tools=(MCPToolInfo(name="read_file", description="Read a file"),),
        )
        errored = MCPServerInfo(
            name="broken",
            transport="http",
            status="error",
            error="connection refused",
        )
        target = MCPServerInfo(
            name="github",
            transport="http",
            status="unauthenticated",
            error="needs re-authentication",
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[ok, errored, target])
        banner = MagicMock(spec=WelcomeBanner)
        app.query_one = MagicMock(return_value=banner)  # ty: ignore

        app._apply_optimistic_mcp_login_pending_state("github")

        assert app._mcp_server_info is not None
        assert app._mcp_server_info[0] == ok
        assert app._mcp_server_info[1] == errored
        assert app._mcp_server_info[2].status == "awaiting_reconnect"
        assert app._mcp_server_info[2].error == (
            "Authenticated — run `/mcp reconnect` to load tools."
        )
        assert app._mcp_unauthenticated == 0
        assert app._mcp_errored == 1
        assert app._mcp_awaiting_reconnect == 1
        banner.set_connected.assert_called_once_with(
            1,
            mcp_unauthenticated=0,
            mcp_errored=1,
            mcp_awaiting_reconnect=1,
        )

    def test_optimistic_mcp_login_pending_state_warns_for_unknown_server(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """An unexpected OAuth callback does not fail silently."""
        from deepagents_code.mcp_tools import MCPServerInfo

        original = MCPServerInfo(
            name="github",
            transport="http",
            status="unauthenticated",
            error="needs re-authentication",
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore

        with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
            app._apply_optimistic_mcp_login_pending_state("notion")

        assert app._mcp_server_info == [original]
        assert app._mcp_unauthenticated == 1
        assert any(
            "unknown server 'notion'" in record.message for record in caplog.records
        )

    def test_refresh_welcome_banner_mcp_counts_ignores_missing_banner(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """MCP count refresh is best-effort before the welcome banner mounts."""
        app = DeepAgentsApp(agent=MagicMock())
        app.query_one = MagicMock(side_effect=NoMatches("welcome-banner"))  # ty: ignore

        with caplog.at_level(logging.DEBUG, logger="deepagents_code.app"):
            app._refresh_welcome_banner_mcp_counts()

        assert any(
            "Welcome banner not mounted during MCP count refresh" in record.message
            for record in caplog.records
        )

    def test_init_counts_awaiting_reconnect_servers(self) -> None:
        """Constructor seeds `_mcp_awaiting_reconnect` from initial server info."""
        from deepagents_code.mcp_tools import MCPServerInfo

        waiting = MCPServerInfo(
            name="github",
            transport="http",
            status="awaiting_reconnect",
            error="Authenticated — run `/mcp reconnect` to load tools.",
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[waiting])

        assert app._mcp_awaiting_reconnect == 1

    async def test_server_ready_drops_awaiting_reconnect_after_reconnect(
        self,
    ) -> None:
        """A successful reconnect clears the awaiting-reconnect counter.

        Locks in the contract that `MCPServerReady` recomputes the new counter
        from authoritative server info, so the splash line disappears as soon
        as the LangGraph server reloads tools.
        """
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
        from deepagents_code.widgets.welcome import WelcomeBanner

        waiting = MCPServerInfo(
            name="github",
            transport="http",
            status="awaiting_reconnect",
            error="Authenticated — run `/mcp reconnect` to load tools.",
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[waiting])
        assert app._mcp_awaiting_reconnect == 1

        banner = MagicMock(spec=WelcomeBanner)
        app.query_one = MagicMock(return_value=banner)  # ty: ignore
        app.call_after_refresh = lambda cb: cb()  # ty: ignore

        loaded = MCPServerInfo(
            name="github",
            transport="http",
            tools=(MCPToolInfo(name="search_repos", description="Search repos"),),
        )
        app.on_deep_agents_app_server_ready(
            app.ServerReady(
                agent=MagicMock(), server_proc=None, mcp_server_info=[loaded]
            ),
        )
        for _ in range(3):
            await asyncio.sleep(0)

        assert app._mcp_awaiting_reconnect == 0
        banner.set_connected.assert_called_once_with(
            1,
            mcp_unauthenticated=0,
            mcp_errored=0,
            mcp_awaiting_reconnect=0,
        )

    async def test_disable_then_reenable_before_reconnect_clears_pending_notice(
        self,
    ) -> None:
        """Undoing a disable before reconnect does not tell the user to reconnect."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo

        original = MCPServerInfo(
            name="filesystem",
            transport="stdio",
            tools=(MCPToolInfo(name="read_file", description="Read a file"),),
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.mcp_disabled.is_server_disabled",
                    side_effect=[False, True],
                ),
                patch(
                    "deepagents_code.mcp_disabled.set_server_disabled",
                    return_value=(True, None),
                ),
                patch.object(app, "_show_mcp_viewer", new=AsyncMock()),
                patch.object(app, "notify") as notify,
            ):
                await app._toggle_mcp_server_disabled("filesystem")
                await app._toggle_mcp_server_disabled("filesystem")

        assert app._pending_mcp_reconnect is False
        assert notify.call_count == 2
        assert notify.call_args_list[0].args[0] == (
            "MCP server 'filesystem' disabled. "
            "Run `/mcp reconnect` or press Ctrl+R to apply."
        )
        assert notify.call_args_list[1].args[0] == "MCP server 'filesystem' enabled."

    async def test_toggle_disable_notify_surfaces_persistence_error(self) -> None:
        """A failed persist surfaces the underlying detail and skips state flip."""
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo

        original = MCPServerInfo(
            name="filesystem",
            transport="stdio",
            tools=(MCPToolInfo(name="read_file", description="Read a file"),),
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.mcp_disabled.is_server_disabled",
                    return_value=False,
                ),
                patch(
                    "deepagents_code.mcp_disabled.set_server_disabled",
                    return_value=(False, "could not write /tmp/config.toml"),
                ),
                patch.object(app, "_show_mcp_viewer", new=AsyncMock()),
                patch.object(app, "notify") as notify,
            ):
                await app._toggle_mcp_server_disabled("filesystem")

        notify.assert_called_once()
        message = notify.call_args.args[0]
        assert "filesystem" in message
        assert "could not write /tmp/config.toml" in message
        assert notify.call_args.kwargs.get("severity") == "error"
        assert notify.call_args.kwargs.get("markup") is False
        # In-memory state must not flip on persistence failure.
        assert app._mcp_server_info == [original]
        assert app._pending_mcp_reconnect is False

    async def test_server_ready_refreshes_open_viewer_via_task(self) -> None:
        """A server-ready event refreshes an already-open MCP viewer.

        Covers the `asyncio.create_task(_refresh_viewer())` path added
        when `refresh_server_info` became async: without it, a user who
        opened `/mcp` before the server finished starting would stare at
        the connecting placeholder forever.
        """
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
        from deepagents_code.widgets.mcp_viewer import MCPViewerScreen

        ready_info = [
            MCPServerInfo(
                name="filesystem",
                transport="stdio",
                tools=(MCPToolInfo(name="read_file", description="Read a file"),),
            )
        ]
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._connecting = True
            viewer = MCPViewerScreen(server_info=[], connecting=True)
            app.push_screen(viewer)
            app._active_mcp_viewer = viewer
            await pilot.pause()

            app.on_deep_agents_app_server_ready(
                app.ServerReady(
                    agent=MagicMock(),
                    server_proc=None,
                    mcp_server_info=ready_info,
                )
            )
            for _ in range(3):
                await pilot.pause()

            assert viewer._server_info == ready_info
            assert viewer._connecting is False

    async def test_toggle_disable_rejects_empty_server_name(self) -> None:
        """An empty server name must not reach the persistence layer."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.mcp_disabled.set_server_disabled"
                ) as set_disabled,
                patch.object(app, "notify") as notify,
            ):
                await app._toggle_mcp_server_disabled("")
            set_disabled.assert_not_called()
            notify.assert_not_called()

    async def test_toggle_disable_rejects_unknown_server_name(self) -> None:
        """A server name absent from the loaded config notifies and stops.

        Surfaces the rejection to the user via `notify` (rather than a
        silent log) so an F2 that does nothing isn't mistaken for a
        toggle that succeeded — covers the config-reload race where the
        viewer holds a stale server reference.
        """
        from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo

        known = MCPServerInfo(
            name="filesystem",
            transport="stdio",
            tools=(MCPToolInfo(name="read_file", description="Read a file"),),
        )
        app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[known])
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch(
                    "deepagents_code.mcp_disabled.set_server_disabled"
                ) as set_disabled,
                patch.object(app, "notify") as notify,
            ):
                await app._toggle_mcp_server_disabled("stranger")
            set_disabled.assert_not_called()
            notify.assert_called_once()
            args, kwargs = notify.call_args
            assert "stranger" in args[0]
            assert kwargs.get("severity") == "warning"
            assert kwargs.get("markup") is False

    async def test_mcp_login_rejects_while_connecting(self) -> None:
        """`_connecting=True` prevents login until the server is ready."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = {"some": "kwarg"}
            app._connecting = True
            app._server_proc = MagicMock()
            try:
                with patch.object(app, "notify") as notify:
                    app._start_mcp_login("notion")
                notify.assert_called_once()
                assert "server is ready" in notify.call_args.args[0].lower()
                assert not any(a.kind == "mcp_login" for a in app._deferred_actions)
            finally:
                app._connecting = False

    async def test_mcp_login_rejects_while_server_proc_is_none(self) -> None:
        """`_server_proc=None` prevents login until the server process exists."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = {"some": "kwarg"}
            app._connecting = False
            app._server_proc = None
            with (
                patch.object(app, "run_worker") as run_worker,
                patch.object(app, "notify") as notify,
            ):
                app._start_mcp_login("notion")
            run_worker.assert_not_called()
            notify.assert_called_once()
            assert "server is ready" in notify.call_args.args[0].lower()

    async def test_mcp_login_rejects_while_agent_switching(self) -> None:
        """`_agent_switching=True` refuses login with a distinct message."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = {"some": "kwarg"}
            app._server_proc = MagicMock()
            app._agent_switching = True
            try:
                with (
                    patch.object(app, "run_worker") as run_worker,
                    patch.object(app, "notify") as notify,
                ):
                    app._start_mcp_login("notion")
                run_worker.assert_not_called()
                assert not any(a.kind == "mcp_login" for a in app._deferred_actions)
                notify.assert_called_once()
                assert "agent switch" in notify.call_args.args[0].lower()
            finally:
                app._agent_switching = False

    async def test_mcp_login_defers_while_shell_running(self) -> None:
        """`_shell_running=True` also defers login via `DeferredAction`."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            app._server_kwargs = {"some": "kwarg"}
            app._server_proc = MagicMock()
            app._shell_running = True
            try:
                with patch.object(app, "run_worker") as run_worker:
                    app._start_mcp_login("notion")
                run_worker.assert_not_called()
                assert any(a.kind == "mcp_login" for a in app._deferred_actions)
            finally:
                app._shell_running = False

    async def test_mcp_unknown_subcommand_shows_help(self) -> None:
        """An unknown `/mcp` subcommand surfaces an inline error message."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            await app._handle_command("/mcp frobnicate")
            await pilot.pause()
            assert any(
                "Unknown" in str(w._content) and "frobnicate" in str(w._content)
                for w in app.query(AppMessage)
            )

    async def test_mcp_login_worker_surfaces_config_resolution_error(self) -> None:
        """`_run_mcp_login_worker` exits with ErrorMessage on config-resolve failure."""
        from deepagents_code.mcp_login_service import (
            ConfigErrorKind,
            ConfigResolutionError,
        )

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }
            with patch(
                "deepagents_code.mcp_login_service.resolve_mcp_config",
                return_value=ConfigResolutionError(
                    kind=ConfigErrorKind.NO_CONFIG_FOUND,
                    message="No MCP config file found",
                ),
            ):
                await app._run_mcp_login_worker("notion")
                await pilot.pause()
            assert any(
                "No MCP config file found" in str(w._content)
                for w in app.query(ErrorMessage)
            )

    async def test_mcp_login_worker_does_not_leak_unknown_exception_message(
        self,
    ) -> None:
        """Unknown login exceptions are summarized without leaking their str().

        The MCP SDK can raise exceptions whose `args`/`repr` include an
        `OAuthToken`. The worker uses `format_login_failure` to degrade
        unrecognized types to a class-name chain so tokens never reach the
        user-facing `ErrorMessage` or the rotating log files.
        """
        from pathlib import Path as _Path

        from deepagents_code.mcp_login_service import (
            ConfigResolution,
            ServerSelection,
        )

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }

            resolution = ConfigResolution(
                config={"mcpServers": {"notion": {"type": "http", "auth": "oauth"}}},
                used_paths=(_Path("/tmp/mcp.json"),),
            )
            selection = ServerSelection(
                server_name="notion",
                server_config={
                    "type": "http",
                    "auth": "oauth",
                    "url": "https://example",
                },
            )

            class _FakeMcpError(RuntimeError):
                pass

            sentinel = "TOKEN_PAYLOAD_MUST_NOT_LEAK"

            async def _failing_login(**_: object) -> None:
                await asyncio.sleep(0)
                raise _FakeMcpError(sentinel)

            with (
                patch(
                    "deepagents_code.mcp_login_service.resolve_mcp_config",
                    return_value=resolution,
                ),
                patch(
                    "deepagents_code.mcp_login_service.select_server",
                    return_value=selection,
                ),
                patch("deepagents_code.mcp_auth.login", _failing_login),
            ):
                await app._run_mcp_login_worker("notion")
                await pilot.pause()

            rendered = " ".join(str(w._content) for w in app.query(ErrorMessage))
            assert sentinel not in rendered
            assert "_FakeMcpError" in rendered or "FakeMcpError" in rendered

    async def test_mcp_login_success_invokes_reconnect_prompt(self) -> None:
        """A successful login routes through `_prompt_mcp_reconnect`.

        The worker no longer auto-restarts the LangGraph server: the user
        is given the choice via a modal so multiple back-to-back logins
        can be batched into a single reconnect.
        """
        from pathlib import Path as _Path

        from deepagents_code.mcp_login_service import (
            ConfigResolution,
            ServerSelection,
        )

        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._mcp_preload_kwargs = {
                "mcp_config_path": None,
                "no_mcp": False,
                "trust_project_mcp": None,
            }

            resolution = ConfigResolution(
                config={"mcpServers": {"notion": {"type": "http", "auth": "oauth"}}},
                used_paths=(_Path("/tmp/mcp.json"),),
            )
            selection = ServerSelection(
                server_name="notion",
                server_config={
                    "type": "http",
                    "auth": "oauth",
                    "url": "https://example",
                },
            )

            async def _ok_login(**_: object) -> None:
                await asyncio.sleep(0)

            with (
                patch(
                    "deepagents_code.mcp_login_service.resolve_mcp_config",
                    return_value=resolution,
                ),
                patch(
                    "deepagents_code.mcp_login_service.select_server",
                    return_value=selection,
                ),
                patch("deepagents_code.mcp_auth.login", _ok_login),
                patch.object(app, "_prompt_mcp_reconnect", new=AsyncMock()) as prompt,
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
            ):
                await app._run_mcp_login_worker("notion")
                await pilot.pause()

            prompt.assert_awaited_once_with("notion")
            restart.assert_not_called()

    async def test_prompt_mcp_reconnect_restart_choice_restarts(self) -> None:
        """Choosing `reconnect` triggers `_restart_server_for_mcp_refresh`."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True

            def _push_screen(_screen: object, callback: Any) -> None:  # noqa: ANN401  # callback signature matches Textual's variant
                callback("reconnect")

            with (
                patch.object(app, "push_screen", side_effect=_push_screen),
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
            ):
                await app._prompt_mcp_reconnect("notion")

            restart.assert_awaited_once_with("notion")
            assert app._pending_mcp_reconnect is False

    async def test_prompt_mcp_reconnect_restart_choice_clears_splash_prompts(
        self,
    ) -> None:
        """Choosing `reconnect` clears stale login/reconnect splash counters."""
        from deepagents_code.mcp_tools import MCPServerInfo
        from deepagents_code.widgets.welcome import WelcomeBanner

        app = DeepAgentsApp(
            agent=MagicMock(),
            mcp_server_info=[
                MCPServerInfo(
                    name="github",
                    transport="http",
                    status="awaiting_reconnect",
                    error="Authenticated — run `/mcp reconnect` to load tools.",
                ),
                MCPServerInfo(
                    name="notion",
                    transport="http",
                    status="unauthenticated",
                    error="needs re-authentication",
                ),
            ],
        )
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True

            banner = MagicMock(spec=WelcomeBanner)

            def _push_screen(_screen: object, callback: Any) -> None:  # noqa: ANN401  # callback signature matches Textual's variant
                callback("reconnect")

            with (
                patch.object(app, "push_screen", side_effect=_push_screen),
                patch.object(app, "query_one", return_value=banner),
                patch.object(app, "_restart_server_for_mcp_refresh", new=AsyncMock()),
            ):
                await app._prompt_mcp_reconnect("notion")

            assert app._mcp_unauthenticated == 0
            assert app._mcp_awaiting_reconnect == 0
            banner.set_connected.assert_called_once_with(
                0,
                mcp_unauthenticated=0,
                mcp_errored=0,
                mcp_awaiting_reconnect=0,
            )

    async def test_prompt_mcp_reconnect_later_choice_defers(self) -> None:
        """Choosing `later` marks the reconnect pending and reopens the switcher.

        The viewer is the obvious launchpad for the next login, so deferring
        routes the user back there instead of dropping them at the chat input.
        """
        from deepagents_code.mcp_tools import MCPServerInfo

        app = DeepAgentsApp(
            agent=MagicMock(),
            mcp_server_info=[
                MCPServerInfo(
                    name="notion",
                    transport="http",
                    status="unauthenticated",
                    error="needs re-authentication",
                )
            ],
        )
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._pending_mcp_reconnect is False

            def _push_screen(_screen: object, callback: Any) -> None:  # noqa: ANN401  # callback signature matches Textual's variant
                callback("later")

            with (
                patch.object(app, "push_screen", side_effect=_push_screen),
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(app, "notify") as notify,
                patch.object(app, "_show_mcp_viewer", new=AsyncMock()) as show_viewer,
            ):
                await app._prompt_mcp_reconnect("notion")

            restart.assert_not_called()
            assert app._pending_mcp_reconnect is True
            assert app._mcp_server_info is not None
            assert app._mcp_server_info[0].status == "awaiting_reconnect"
            notify.assert_called_once()
            message = notify.call_args.args[0]
            assert "notion" in message
            assert "/mcp reconnect" in message
            show_viewer.assert_awaited_once()

    async def test_mcp_reconnect_subcommand_restarts_when_pending(self) -> None:
        """`/mcp reconnect` triggers a restart when a login is pending."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True
            with patch.object(
                app, "_restart_server_for_mcp_refresh", new=AsyncMock()
            ) as restart:
                await app._handle_command("/mcp reconnect")
                await pilot.pause()
            restart.assert_awaited_once()

    async def test_mcp_reconnect_subcommand_noop_when_not_pending(self) -> None:
        """`/mcp reconnect` surfaces a notice and does nothing when idle."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._pending_mcp_reconnect is False
            with patch.object(
                app, "_restart_server_for_mcp_refresh", new=AsyncMock()
            ) as restart:
                await app._handle_command("/mcp reconnect")
                await pilot.pause()
            restart.assert_not_called()
            # The notice must not claim the tool set is fresh — pending
            # state is session-scoped, so prior-run logins still need a
            # full relaunch.
            assert any(
                "No MCP login is queued" in str(w._content)
                and "relaunch" in str(w._content)
                for w in app.query(AppMessage)
            )

    async def test_mcp_reconnect_force_confirm_restarts(self) -> None:
        """`/mcp reconnect force` restarts after the confirm modal accepts."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            assert app._pending_mcp_reconnect is False
            with (
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(
                    app, "_push_screen_wait", new=AsyncMock(return_value=True)
                ),
            ):
                await app._handle_command("/mcp reconnect force")
                await pilot.pause()
            restart.assert_awaited_once_with("forced reconnect")

    async def test_mcp_reconnect_force_cancel_skips_restart(self) -> None:
        """`/mcp reconnect force` does nothing when the confirm modal is cancelled."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(
                    app, "_push_screen_wait", new=AsyncMock(return_value=False)
                ),
            ):
                await app._handle_command("/mcp reconnect force")
                await pilot.pause()
            restart.assert_not_called()

    async def test_mcp_reconnect_force_skips_confirm_when_pending(self) -> None:
        """`/mcp reconnect force` restarts directly when a login is queued."""
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True
            with (
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(app, "_push_screen_wait", new=AsyncMock()) as push_screen,
            ):
                await app._handle_command("/mcp reconnect force")
                await pilot.pause()
            restart.assert_awaited_once_with("pending login")
            push_screen.assert_not_called()

    async def test_mcp_reconnect_invalid_arg_surfaces_usage(self) -> None:
        """Invalid args route to the usage notice without invoking the handler.

        Pure parser coverage lives in `TestParseReconnectArgs`; this test
        only guards the wire from invalid parse → usage message.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(
                app, "_handle_mcp_reconnect_command", new=AsyncMock()
            ) as handler:
                await app._handle_command("/mcp reconnect force extra")
                await pilot.pause()
            handler.assert_not_called()
            assert any(
                "Usage: /mcp reconnect" in str(w._content)
                for w in app.query(AppMessage)
            )

    async def test_viewer_ctrl_r_routes_to_reconnect_handler(self) -> None:
        """End-to-end: Ctrl+R in the viewer triggers the restart path.

        Guards the wire from `MCPViewerScreen` (which dismisses with
        `MCP_VIEWER_RECONNECT_REQUEST`) through `_show_mcp_viewer`'s
        `handle_result` callback and the deferred
        `_reconnect_from_viewer_safe` coroutine. A regression that
        changes the sentinel string or drops the `call_later` would
        show up here even though the unit tests pass.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True
            with patch.object(
                app, "_restart_server_for_mcp_refresh", new=AsyncMock()
            ) as restart:
                await app._show_mcp_viewer()
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause()
            restart.assert_awaited_once_with("pending login")

    async def test_prompt_mcp_reconnect_pilot_driven_happy_path(self) -> None:
        """End-to-end: real modal mounts, `enter` keypress, restart fires.

        Guards the wiring between `_prompt_mcp_reconnect`, the
        `MCPReconnectPromptScreen`, and `_restart_server_for_mcp_refresh`
        that the patch-based tests can't catch (they bypass the modal).
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(
                app, "_restart_server_for_mcp_refresh", new=AsyncMock()
            ) as restart:
                prompt_task = asyncio.create_task(app._prompt_mcp_reconnect("notion"))
                await pilot.pause()
                await pilot.press("enter")
                await pilot.pause()
                await prompt_task
            restart.assert_awaited_once_with("notion")

    async def test_prompt_mcp_reconnect_pilot_driven_escape_reopens_viewer(
        self,
    ) -> None:
        """End-to-end: real Esc against `DeepAgentsApp` defers and reopens.

        Guards the full chain that patch-based tests bypass:
        `DeepAgentsApp.action_interrupt` (priority Esc binding) →
        dispatch to `MCPReconnectPromptScreen.action_cancel` →
        `dismiss("later")` → `_show_mcp_viewer`. A regression in any
        link — removing `action_cancel`, the app routing the modal
        differently, or the navigation never wiring up — fails here.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with patch.object(app, "_show_mcp_viewer", new=AsyncMock()) as show_viewer:
                prompt_task = asyncio.create_task(
                    app._prompt_mcp_reconnect("notion"),
                )
                await pilot.pause()
                await pilot.press("escape")
                await pilot.pause()
                await prompt_task
            assert app._pending_mcp_reconnect is True
            show_viewer.assert_awaited_once()

    async def test_prompt_mcp_reconnect_none_dismiss_silent(self) -> None:
        """A `None` dismiss (programmatic) defers without a user notice.

        The user didn't pick `later`, so we don't claim they did; the
        token is still on disk, so we keep the pending flag set.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()

            def _push_screen(_screen: object, callback: Any) -> None:  # noqa: ANN401  # callback signature matches Textual's variant
                callback(None)

            with (
                patch.object(app, "push_screen", side_effect=_push_screen),
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(app, "notify") as notify,
                patch.object(app, "_show_mcp_viewer", new=AsyncMock()) as show_viewer,
            ):
                await app._prompt_mcp_reconnect("notion")

            restart.assert_not_called()
            assert app._pending_mcp_reconnect is True
            notify.assert_not_called()
            # `None` is not an explicit user choice — don't navigate.
            show_viewer.assert_not_called()

    async def test_prompt_mcp_reconnect_push_screen_failure_defers(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """`push_screen` raising falls back to defer so the login isn't lost.

        The fallback also routes through the new "reopen viewer" path
        (since the fallback sets `choice = "later"`), which tries to push
        the viewer and hits the same patched failure. The outer try/except
        around `_show_mcp_viewer` must swallow that secondary failure and
        log it — anything else would crash the worker after the token
        has already been persisted to disk.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            with (
                patch.object(
                    app,
                    "push_screen",
                    side_effect=RuntimeError("modal mount failed"),
                ),
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(app, "notify") as notify,
                caplog.at_level("ERROR", logger="deepagents_code.app"),
            ):
                await app._prompt_mcp_reconnect("notion")
            restart.assert_not_called()
            assert app._pending_mcp_reconnect is True
            assert any(
                "Failed to reopen MCP viewer" in record.getMessage()
                for record in caplog.records
            )
            # Both notifies fire: the "logged in / run /mcp reconnect"
            # primary and the secondary "couldn't reopen the MCP viewer"
            # warning that closes the silent-failure gap.
            messages = [call.args[0] for call in notify.call_args_list]
            assert any("Run `/mcp reconnect`" in m for m in messages)
            assert any("Couldn't reopen the MCP viewer" in m for m in messages)

    async def test_pending_reconnect_coalesces_multiple_defers(self) -> None:
        """Two deferred logins resolve via a single `/mcp reconnect`.

        This is the PR's core motivation: back-to-back auths shouldn't
        each pay a restart cost. After both defers, exactly one restart
        fires when the user runs `/mcp reconnect`.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()

            def _push_screen(_screen: object, callback: Any) -> None:  # noqa: ANN401  # callback signature matches Textual's variant
                callback("later")

            with (
                patch.object(app, "push_screen", side_effect=_push_screen),
                patch.object(app, "notify"),
                patch.object(
                    app, "_restart_server_for_mcp_refresh", new=AsyncMock()
                ) as restart,
                patch.object(app, "_show_mcp_viewer", new=AsyncMock()),
            ):
                await app._prompt_mcp_reconnect("notion")
                await app._prompt_mcp_reconnect("github")
                assert app._pending_mcp_reconnect is True
                restart.assert_not_called()

                await app._handle_command("/mcp reconnect")
                await pilot.pause()
            restart.assert_awaited_once()

    async def test_restart_for_mcp_refresh_clears_pending_flag(self) -> None:
        """`_restart_server_for_mcp_refresh` clears `_pending_mcp_reconnect`.

        Guards the flag reset against accidental removal: without it,
        `/mcp reconnect` would keep claiming a login is queued after
        every restart.
        """
        app = DeepAgentsApp(agent=MagicMock())
        async with app.run_test() as pilot:
            await pilot.pause()
            app._pending_mcp_reconnect = True
            # Force the early-return branch: no server proc means the
            # method bails before touching restart state but should
            # still clear the flag so we don't get stuck.
            assert app._server_proc is None
            await app._restart_server_for_mcp_refresh("notion")
            assert app._pending_mcp_reconnect is False


class TestParseReconnectArgs:
    """Pure-function tests for `/mcp reconnect` argument parsing."""

    @pytest.mark.parametrize("token", ["force", "--force", "-f", "FORCE", "Force"])
    def test_force_spellings(self, token: str) -> None:
        """All accepted spellings and case variants set `force=True`."""
        from deepagents_code.app import _parse_reconnect_args

        assert _parse_reconnect_args(token) == (True, True)

    def test_empty_is_valid_no_force(self) -> None:
        """No args is the idempotent path."""
        from deepagents_code.app import _parse_reconnect_args

        assert _parse_reconnect_args("") == (False, True)

    @pytest.mark.parametrize(
        "args",
        ["bogus", "force extra", "--force trailing", "force-stop"],
    )
    def test_invalid_args_reject(self, args: str) -> None:
        """Unknown tokens and trailing-token-after-force route to usage."""
        from deepagents_code.app import _parse_reconnect_args

        assert _parse_reconnect_args(args) == (False, False)


class TestRestartCommand:
    """`/restart` slash command — config reload + server respawn."""

    async def test_remote_server_mode_short_circuits(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When the app does not own a server, /restart must not attempt one."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = None
            app._server_kwargs = None

            called = False

            async def _fail() -> None:  # noqa: RUF029  # awaited by handler
                nonlocal called
                called = True

            monkeypatch.setattr(app, "_restart_server_manual", _fail)

            await app._handle_command("/restart")
            await pilot.pause()

            assert called is False
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any("Cannot restart" in m for m in app_msgs)

    async def test_server_still_starting_does_not_claim_remote(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`/restart` during startup must not show the remote-server message.

        We own a server (`_server_kwargs` set) but `_server_proc` is `None`
        while the startup worker is still obtaining it. The handler should
        report "still starting" rather than the misleading "connected to a
        remote LangGraph server".
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = None
            app._server_kwargs = {}
            app._server_startup_deferred = False
            app._connecting = True

            called = False

            async def _fail() -> None:  # noqa: RUF029  # awaited by handler
                nonlocal called
                called = True

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", list)
            monkeypatch.setattr(
                "deepagents_code.model_config.clear_caches", lambda: None
            )
            monkeypatch.setattr(app, "_restart_server_manual", _fail)

            await app._handle_command("/restart")
            await pilot.pause()

            assert called is False
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any("still starting" in m for m in app_msgs)
            assert not any("waiting for a model" in m for m in app_msgs)
            assert not any("remote LangGraph server" in m for m in app_msgs)

    async def test_failed_startup_does_not_claim_still_starting(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`/restart` after failed startup must not imply work is still pending."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = None
            app._server_kwargs = {}
            app._server_startup_deferred = False
            app._connecting = False
            app._server_startup_error = "ModelConfigError: missing API key"

            called = False

            async def _fail() -> None:  # noqa: RUF029  # awaited by handler
                nonlocal called
                called = True

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", list)
            monkeypatch.setattr(
                "deepagents_code.model_config.clear_caches", lambda: None
            )
            monkeypatch.setattr(app, "_restart_server_manual", _fail)

            await app._handle_command("/restart")
            await pilot.pause()

            assert called is False
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any("server did not finish starting" in m for m in app_msgs)
            assert any(
                "Last error: ModelConfigError: missing API key" in m for m in app_msgs
            )
            assert not any("still starting" in m for m in app_msgs)
            assert not any("waiting for a model" in m for m in app_msgs)
            assert not any("remote LangGraph server" in m for m in app_msgs)

    async def test_deferred_startup_reports_waiting_for_model(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`/restart` while startup is deferred guides the user to `/auth`/`/model`."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = None
            app._server_kwargs = {}
            app._server_startup_deferred = True

            called = False

            async def _fail() -> None:  # noqa: RUF029  # awaited by handler
                nonlocal called
                called = True

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", list)
            monkeypatch.setattr(
                "deepagents_code.model_config.clear_caches", lambda: None
            )
            monkeypatch.setattr(app, "_restart_server_manual", _fail)

            await app._handle_command("/restart")
            await pilot.pause()

            assert called is False
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any("waiting for a model" in m for m in app_msgs)
            assert not any("still starting" in m for m in app_msgs)
            assert not any("remote LangGraph server" in m for m in app_msgs)

    async def test_calls_server_restart_and_clears_caches(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Happy path: reload + clear caches + respawn the subprocess."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            reload_called = False
            clear_called = False
            restart_called = False

            def _reload() -> list[str]:
                nonlocal reload_called
                reload_called = True
                return []

            def _clear() -> None:
                nonlocal clear_called
                clear_called = True

            async def _fake_restart() -> bool:  # noqa: RUF029  # awaited by handler
                nonlocal restart_called
                restart_called = True
                return True

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", _reload)
            monkeypatch.setattr("deepagents_code.model_config.clear_caches", _clear)
            monkeypatch.setattr(app, "_restart_server_manual", _fake_restart)

            await app._handle_command("/restart")
            await pilot.pause()

            assert reload_called
            assert clear_called
            assert restart_called
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            # The transient "Restarting server..." status is removed once the
            # restart succeeds; only the completion banner remains.
            assert not any("Restarting server" in m for m in app_msgs)
            assert any("Restart complete" in m for m in app_msgs)

    async def test_failed_restart_removes_transient_and_suppresses_completion(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A failed restart removes "Restarting..." without showing completion.

        Guards the conditional gate in `_handle_restart_command`: the success
        banner is only mounted when `_restart_server_manual()` returns `True`.
        On failure the recovery UI (via `ServerStartFailed`) is the user's
        feedback, so stale transient progress and the misleading completion
        banner must stay suppressed.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            restart_called = False

            def _reload() -> list[str]:
                return []

            async def _fake_restart() -> bool:  # noqa: RUF029  # awaited by handler
                nonlocal restart_called
                restart_called = True
                return False

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", _reload)
            monkeypatch.setattr(
                "deepagents_code.model_config.clear_caches", lambda: None
            )
            monkeypatch.setattr(app, "_restart_server_manual", _fake_restart)

            await app._handle_command("/restart")
            await pilot.pause()

            assert restart_called
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert not any("Restarting server" in m for m in app_msgs)
            assert not any("Restart complete" in m for m in app_msgs)

    async def test_raising_restart_removes_transient_and_propagates(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A raising `_restart_server_manual()` clears the transient, then raises.

        The transient "Restarting server..." status is mounted before
        `_restart_server_manual()` is awaited, so the `try/finally` in
        `_handle_restart_command` exists solely to remove it when the restart
        raises (not merely returns `False`). On a raise the transient must be
        gone, the misleading completion banner must never mount, and the
        exception must propagate rather than be swallowed.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            restart_called = False

            async def _boom() -> bool:  # noqa: RUF029  # awaited by handler
                nonlocal restart_called
                restart_called = True
                msg = "respawn exploded"
                raise RuntimeError(msg)

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", list)
            monkeypatch.setattr(
                "deepagents_code.model_config.clear_caches", lambda: None
            )
            monkeypatch.setattr(app, "_restart_server_manual", _boom)

            with pytest.raises(RuntimeError, match="respawn exploded"):
                await app._handle_restart_command("/restart")
            await pilot.pause()

            assert restart_called
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert not any("Restarting server" in m for m in app_msgs)
            assert not any("Restart complete" in m for m in app_msgs)

    async def test_reload_failure_skips_restart(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A reload error must not proceed to server.restart()."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            def _boom() -> list[str]:
                msg = "bad .env"
                raise OSError(msg)

            restart_called = False

            async def _fake_restart() -> None:  # noqa: RUF029  # awaited by handler
                nonlocal restart_called
                restart_called = True

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", _boom)
            monkeypatch.setattr(app, "_restart_server_manual", _fake_restart)

            await app._handle_command("/restart")
            await pilot.pause()

            assert restart_called is False
            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any("Failed to reload configuration" in m for m in app_msgs)

    async def test_reload_keyerror_does_not_crash(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Widened catch handles non-OSError/ValueError reload failures."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            def _boom() -> list[str]:
                msg = "missing config key"
                raise KeyError(msg)

            async def _noop_restart() -> None:  # noqa: RUF029  # awaited by handler
                return

            from deepagents_code.config import settings

            monkeypatch.setattr(settings, "reload_from_environment", _boom)
            monkeypatch.setattr(app, "_restart_server_manual", _noop_restart)

            await app._handle_command("/restart")
            await pilot.pause()

            app_msgs = [str(w._content) for w in app.query(AppMessage)]
            assert any(
                "Failed to reload configuration" in m and "KeyError" in m
                for m in app_msgs
            )

    async def test_cancels_inflight_worker_before_respawn(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Active agent worker is cancelled and queue drained before respawn."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}

            worker = MagicMock()
            app._agent_worker = worker
            app._agent_running = True
            app._pending_messages.append(QueuedMessage(text="hi", mode="normal"))

            from deepagents_code.config import settings

            def _reload() -> list[str]:
                return []

            def _clear() -> None:
                return

            monkeypatch.setattr(settings, "reload_from_environment", _reload)
            monkeypatch.setattr("deepagents_code.model_config.clear_caches", _clear)

            async def _noop_restart() -> None:  # noqa: RUF029  # awaited by handler
                return

            monkeypatch.setattr(app, "_restart_server_manual", _noop_restart)

            await app._handle_command("/restart")
            await pilot.pause()

            worker.cancel.assert_called_once()
            assert app._agent_running is False
            assert len(app._pending_messages) == 0

    async def test_case_insensitive_bypass_when_busy(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """/RESTART (any case, with whitespace) bypasses the queue while busy."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = MagicMock()
            app._server_kwargs = {}
            app._agent_running = True

            handled: list[str] = []

            async def _capture(value: str, mode: str) -> None:  # noqa: RUF029
                handled.append(f"{mode}:{value}")

            monkeypatch.setattr(app, "_process_message", _capture)

            await app._submit_input("  /RESTART ", mode="command")
            await pilot.pause()

            assert handled == ["command:  /RESTART "]
            assert len(app._pending_messages) == 0


class TestRespawnServer:
    """Direct coverage of `_respawn_server` — invoked via `_restart_server_manual`."""

    async def _prepare(self, app: DeepAgentsApp) -> MagicMock:
        """Wire up the minimal preconditions for `_respawn_server` to run."""
        proc = MagicMock()
        proc.url = "http://localhost:0/"
        proc.restart = AsyncMock()
        app._server_proc = proc
        app._server_kwargs = {}
        app._mcp_preload_kwargs = {}
        return proc

    async def test_happy_path_posts_server_ready(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Successful restart + preload posts `ServerReady` with mcp_info."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            proc = await self._prepare(app)

            async def _preload(**_: Any) -> list[str]:  # noqa: RUF029
                return ["info"]

            monkeypatch.setattr(
                "deepagents_code.main._preload_session_mcp_server_info",
                _preload,
            )

            posted: list[Any] = []
            monkeypatch.setattr(app, "post_message", posted.append)

            result = await app._restart_server_manual()

            assert result is True
            proc.restart.assert_awaited_once()
            ready = [m for m in posted if isinstance(m, app.ServerReady)]
            assert len(ready) == 1
            assert ready[0].mcp_server_info == ["info"]
            assert app._connecting is False or app._agent is not None

    async def test_subprocess_failure_posts_server_start_failed(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`server_proc.restart()` raising surfaces as `ServerStartFailed`."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            proc = await self._prepare(app)
            proc.restart = AsyncMock(side_effect=RuntimeError("boom"))

            posted: list[Any] = []
            monkeypatch.setattr(app, "post_message", posted.append)

            result = await app._restart_server_manual()

            assert result is False
            failed = [m for m in posted if isinstance(m, app.ServerStartFailed)]
            assert len(failed) == 1
            assert isinstance(failed[0].error, RuntimeError)
            assert app._connecting is False
            assert app._reconnecting is False

    async def test_subprocess_timeout_posts_server_start_failed(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A hung `server_proc.restart()` times out and surfaces as failure."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            proc = await self._prepare(app)

            async def _hang() -> None:
                await asyncio.sleep(10)

            proc.restart = _hang

            posted: list[Any] = []
            monkeypatch.setattr(app, "post_message", posted.append)

            result = await app._respawn_server(
                log_message="restart timed out",
                mcp_failure_log="",
                mcp_failure_toast="",
                restart_timeout=0.01,
            )

            assert result is False
            failed = [m for m in posted if isinstance(m, app.ServerStartFailed)]
            assert len(failed) == 1
            assert isinstance(failed[0].error, asyncio.TimeoutError)
            assert app._connecting is False
            assert app._reconnecting is False

    async def test_mcp_preload_failure_is_non_fatal(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """MCP preload error logs + toasts but `ServerReady` still posts."""
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            proc = await self._prepare(app)

            async def _preload(**_: Any) -> list[str]:  # noqa: RUF029  # awaited by helper
                msg = "mcp wedged"
                raise RuntimeError(msg)

            monkeypatch.setattr(
                "deepagents_code.main._preload_session_mcp_server_info",
                _preload,
            )

            posted: list[Any] = []
            monkeypatch.setattr(app, "post_message", posted.append)

            result = await app._restart_server_manual()

            # A non-fatal MCP preload error still counts as a successful
            # restart — the server came up; only metadata refresh degraded.
            assert result is True
            proc.restart.assert_awaited_once()
            ready = [m for m in posted if isinstance(m, app.ServerReady)]
            assert len(ready) == 1
            assert ready[0].mcp_server_info is None

    async def test_no_owned_subprocess_returns_false(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Returns `False` without side effects when there is no owned server.

        Defensive backstop: callers guard this upstream, but `_respawn_server`
        must still report failure (and post nothing) when invoked with no
        subprocess to restart.
        """
        app = DeepAgentsApp()
        async with app.run_test() as pilot:
            await pilot.pause()

            app._server_proc = None
            app._server_kwargs = None

            posted: list[Any] = []
            monkeypatch.setattr(app, "post_message", posted.append)

            result = await app._respawn_server(
                log_message="",
                mcp_failure_log="",
                mcp_failure_toast="",
            )

            assert result is False
            assert not any(
                isinstance(m, (app.ServerReady, app.ServerStartFailed)) for m in posted
            )


class TestCanBypassQueue:
    """Tests for `_can_bypass_queue`, focused on failed-startup recovery.

    A failed model build (e.g. a missing provider package) leaves the app in a
    `_server_startup_error` state that parks queued messages until a successful
    start drains them. Recovery commands like `/install` must escape that wedge
    so the user can repair the session, but only while nothing is running.
    """

    def _app(self) -> DeepAgentsApp:
        return DeepAgentsApp(thread_id="thread-bypass")

    def test_install_bypasses_when_startup_failed(self) -> None:
        app = self._app()
        app._server_startup_error = "Missing package for provider 'baseten'."
        app._agent_running = False
        app._shell_running = False
        assert app._can_bypass_queue("/install baseten") is True

    def test_reload_and_update_bypass_when_startup_failed(self) -> None:
        app = self._app()
        app._server_startup_error = "boom"
        assert app._can_bypass_queue("/reload") is True
        assert app._can_bypass_queue("/update") is True

    def test_install_does_not_bypass_without_startup_error(self) -> None:
        # Normal idle: `/install` is QUEUED-tier and only earns the recovery
        # exemption when startup has actually failed.
        app = self._app()
        app._server_startup_error = None
        assert app._can_bypass_queue("/install baseten") is False

    def test_install_does_not_bypass_while_agent_running(self) -> None:
        # Reinstalling the tool mid-turn could swap the running binary, so the
        # recovery bypass is gated on no active work even when an error is set.
        app = self._app()
        app._server_startup_error = "boom"
        app._agent_running = True
        assert app._can_bypass_queue("/install baseten") is False

    def test_install_does_not_bypass_while_shell_running(self) -> None:
        app = self._app()
        app._server_startup_error = "boom"
        app._shell_running = True
        assert app._can_bypass_queue("/install baseten") is False

    def test_non_recovery_queued_command_stays_queued_on_failure(self) -> None:
        # The exemption is allowlisted; an unrelated QUEUED command (`/clear`)
        # must not ride along into the failed-startup bypass.
        app = self._app()
        app._server_startup_error = "boom"
        assert app._can_bypass_queue("/clear") is False

    def test_install_bypasses_even_during_startup_sequence(self) -> None:
        # `_startup_sequence_running` and `_connecting` intentionally do NOT
        # block recovery — only active agent/shell work does. This pins that
        # decision so a future "tightening" of the guard can't silently
        # re-wedge the recovery path.
        app = self._app()
        app._server_startup_error = "boom"
        app._startup_sequence_running = True
        app._connecting = True
        assert app._can_bypass_queue("/install baseten") is True

    async def test_submit_input_processes_install_after_failed_startup(self) -> None:
        # End-to-end: the fix is only real if `_submit_input` actually routes
        # `/install` to processing instead of the pending queue. The predicate
        # tests above don't catch a regression that unwires `_can_bypass_queue`
        # from the queue decision — this does.
        app = self._app()
        app._server_startup_error = "Missing package for provider 'baseten'."
        processed: list[tuple[str, str]] = []

        async def _process(value: str, mode: str) -> None:  # noqa: RUF029  # replaces an awaited coroutine method; must stay async
            processed.append((value, mode))

        app._process_message = _process  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        await app._submit_input("/install baseten", "command")

        assert processed == [("/install baseten", "command")]
        assert not app._pending_messages

    async def test_submit_input_queues_non_recovery_after_failed_startup(self) -> None:
        # The mirror of the above: a non-recovery command still parks in the
        # queue during the failed-startup state instead of being processed.
        app = self._app()
        app._server_startup_error = "boom"
        processed: list[tuple[str, str]] = []

        async def _process(value: str, mode: str) -> None:  # noqa: RUF029  # replaces an awaited coroutine method; must stay async
            processed.append((value, mode))

        app._process_message = _process  # ty: ignore
        app._mount_message = AsyncMock()  # ty: ignore

        await app._submit_input("/clear", "command")

        assert processed == []
        assert len(app._pending_messages) == 1
        assert app._pending_messages[0].text == "/clear"


class TestStatusBarConnectionMirroring:
    """The bottom status bar must mirror the connection + queue state.

    The welcome banner intentionally stays out of connection progress, so
    these tests pin the always-visible status bar as the single owner.
    """

    async def test_mount_syncs_existing_connecting_state(self) -> None:
        """Initial startup defers the status-bar connection indicator."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            assert app._status_bar is not None
            assert app._status_bar.connection_state == ""
            assert app._connection_status_reveal_timer is not None
            app._cancel_connection_status_reveal_timer()

    async def test_reveal_syncs_existing_connecting_state(self) -> None:
        """The deferred reveal shows the current connection state on the bar."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            app._reveal_connection_status()

            assert app._status_bar is not None
            assert app._status_bar.connection_state == "connecting"
            assert app._connection_status_reveal_timer is None

    async def test_connect_cancels_deferred_reveal_timer(self) -> None:
        """Connecting resolving should clear deferral and cancel the timer."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            assert app._connection_status_reveal_timer is not None
            app._connecting = False
            app._sync_status_connection()

            assert app._status_bar is not None
            assert app._status_bar.connection_state == ""
            assert app._defer_connection_status_display is False
            assert app._connection_status_reveal_timer is None

    async def test_reveal_syncs_reconnecting_state(self) -> None:
        """Deferred reveal should preserve reconnect labeling."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = True
        app._defer_connection_status_display = True

        async with app.run_test():
            app._reveal_connection_status()

            assert app._status_bar is not None
            assert app._status_bar.connection_state == "reconnecting"
            assert app._connection_status_reveal_timer is None

    async def test_deferred_schedule_is_idempotent(self) -> None:
        """Repeated syncs while deferred should not replace the timer handle."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            timer = app._connection_status_reveal_timer
            assert timer is not None

            app._sync_status_connection()

            assert app._connection_status_reveal_timer is timer
            app._cancel_connection_status_reveal_timer()

    async def test_reveal_timer_callback_clears_handle(self) -> None:
        """The timer callback clears its handle before revealing status."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            assert app._connection_status_reveal_timer is not None

            app._on_connection_status_reveal_timer()

            assert app._status_bar is not None
            assert app._status_bar.connection_state == "connecting"
            assert app._connection_status_reveal_timer is None

    async def test_app_banner_does_not_duplicate_connecting_state(self) -> None:
        """The status bar, not the welcome footer, owns app connection progress."""
        from deepagents_code.widgets.welcome import WelcomeBanner

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test():
            assert app._status_bar is not None
            app._reveal_connection_status()
            assert app._status_bar.connection_state == "connecting"
            banner = app.query_one("#welcome-banner", WelcomeBanner)
            assert "Connecting to server" not in banner._build_banner().plain

    async def test_queued_input_does_not_reveal_banner_connection_footer(self) -> None:
        """Queued input should update the bar without adding a second spinner."""
        from deepagents_code.widgets.welcome import WelcomeBanner

        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False

        async with app.run_test():
            await app._submit_input("queued while connecting", "normal")

            assert app._status_bar is not None
            assert app._status_bar.connection_state == "connecting"
            assert app._status_bar.queued_count == 1
            banner = app.query_one("#welcome-banner", WelcomeBanner)
            assert "Connecting to server" not in banner._build_banner().plain

    async def test_sync_reflects_reconnecting(self) -> None:
        """`_sync_status_connection` should surface a reconnect on the bar."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._sync_status_connection()
            assert app._status_bar is not None
            assert app._status_bar.connection_state == "reconnecting"

    async def test_sync_reflects_connecting(self) -> None:
        """An initial connect (not a reconnect) reads as connecting."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = False
            app._sync_status_connection()
            assert app._status_bar is not None
            assert app._status_bar.connection_state == "connecting"

    async def test_sync_reflects_resuming(self) -> None:
        """A `-r` initial connect reads as resuming, not the generic connecting."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = False
            app._resuming = True
            app._sync_status_connection()
            assert app._status_bar is not None
            assert app._status_bar.connection_state == "resuming"

    async def test_sync_prefers_reconnecting_over_resuming(self) -> None:
        """A reconnect of a resumed thread labels as reconnecting, not resuming.

        Locks the precedence in `_sync_status_connection` (reconnect checked
        before resume) so a future reorder of the branch chain can't surface
        "Resuming" while a reconnect is in flight.
        """
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._resuming = True
            app._sync_status_connection()
            assert app._status_bar is not None
            assert app._status_bar.connection_state == "reconnecting"

    async def test_resume_intent_arms_resuming_flag(self) -> None:
        """A `-r` resume intent with a pending connect should arm `_resuming`."""
        app = DeepAgentsApp(
            thread_id="thread-123",
            resume_thread="thread-123",
            server_kwargs={"assistant_id": "agent", "model_name": None},
        )
        assert app._connecting is True
        assert app._resuming is True

    async def test_no_resume_intent_leaves_resuming_unset(self) -> None:
        """A plain connect (no `-r`) should not arm `_resuming`."""
        app = DeepAgentsApp(
            thread_id="thread-123",
            server_kwargs={"assistant_id": "agent", "model_name": None},
        )
        assert app._connecting is True
        assert app._resuming is False

    async def test_sync_clears_resuming_when_connected(self) -> None:
        """Clearing `_connecting` should also drop the resuming flag."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._resuming = True
            app._sync_status_connection()
            app._connecting = False
            app._sync_status_connection()
            assert app._resuming is False
            assert app._status_bar is not None
            assert app._status_bar.connection_state == ""

    async def test_sync_clears_when_connected(self) -> None:
        """Clearing `_connecting` should empty the connection indicator."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._sync_status_connection()
            app._connecting = False
            app._reconnecting = False
            app._sync_status_connection()
            assert app._status_bar is not None
            assert app._status_bar.connection_state == ""

    async def test_queue_depth_mirrored_on_submit_and_drain(self) -> None:
        """Queuing during a reconnect shows the count; draining clears it."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._sync_status_connection()

            processed: list[tuple[str, str]] = []

            async def _process(value: str, mode: str) -> None:  # noqa: RUF029  # replaces an awaited coroutine method
                processed.append((value, mode))

            app._process_message = _process  # ty: ignore

            await app._submit_input("queued while reconnecting", "normal")
            assert app._status_bar is not None
            assert app._status_bar.queued_count == 1

            app._connecting = False
            await app._process_next_from_queue()
            assert processed == [("queued while reconnecting", "normal")]
            assert app._status_bar.queued_count == 0

    async def test_discard_queue_clears_count(self) -> None:
        """Discarding the queue (ESC-interrupt, force-clear, `/restart`) zeroes it."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._sync_status_connection()

            async def _process(_value: str, _mode: str) -> None:  # noqa: RUF029  # replaces an awaited coroutine method
                return

            app._process_message = _process  # ty: ignore

            await app._submit_input("first", "normal")
            await app._submit_input("second", "normal")
            assert app._status_bar is not None
            assert app._status_bar.queued_count == 2

            app._discard_queue()
            assert not app._pending_messages
            assert app._status_bar.queued_count == 0

    async def test_pop_last_queued_message_updates_count(self) -> None:
        """Retracting the most recent queued message decrements the badge."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        async with app.run_test():
            app._connecting = True
            app._reconnecting = True
            app._sync_status_connection()

            async def _process(_value: str, _mode: str) -> None:  # noqa: RUF029  # replaces an awaited coroutine method
                return

            app._process_message = _process  # ty: ignore

            await app._submit_input("first", "normal")
            await app._submit_input("second", "normal")
            assert app._status_bar is not None
            assert app._status_bar.queued_count == 2

            app._pop_last_queued_message()
            assert len(app._pending_messages) == 1
            assert app._status_bar.queued_count == 1

    async def test_deferred_reveal_timer_fires_after_delay(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The scheduled timer actually reveals the indicator once it elapses.

        The other deferral tests invoke the reveal callback directly; this one
        exercises the real `set_timer` wiring end-to-end (shrunk to ~10ms) so a
        broken callback reference wouldn't pass silently.
        """
        monkeypatch.setattr(
            "deepagents_code.app._CONNECTING_STATUS_REVEAL_DELAY_SECONDS", 0.01
        )
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        app._connecting = True
        app._reconnecting = False
        app._defer_connection_status_display = True

        async with app.run_test() as pilot:
            # The ~10ms timer may fire during run_test setup or during the
            # pause below; either way its firing is what reveals the state.
            await pilot.pause(0.05)

            assert app._status_bar is not None
            assert app._status_bar.connection_state == "connecting"
            assert app._connection_status_reveal_timer is None
            assert app._defer_connection_status_display is False


class TestResumeThreadCwdSwitch:
    """Tests for cwd mismatch handling while resuming threads."""

    async def test_offer_switch_changes_process_cwd_and_widgets(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Accepting the prompt switches process and UI cwd before startup."""
        from deepagents_code.config import settings

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="thread-1", cwd=current)
        push_wait = AsyncMock(return_value="switch")
        app._push_screen_wait = push_wait  # ty: ignore[invalid-assignment]
        monkeypatch.setattr(
            app,
            "_preview_project_settings_change",
            AsyncMock(return_value=True),
        )
        chat_input = MagicMock()
        app._chat_input = chat_input
        status_bar = MagicMock()
        status_bar.cwd = str(current)
        app._status_bar = status_bar
        reload_calls: list[Path | None] = []

        def reload_from_environment(*, start_path: Path | None = None) -> list[str]:
            reload_calls.append(start_path)
            return ["project_root: old -> new"]

        monkeypatch.setattr(
            settings,
            "reload_from_environment",
            reload_from_environment,
        )

        with (
            patch("deepagents_code.sessions.get_thread_cwd", return_value=str(target)),
            patch("deepagents_code.model_config.clear_caches") as clear_caches,
        ):
            ok = await app._offer_thread_cwd_switch("thread-1", restart_server=False)

        assert ok == "continue"
        assert Path.cwd() == target
        assert app._cwd == str(target)
        chat_input.set_cwd.assert_called_once_with(target)
        assert status_bar.cwd == str(target)
        assert reload_calls == [target]
        clear_caches.assert_called_once_with()
        screen = push_wait.call_args.args[0]
        assert screen._project_settings_change_detected is True

    async def test_offer_switch_preserves_launch_relative_server_paths(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Launch-time cwd switches keep CLI paths rooted at launch cwd."""
        from deepagents_code.config import settings

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="thread-1", cwd=current)
        app._server_kwargs = {
            "assistant_id": "agent",
            "mcp_config_path": "./mcp.json",
            "sandbox_setup": "./setup.sh",
        }
        app._mcp_preload_kwargs = {"mcp_config_path": "./mcp.json"}
        app._push_screen_wait = AsyncMock(return_value="switch")  # ty: ignore[invalid-assignment]
        monkeypatch.setattr(
            app,
            "_preview_project_settings_change",
            AsyncMock(return_value=False),
        )
        monkeypatch.setattr(
            settings,
            "reload_from_environment",
            lambda **_kwargs: [],
        )

        with (
            patch("deepagents_code.sessions.get_thread_cwd", return_value=str(target)),
            patch("deepagents_code.model_config.clear_caches"),
        ):
            ok = await app._offer_thread_cwd_switch("thread-1", restart_server=False)

        assert ok == "continue"
        assert Path.cwd() == target
        assert app._server_kwargs["mcp_config_path"] == str(current / "mcp.json")
        assert app._server_kwargs["sandbox_setup"] == str(current / "setup.sh")
        assert app._mcp_preload_kwargs["mcp_config_path"] == str(current / "mcp.json")

    async def test_live_cwd_switch_rediscovers_skills(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A live cwd switch refreshes the cached skill metadata."""
        from deepagents_code.config import settings

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="thread-1", cwd=current)

        def reload_from_environment(*, start_path: Path | None = None) -> list[str]:
            del start_path
            return []

        monkeypatch.setattr(
            settings,
            "reload_from_environment",
            reload_from_environment,
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            scheduled_groups: list[str | None] = []

            async def discover_skills() -> bool:  # noqa: RUF029
                return True

            def run_worker(work: object, *args: object, **kwargs: object) -> MagicMock:
                del args
                group = kwargs.get("group")
                assert group is None or isinstance(group, str)
                scheduled_groups.append(group)
                if inspect.iscoroutine(work):
                    work.close()
                return MagicMock()

            monkeypatch.setattr(app, "_discover_skills", discover_skills)
            monkeypatch.setattr(app, "run_worker", run_worker)

            with patch("deepagents_code.model_config.clear_caches"):
                app._switch_process_cwd(target)

        assert scheduled_groups == ["startup-skill-discovery"]

    async def test_offer_stay_warns_without_switching(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Declining the prompt leaves cwd unchanged and warns."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="thread-1", cwd=current)
        app._push_screen_wait = AsyncMock(return_value="stay")  # ty: ignore[invalid-assignment]
        notify = MagicMock()
        app.notify = notify  # ty: ignore[invalid-assignment]

        with patch("deepagents_code.sessions.get_thread_cwd", return_value=str(target)):
            ok = await app._offer_thread_cwd_switch("thread-1", restart_server=False)

        assert ok == "continue"
        assert Path.cwd() == current
        assert app._cwd == str(current)
        notify.assert_called_once()
        assert "Cached local context may be stale" in notify.call_args.args[0]

    async def test_no_prompt_when_thread_cwd_matches_current(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Matching cwd resumes without prompting."""
        monkeypatch.chdir(tmp_path)
        app = DeepAgentsApp(thread_id="thread-1", cwd=tmp_path)
        push_wait = AsyncMock(return_value="switch")
        app._push_screen_wait = push_wait  # ty: ignore[invalid-assignment]

        with patch(
            "deepagents_code.sessions.get_thread_cwd", return_value=str(tmp_path)
        ):
            ok = await app._offer_thread_cwd_switch("thread-1", restart_server=False)

        assert ok == "continue"
        push_wait.assert_not_called()

    async def test_resume_prefetch_failure_restores_server_backed_cwd_switch(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Failed `/threads` prefetch restores cwd/server after accepted switch."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="old-thread", cwd=current)
        app._agent = MagicMock()
        app._session_state = TextualSessionState(thread_id="old-thread")
        app._lc_thread_id = "old-thread"
        app._server_kwargs = {"assistant_id": "agent"}
        app._server_proc = MagicMock()
        app._push_screen_wait = AsyncMock(return_value="switch")  # ty: ignore[invalid-assignment]
        app._fetch_thread_history_data = AsyncMock(  # ty: ignore[invalid-assignment]
            side_effect=RuntimeError("history unavailable")
        )
        load_thread_history = AsyncMock()
        app._load_thread_history = load_thread_history  # ty: ignore[invalid-assignment]
        app._mount_message = AsyncMock()  # ty: ignore[invalid-assignment]
        set_spinner = AsyncMock()
        app._set_spinner = set_spinner  # ty: ignore[invalid-assignment]
        app._update_status = MagicMock()  # ty: ignore[invalid-assignment]
        replace_calls: list[Path] = []

        def replace_server(cwd: Path) -> str:
            replace_calls.append(cwd)
            app._switch_process_cwd(cwd)
            return "continue"

        app._replace_server_after_cwd_switch = AsyncMock(  # ty: ignore[invalid-assignment]
            side_effect=replace_server
        )

        with patch("deepagents_code.sessions.get_thread_cwd", return_value=str(target)):
            await app._resume_thread("new-thread")

        assert replace_calls == [target, current]
        assert Path.cwd() == current
        assert app._cwd == str(current)
        assert app._session_state.thread_id == "old-thread"
        assert app._lc_thread_id == "old-thread"
        set_spinner.assert_has_awaits([call("Loading thread"), call(None)])
        load_thread_history.assert_not_awaited()

    # --- _resolve_thread_cwd_mismatch (pure staticmethod) ---

    def test_resolve_relative_path_is_unavailable(self) -> None:
        """A relative stored cwd cannot be honored and is flagged unavailable."""
        status, path = DeepAgentsApp._resolve_thread_cwd_mismatch(
            "relative/dir", "/abs/current"
        )
        assert status == "unavailable"
        assert path is None

    def test_resolve_missing_dir_is_unavailable(self, tmp_path: Path) -> None:
        """An absolute stored cwd that no longer exists is flagged unavailable."""
        missing = tmp_path / "gone"
        status, path = DeepAgentsApp._resolve_thread_cwd_mismatch(
            str(missing), str(tmp_path)
        )
        assert status == "unavailable"
        assert path is None

    def test_resolve_same_dir_is_match(self, tmp_path: Path) -> None:
        """A stored cwd equal to the current cwd is a match (no path)."""
        status, path = DeepAgentsApp._resolve_thread_cwd_mismatch(
            str(tmp_path), str(tmp_path)
        )
        assert status == "match"
        assert path is None

    def test_resolve_different_dir_is_mismatch(self, tmp_path: Path) -> None:
        """A real, different stored cwd is a mismatch and returns the path."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        status, path = DeepAgentsApp._resolve_thread_cwd_mismatch(
            str(target), str(current)
        )
        assert status == "mismatch"
        assert path == target.resolve()

    def test_resolve_symlink_to_current_is_match(self, tmp_path: Path) -> None:
        """A stored cwd that symlinks to the current cwd is not a mismatch."""
        real = tmp_path / "real"
        real.mkdir()
        link = tmp_path / "link"
        link.symlink_to(real)
        status, path = DeepAgentsApp._resolve_thread_cwd_mismatch(str(link), str(real))
        assert status == "match"
        assert path is None

    # --- _unwrap_cwd_switch_server_result (pure staticmethod) ---

    def test_unwrap_passes_through_success_tuple(self) -> None:
        """A non-exception gathered result is returned unchanged."""
        result = (MagicMock(), MagicMock(), None)
        assert DeepAgentsApp._unwrap_cwd_switch_server_result(result) is result

    def test_unwrap_reraises_exception(self) -> None:
        """A gathered `Exception` is re-raised as itself."""
        err = RuntimeError("boom")
        with pytest.raises(RuntimeError, match="boom"):
            DeepAgentsApp._unwrap_cwd_switch_server_result(err)

    def test_unwrap_reraises_cancelled_error(self) -> None:
        """A gathered `CancelledError` surfaces instead of being unpacked."""
        with pytest.raises(asyncio.CancelledError):
            DeepAgentsApp._unwrap_cwd_switch_server_result(asyncio.CancelledError())

    # --- _thread_cwd_mismatch warning branch ---

    async def test_thread_cwd_mismatch_warns_when_unavailable(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A missing stored directory warns and yields no switch target."""
        monkeypatch.chdir(tmp_path)
        app = DeepAgentsApp(thread_id="t", cwd=tmp_path)
        notify = MagicMock()
        app.notify = notify  # ty: ignore[invalid-assignment]
        missing = tmp_path / "gone"

        with patch(
            "deepagents_code.sessions.get_thread_cwd", return_value=str(missing)
        ):
            result = await app._thread_cwd_mismatch("t")

        assert result is None
        notify.assert_called_once()
        assert "is not available" in notify.call_args.args[0]

    # --- _replace_server_after_cwd_switch (server lifecycle + rollback) ---

    def _arm_server_backed_app(
        self,
        app: DeepAgentsApp,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Wire an app with an owned server and stub cwd-refresh side effects."""
        from deepagents_code.config import settings

        monkeypatch.setattr(
            settings,
            "reload_from_environment",
            lambda **_kwargs: [],
        )
        app.notify = MagicMock()  # ty: ignore[invalid-assignment]
        # The app is never mounted, so the welcome-banner lookup must miss.
        app.query_one = MagicMock(side_effect=NoMatches("no banner"))  # ty: ignore[invalid-assignment]

    async def test_replace_server_success_swaps_and_continues(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A successful restart stops the old server and emits ServerReady."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        self._arm_server_backed_app(app, monkeypatch)
        old_server = MagicMock()
        app._server_proc = old_server
        app._server_kwargs = {"assistant_id": "agent"}
        app._mcp_preload_kwargs = None
        status_bar = MagicMock()
        app._status_bar = status_bar
        new_agent = MagicMock()
        new_server = MagicMock()
        ready: list[Any] = []
        app.on_deep_agents_app_server_ready = ready.append  # ty: ignore[invalid-assignment]

        async def fake_start(**_kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
            return new_agent, new_server, None

        with (
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=fake_start,
            ),
            patch("deepagents_code.model_config.clear_caches"),
        ):
            result = await app._replace_server_after_cwd_switch(target)

        assert result == "continue"
        assert Path.cwd() == target
        old_server.stop.assert_called_once_with()
        status_bar.set_connection.assert_called_once_with("reconnecting")
        assert len(ready) == 1
        assert ready[0].agent is new_agent
        assert ready[0].server_proc is new_server

    async def test_replace_server_preserves_launch_relative_paths(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A cwd switch does not re-resolve restart paths in the target cwd."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        self._arm_server_backed_app(app, monkeypatch)
        app._server_proc = MagicMock()
        app._server_kwargs = {
            "assistant_id": "agent",
            "mcp_config_path": "./mcp.json",
            "sandbox_setup": "./setup.sh",
        }
        app._mcp_preload_kwargs = {"mcp_config_path": "./mcp.json"}
        ready: list[Any] = []
        app.on_deep_agents_app_server_ready = ready.append  # ty: ignore[invalid-assignment]

        async def fake_start(**kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
            assert kwargs["mcp_config_path"] == str(current / "mcp.json")
            assert kwargs["sandbox_setup"] == str(current / "setup.sh")
            return MagicMock(), MagicMock(), None

        async def fake_preload(**kwargs: object) -> list[Any]:  # noqa: RUF029
            assert kwargs["mcp_config_path"] == str(current / "mcp.json")
            return []

        with (
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=fake_start,
            ),
            patch(
                "deepagents_code.main._preload_session_mcp_server_info",
                side_effect=fake_preload,
            ),
            patch("deepagents_code.model_config.clear_caches"),
        ):
            result = await app._replace_server_after_cwd_switch(target)

        assert result == "continue"
        assert Path.cwd() == target
        assert app._server_kwargs["mcp_config_path"] == str(current / "mcp.json")
        assert app._server_kwargs["sandbox_setup"] == str(current / "setup.sh")
        assert app._mcp_preload_kwargs["mcp_config_path"] == str(current / "mcp.json")
        assert len(ready) == 1

    async def test_replace_server_failure_rolls_back_and_aborts(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A failed restart restores prior cwd/agent/server and returns abort."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        self._arm_server_backed_app(app, monkeypatch)
        old_server = MagicMock()
        old_agent = MagicMock()
        app._server_proc = old_server
        app._agent = old_agent
        app._server_kwargs = {"assistant_id": "agent"}
        app._mcp_preload_kwargs = None
        app._mcp_server_info = ["prev"]
        status_bar = MagicMock()
        app._status_bar = status_bar

        async def boom(**_kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
            msg = "server failed"
            raise RuntimeError(msg)

        with (
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=boom,
            ),
            patch("deepagents_code.model_config.clear_caches"),
        ):
            result = await app._replace_server_after_cwd_switch(target)

        assert result == "abort"
        assert Path.cwd() == current
        assert app._cwd == str(current)
        assert app._agent is old_agent
        assert app._server_proc is old_server
        assert app._mcp_server_info == ["prev"]
        assert app._connecting is False
        assert app._reconnecting is False
        assert status_bar.set_connection.call_args_list == [
            call("reconnecting"),
            call(""),
        ]
        old_server.stop.assert_not_called()

    async def test_replace_server_failure_rolls_back_project_dotenv(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A failed restart restores previous cwd-scoped dotenv settings."""
        import os

        import deepagents_code.config as config_mod
        from deepagents_code.config import _RELOADABLE_FIELDS, Settings, settings

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        (current / ".env").write_text(
            "DEEPAGENTS_CODE_OPENAI_API_KEY=sk-current\n",
        )
        (target / ".env").write_text(
            "DEEPAGENTS_CODE_OPENAI_API_KEY=sk-target\n",
        )
        monkeypatch.chdir(current)
        monkeypatch.delenv("DEEPAGENTS_CODE_OPENAI_API_KEY", raising=False)
        monkeypatch.setattr(
            config_mod,
            "_GLOBAL_DOTENV_PATH",
            tmp_path / "missing-global.env",
        )
        config_mod._dotenv_loaded_values.clear()
        saved = {field: getattr(settings, field) for field in _RELOADABLE_FIELDS}

        try:
            app = DeepAgentsApp(thread_id="t", cwd=current)
            self._arm_server_backed_app(app, monkeypatch)
            monkeypatch.setattr(
                settings,
                "reload_from_environment",
                Settings.reload_from_environment.__get__(settings, Settings),
            )
            settings.reload_from_environment(start_path=current)
            assert settings.openai_api_key == "sk-current"
            app._server_proc = MagicMock()
            app._agent = MagicMock()
            app._server_kwargs = {"assistant_id": "agent"}
            app._mcp_preload_kwargs = None

            async def boom(**_kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
                msg = "server failed"
                raise RuntimeError(msg)

            with (
                patch(
                    "deepagents_code.server_manager.start_server_and_get_agent",
                    side_effect=boom,
                ),
                patch("deepagents_code.model_config.clear_caches"),
            ):
                result = await app._replace_server_after_cwd_switch(target)

            assert result == "abort"
            assert Path.cwd() == current
            assert settings.openai_api_key == "sk-current"
            assert os.environ["DEEPAGENTS_CODE_OPENAI_API_KEY"] == "sk-current"
        finally:
            for field, value in saved.items():
                setattr(settings, field, value)
            config_mod._dotenv_loaded_values.clear()

    async def test_replace_server_propagates_non_exception_after_rollback(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A non-`Exception` failure rolls back, then propagates (not 'abort')."""

        class _CwdSwitchFatalError(BaseException):
            """Test-only non-`Exception` signal."""

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        self._arm_server_backed_app(app, monkeypatch)
        old_server = MagicMock()
        old_agent = MagicMock()
        app._server_proc = old_server
        app._agent = old_agent
        app._server_kwargs = {"assistant_id": "agent"}
        app._mcp_preload_kwargs = None

        async def fatal(**_kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
            raise _CwdSwitchFatalError

        with (
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=fatal,
            ),
            patch("deepagents_code.model_config.clear_caches"),
            pytest.raises(_CwdSwitchFatalError),
        ):
            await app._replace_server_after_cwd_switch(target)

        assert Path.cwd() == current
        assert app._agent is old_agent
        assert app._server_proc is old_server
        assert app._connecting is False
        old_server.stop.assert_not_called()

    async def test_replace_server_preserves_mcp_info_on_preload_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """An MCP preload failure keeps prior tool metadata, not None."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        self._arm_server_backed_app(app, monkeypatch)
        app._server_proc = MagicMock()
        app._server_kwargs = {"assistant_id": "agent"}
        app._mcp_preload_kwargs = {"mcp_config_path": None}
        app._mcp_server_info = ["prev-mcp"]
        ready: list[Any] = []
        app.on_deep_agents_app_server_ready = ready.append  # ty: ignore[invalid-assignment]

        async def fake_start(**_kwargs: object) -> tuple[Any, Any, None]:  # noqa: RUF029
            return MagicMock(), MagicMock(), None

        async def boom_mcp(**_kwargs: object) -> list[Any]:  # noqa: RUF029
            msg = "mcp down"
            raise RuntimeError(msg)

        with (
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=fake_start,
            ),
            patch(
                "deepagents_code.main._preload_session_mcp_server_info",
                side_effect=boom_mcp,
            ),
            patch("deepagents_code.model_config.clear_caches"),
        ):
            result = await app._replace_server_after_cwd_switch(target)

        assert result == "continue"
        assert len(ready) == 1
        assert ready[0].mcp_server_info == ["prev-mcp"]

    # --- _switch_process_cwd atomicity ---

    async def test_switch_process_cwd_restores_cwd_on_refresh_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A failed project refresh undoes the chdir so cwd state stays consistent.

        Guards the rollback contract: if `self._cwd` and the real process cwd
        diverged here, `_restore_cwd_after_failed_thread_switch` would see a
        false match and silently skip restoring.
        """
        from deepagents_code.config import settings

        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(current)
        app = DeepAgentsApp(thread_id="t", cwd=current)
        app._chat_input = None
        app._status_bar = None

        def boom(*, start_path: Path | None = None) -> list[str]:
            del start_path
            msg = "reload failed"
            raise RuntimeError(msg)

        monkeypatch.setattr(settings, "reload_from_environment", boom)

        with (
            patch("deepagents_code.model_config.clear_caches"),
            pytest.raises(RuntimeError, match="reload failed"),
        ):
            app._switch_process_cwd(target)

        assert Path.cwd() == current
        assert app._cwd == str(current)

    # --- _cwd_paths_equal (pure staticmethod) ---

    def test_cwd_paths_equal_matches_symlinked_paths(self, tmp_path: Path) -> None:
        """Two paths that resolve to the same directory compare equal."""
        real = tmp_path / "real"
        real.mkdir()
        link = tmp_path / "link"
        link.symlink_to(real)
        assert DeepAgentsApp._cwd_paths_equal(str(link), real) is True

    def test_cwd_paths_equal_distinguishes_different_dirs(self, tmp_path: Path) -> None:
        """Genuinely different directories compare unequal."""
        a = tmp_path / "a"
        b = tmp_path / "b"
        a.mkdir()
        b.mkdir()
        assert DeepAgentsApp._cwd_paths_equal(str(a), b) is False

    # --- _restore_cwd_after_failed_thread_switch (branches) ---

    async def test_restore_after_failed_switch_is_noop_when_cwd_unchanged(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A declined switch (cwd never moved) restores nothing -- no server churn."""
        monkeypatch.chdir(tmp_path)
        app = DeepAgentsApp(thread_id="t", cwd=tmp_path)
        app._server_kwargs = {"assistant_id": "agent"}
        app._server_proc = MagicMock()
        replace = AsyncMock()
        app._replace_server_after_cwd_switch = replace  # ty: ignore[invalid-assignment]

        await app._restore_cwd_after_failed_thread_switch(Path(app._cwd))

        replace.assert_not_awaited()

    async def test_restore_after_failed_switch_without_owned_server_switches_back(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Without an owned server, restore changes the process cwd directly."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(target)
        app = DeepAgentsApp(thread_id="t", cwd=target)
        app._server_kwargs = None
        app._server_proc = None
        switch_calls: list[Path] = []
        monkeypatch.setattr(
            app,
            "_switch_process_cwd",
            switch_calls.append,
        )

        await app._restore_cwd_after_failed_thread_switch(current)

        assert switch_calls == [current]

    async def test_restore_after_failed_switch_notifies_on_oserror(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A failed direct cwd restore warns that the directory may be wrong."""
        current = tmp_path / "current"
        target = tmp_path / "target"
        current.mkdir()
        target.mkdir()
        monkeypatch.chdir(target)
        app = DeepAgentsApp(thread_id="t", cwd=target)
        app._server_kwargs = None
        app._server_proc = None
        notify = MagicMock()
        app.notify = notify  # ty: ignore[invalid-assignment]

        def boom(cwd: Path) -> None:
            del cwd
            msg = "cannot chdir"
            raise OSError(msg)

        monkeypatch.setattr(app, "_switch_process_cwd", boom)

        await app._restore_cwd_after_failed_thread_switch(current)

        notify.assert_called_once()
        assert "wrong directory" in notify.call_args.args[0]

    # --- launch-time resume isolates cwd-switch failures ---

    async def test_resolve_resume_isolates_cwd_switch_failure(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A failed cwd-switch offer keeps the resolved thread and warns specifically.

        Regression guard for the headline fix: the cwd-switch offer must not fall
        into the resume-resolution handler, which would discard the already
        resolved thread and report "Could not look up thread history."
        """
        app = DeepAgentsApp(
            agent=MagicMock(),
            assistant_id="agent",
            server_kwargs=None,
            server_proc=None,
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            app._resume_thread_intent = "thread-x"
            notify = MagicMock()
            app.notify = notify  # ty: ignore[invalid-assignment]
            monkeypatch.setattr(
                app,
                "_offer_thread_cwd_switch",
                AsyncMock(side_effect=RuntimeError("offer boom")),
            )
            with (
                patch(
                    "deepagents_code.sessions.thread_exists",
                    AsyncMock(return_value=True),
                ),
                patch(
                    "deepagents_code.sessions.get_thread_agent",
                    AsyncMock(return_value=None),
                ),
            ):
                await app._resolve_resume_thread()

        assert app._lc_thread_id == "thread-x"
        messages = [call.args[0] for call in notify.call_args_list]
        assert any(
            "could not check its working directory" in message for message in messages
        )
        assert all(
            "Could not look up thread history" not in message for message in messages
        )


class TestEnsureManagedRipgrep:
    """`_ensure_managed_ripgrep` installs the managed `rg` once per session.

    `_start_server_background` must install + prepend `PATH` before the
    langgraph subprocess snapshots `os.environ`; the optional-tools worker
    must reuse that result instead of installing a second time.
    """

    async def test_installs_and_prepends_once(self) -> None:
        """First call installs; second call short-circuits via the event."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        ensure = AsyncMock(return_value=Path("/managed/rg"))
        prepend = MagicMock()
        with (
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            assert await app._ensure_managed_ripgrep() is True
            assert await app._ensure_managed_ripgrep() is True

        ensure.assert_awaited_once()
        prepend.assert_called_once()
        assert app._ripgrep_ensured.is_set()
        assert app._ripgrep_install_failed is False

    async def test_installs_when_ripgrep_warning_is_suppressed(self) -> None:
        """Suppressed warning state must not skip managed `rg` installation."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        ensure = AsyncMock(return_value=Path("/managed/rg"))
        prepend = MagicMock()
        check_optional_tools = MagicMock(return_value=[])
        with (
            patch(
                "deepagents_code.main.check_optional_tools",
                check_optional_tools,
            ),
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            assert await app._ensure_managed_ripgrep() is True

        check_optional_tools.assert_not_called()
        ensure.assert_awaited_once()
        prepend.assert_called_once()

    async def test_skips_when_ripgrep_not_missing(self) -> None:
        """A present system `rg` means no install attempt is made."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        ensure = AsyncMock()
        with (
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=False,
            ),
            patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
        ):
            assert await app._ensure_managed_ripgrep() is True

        ensure.assert_not_awaited()
        assert app._ripgrep_ensured.is_set()
        assert app._ripgrep_install_failed is False

    async def test_failed_install_marks_failure(self) -> None:
        """A failed install records failure so callers still warn."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

        prepend = MagicMock()
        with (
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(return_value=None),
            ),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            assert await app._ensure_managed_ripgrep() is False
            # Second call returns the cached failure without re-installing.
            assert await app._ensure_managed_ripgrep() is False

        prepend.assert_not_called()
        assert app._ripgrep_ensured.is_set()
        assert app._ripgrep_install_failed is True

    async def test_checksum_mismatch_notifies_error(self) -> None:
        """A checksum mismatch surfaces a loud error notice and fails closed."""
        from deepagents_code.managed_tools import ChecksumMismatchError

        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notices: list[dict[str, Any]] = []

        def _capture(message: str, **kwargs: Any) -> None:
            notices.append({"message": message, **kwargs})

        with (
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(side_effect=ChecksumMismatchError("bad")),
            ),
            patch.object(app, "notify", _capture),
        ):
            assert await app._ensure_managed_ripgrep() is False

        assert any(note.get("severity") == "error" for note in notices), notices
        assert app._ripgrep_install_failed is True

    async def test_unexpected_failure_notifies_warning(self) -> None:
        """An unexpected install error warns the user, not just the log.

        Mirrors the headless CLI path so a crashed install is distinguishable
        from an absent tool rather than only logged.
        """
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        notices: list[dict[str, Any]] = []

        def _capture(message: str, **kwargs: Any) -> None:
            notices.append({"message": message, **kwargs})

        with (
            patch(
                "deepagents_code.main._should_ensure_managed_ripgrep",
                return_value=True,
            ),
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(side_effect=RuntimeError("boom")),
            ),
            patch.object(app, "notify", _capture),
        ):
            assert await app._ensure_managed_ripgrep() is False

        assert any(note.get("severity") == "warning" for note in notices), notices
        assert app._ripgrep_install_failed is True

    async def test_start_server_background_ensures_before_spawn(self) -> None:
        """The managed `rg` install must run before the server is spawned."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
        app._model_kwargs = None
        app._server_kwargs = {}
        app._mcp_preload_kwargs = None
        app._resume_thread_intent = None
        app._default_assistant_id = None

        call_order: list[str] = []

        async def record_ensure() -> bool:
            call_order.append("ensure")
            await asyncio.sleep(0)
            return True

        def record_start(**_: object) -> object:
            call_order.append("start_server")
            msg = "stop here"
            raise RuntimeError(msg)

        with (
            patch.object(app, "_ensure_managed_ripgrep", side_effect=record_ensure),
            patch(
                "deepagents_code.server_manager.start_server_and_get_agent",
                side_effect=record_start,
            ),
            patch.object(app, "post_message"),
        ):
            await app._start_server_background()

        assert call_order[:2] == ["ensure", "start_server"], call_order


class TestNotifyInterpreterToolsWithoutInterpreter:
    """Tests for `_notify_interpreter_tools_without_interpreter` (TUI advisory)."""

    def test_toasts_when_tools_set_without_interpreter(self) -> None:
        """`interpreter_ptc` set with the interpreter disabled warns once."""
        app = DeepAgentsApp(
            server_kwargs={
                "assistant_id": "agent",
                "model_name": None,
                "interpreter_ptc": "safe",
                "enable_interpreter": False,
            },
        )
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        app._notify_interpreter_tools_without_interpreter()

        notify_mock.assert_called_once()
        assert (
            "--interpreter-tools has no effect unless --interpreter is set"
            in notify_mock.call_args.args[0]
        )
        assert notify_mock.call_args.kwargs.get("severity") == "warning"
        assert notify_mock.call_args.kwargs.get("markup") is False

    def test_no_toast_when_interpreter_enabled(self) -> None:
        """The allowlist takes effect with `--interpreter`, so no warning."""
        app = DeepAgentsApp(
            server_kwargs={
                "assistant_id": "agent",
                "model_name": None,
                "interpreter_ptc": "safe",
                "enable_interpreter": True,
            },
        )
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        app._notify_interpreter_tools_without_interpreter()

        notify_mock.assert_not_called()

    def test_no_toast_without_interpreter_tools(self) -> None:
        """Absent `interpreter_ptc` does not warn."""
        app = DeepAgentsApp(
            server_kwargs={
                "assistant_id": "agent",
                "model_name": None,
                "interpreter_ptc": None,
                "enable_interpreter": False,
            },
        )
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        app._notify_interpreter_tools_without_interpreter()

        notify_mock.assert_not_called()

    def test_no_toast_when_server_kwargs_absent(self) -> None:
        """An agent-backed app with no `server_kwargs` does not warn."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        app._notify_interpreter_tools_without_interpreter()

        notify_mock.assert_not_called()


class TestNotifyOrphanedTracingDisabled:
    """Tests for `_notify_orphaned_tracing_disabled` (TUI advisory)."""

    def test_toasts_when_notice_pending(self) -> None:
        """A pending notice is surfaced as a warning toast."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        with patch(
            "deepagents_code.config.consume_orphaned_tracing_disabled_notice",
            return_value="tracing disabled, set LANGSMITH_API_KEY",
        ):
            app._notify_orphaned_tracing_disabled()

        notify_mock.assert_called_once()
        assert (
            notify_mock.call_args.args[0] == "tracing disabled, set LANGSMITH_API_KEY"
        )
        assert notify_mock.call_args.kwargs.get("severity") == "warning"
        assert notify_mock.call_args.kwargs.get("markup") is False

    def test_no_toast_when_no_notice(self) -> None:
        """No pending notice means no toast fires."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        notify_mock = MagicMock()
        app.notify = notify_mock  # ty: ignore

        with patch(
            "deepagents_code.config.consume_orphaned_tracing_disabled_notice",
            return_value=None,
        ):
            app._notify_orphaned_tracing_disabled()

        notify_mock.assert_not_called()

    def test_render_failure_is_swallowed_and_logged(self) -> None:
        """A failed toast render is logged rather than escaping the callback."""
        app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
        notify_mock = MagicMock(side_effect=RuntimeError("render boom"))
        app.notify = notify_mock  # ty: ignore

        with (
            patch(
                "deepagents_code.config.consume_orphaned_tracing_disabled_notice",
                return_value="tracing disabled",
            ),
            patch("deepagents_code.app.logger.exception") as log_mock,
        ):
            app._notify_orphaned_tracing_disabled()

        log_mock.assert_called_once()
