"""Unit tests for main entry point."""

import asyncio
import inspect
import os
import sys
from collections.abc import Iterator
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from rich.console import Console

from deepagents_code.app import AppResult, DeepAgentsApp, run_textual_app
from deepagents_code.config import build_langsmith_thread_url, reset_langsmith_url_cache
from deepagents_code.main import (
    _auto_install_ripgrep_cli,
    _is_managed_ripgrep_path,
    _restart_current_process,
    _ripgrep_install_hint,
    _run_startup_auto_update,
    _terminal_row_count,
    build_missing_tool_notification,
    check_optional_tools,
    cli_main,
    format_tool_warning_cli,
    run_textual_cli_async,
)

# Most unit tests set `DEEPAGENTS_CODE_NO_UPDATE_CHECK=1` and patch
# `is_update_check_enabled()` to avoid accidental PyPI/DNS work. This module
# tests startup update behavior itself, so each test must control those values.
pytestmark = pytest.mark.self_managed_update_check


class TestStartupAutoUpdate:
    """Tests for startup auto-update behavior."""

    @pytest.fixture(autouse=True)
    def _no_prerelease_lookup(self) -> Iterator[None]:
        """Stub the pre-release dependency lookup for startup tests.

        The startup auto-update path calls `release_requires_prereleases`
        (e.g. in the restart-loop guard) with `latest`. Unstubbed, that reads
        the real host cache and falls through to a live PyPI request, which is
        non-hermetic and would hit the network under a bare `pytest` run. Pin it
        to `False`; the function's own behavior is covered in `test_update_check`.
        """
        with patch(
            "deepagents_code.update_check.release_requires_prereleases",
            return_value=False,
        ):
            yield

    @pytest.fixture(autouse=True)
    def _ack_auto_update_default(self) -> Iterator[None]:
        """Treat the auto-update default as already acknowledged.

        These tests exercise the install/restart path; the one-time migration
        notice is covered in `TestAutoUpdateDefaultMigration`.
        """
        with patch(
            "deepagents_code.update_check.should_announce_auto_update_default",
            return_value=False,
        ):
            yield

    @pytest.fixture(autouse=True)
    def _no_shadowed_dcode(self) -> Iterator[None]:
        """Default to "no PATH shadow detected" for the success-path tests.

        Without this, every successful-upgrade test would run the real
        `detect_shadowed_dcode` against the host filesystem. That's
        hermetic only by accident — the test runner's editable install
        currently short-circuits at `detect_install_method() != "uv"` — but
        a uv-tool-managed Python or CI image that does match would silently
        re-route every "successful update" test through the new
        `if shadow is not None: return` branch and skip the restart
        assertion. Pin to `None` here so the contract being tested is
        "shadow path is opt-in"; the dedicated shadow-present test below
        patches it explicitly.

        Patches at the source module rather than `deepagents_code.main`
        because `_run_startup_auto_update` lazy-imports it inside the
        function.
        """
        with patch(
            "deepagents_code.update_check.detect_shadowed_dcode",
            return_value=None,
        ):
            yield

    def test_successful_update_restarts_before_launch(self) -> None:
        """A successful startup auto-update should exec a fresh process."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch(
                "deepagents_code.main._restart_current_process",
                side_effect=SystemExit(0),
            ) as restart,
            pytest.raises(SystemExit),
        ):
            _run_startup_auto_update(console)

        upgrade.assert_awaited_once()
        restart.assert_called_once_with()

    def test_successful_update_skips_restart_when_shadowed(self) -> None:
        """Successful upgrade + shadowed dcode must NOT restart into the old binary.

        Regression guard for the critical bug: when a stale `dcode` is
        earlier on PATH than uv's bin dir, re-exec'ing would silently
        re-launch the old version. The pre-launch path must surface a
        warning and return *before* `_restart_current_process` so the user
        sees the message and isn't stranded on the old in-memory version
        with no explanation. Also pins the markup-escape behavior: a path
        containing a Rich-special character must not raise.
        """
        from deepagents_code.update_check import ShadowedDcode

        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))
        # Embed `[` in the shadowing path — legal on POSIX filesystems —
        # so a regression that dropped `escape()` would raise a Rich
        # `MarkupError` here instead of silently emitting broken styling.
        shadow = ShadowedDcode(
            shadowing_bin=Path("/opt/old [legacy]/bin/dcode"),
            upgraded_bin_dir=Path("/home/user/.local/bin"),
        )

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            # Override the autouse `_no_shadowed_dcode` fixture for this
            # single test by re-patching the same name with the positive
            # case. The innermost patch wins, so the autouse fixture's
            # `None` doesn't leak through.
            patch(
                "deepagents_code.update_check.detect_shadowed_dcode",
                return_value=shadow,
            ),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_awaited_once()
        restart.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "Warning:" in printed
        # The path's `[legacy]` segment must be Rich-escaped (`\[legacy]`)
        # before interpolation under `markup=True`; a regression that
        # dropped `escape()` would either raise `MarkupError` (test fails)
        # or render `[legacy]` as a (broken) style tag. Asserting the
        # escaped form pins the fix.
        assert "/opt/old \\[legacy]/bin/dcode" in printed
        assert "/home/user/.local/bin" in printed
        assert "Continuing with v" in printed

    def test_disabled_update_does_not_check_pypi(self) -> None:
        """Disabled auto-update should not perform network or install work."""
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=False,
            ),
            patch("deepagents_code.update_check.get_cached_update_available") as check,
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
        ):
            _run_startup_auto_update(console)

        check.assert_not_called()
        upgrade.assert_not_called()

    def test_disabled_update_check_skips_cached_auto_update(self) -> None:
        """Disabled update checks should block cached startup auto-updates."""
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch("deepagents_code.update_check.get_cached_update_available") as check,
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        check.assert_not_called()
        upgrade.assert_not_called()
        restart.assert_not_called()

    def test_restart_uses_module_entrypoint(self) -> None:
        """Restart should reload package code from the updated environment."""
        with (
            patch.object(sys, "executable", "/tool/bin/python"),
            patch.object(sys, "argv", ["dcode", "--model", "openai:gpt-5.5"]),
            patch("os.execv", side_effect=SystemExit(0)) as execv,
            pytest.raises(SystemExit),
        ):
            _restart_current_process()

        execv.assert_called_once_with(
            "/tool/bin/python",
            ["/tool/bin/python", "-m", "deepagents_code", "--model", "openai:gpt-5.5"],
        )

    def test_failed_update_does_not_restart_and_continues(self) -> None:
        """A failed upgrade must not restart; it surfaces the error and returns."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(False, "pip exploded"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch(
                "deepagents_code.update_check.upgrade_command",
                return_value="uv tool upgrade deepagents-code",
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            # Must not raise: a failed upgrade falls through to launch.
            _run_startup_auto_update(console)

        upgrade.assert_awaited_once()
        restart.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "Auto-update failed" in printed

    def test_editable_install_skips_update(self) -> None:
        """Editable installs must short-circuit before any PyPI/install work."""
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=True),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch("deepagents_code.update_check.get_cached_update_available") as check,
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
        ):
            _run_startup_auto_update(console)

        check.assert_not_called()
        upgrade.assert_not_called()

    def test_no_update_available_returns_early(self) -> None:
        """When already current, nothing is announced, installed, or restarted."""
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(False, None),
            ),
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        console.print.assert_not_called()

    def test_in_session_update_already_installed_skips(self) -> None:
        """An in-session `/update` already on disk must not re-upgrade.

        The cache reports a newer version than the baked-in `__version__`,
        but the on-disk install already satisfies it, so the upgrade and
        restart are skipped silently.
        """
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_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.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        console.print.assert_not_called()

    def test_debug_update_skips_install(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """DEBUG_UPDATE announces the update but skips the actual install."""
        console = MagicMock()
        monkeypatch.setenv("DEEPAGENTS_CODE_DEBUG_UPDATE", "1")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "debug mode" in printed

    def test_unexpected_error_does_not_block_startup(self) -> None:
        """An error in the update machinery must never block launch."""
        console = MagicMock()

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                side_effect=RuntimeError("boom"),
            ),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            # Must swallow the error rather than propagate it.
            _run_startup_auto_update(console)

        restart.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "Auto-update failed before startup" in printed

    def test_restart_loop_guard_skips_repeat_upgrade(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A re-exec that did not change the version must not re-upgrade."""
        console = MagicMock()
        # Simulate the sentinel set by the prior generation before its restart.
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.upgrade_command",
                return_value="uv tool upgrade deepagents-code",
            ),
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        # Sentinel is consumed so a genuine future update is not suppressed.
        assert os.environ.get("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE") is None
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "restart loop" in printed

    def test_restart_failure_after_successful_install_continues(self) -> None:
        """A successful install with a failed re-exec reports an accurate message."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch(
                "deepagents_code.main._restart_current_process",
                side_effect=OSError("exec failed"),
            ) as restart,
        ):
            # Install succeeded; a failed re-exec must not raise or claim the
            # update failed.
            _run_startup_auto_update(console)

        restart.assert_called_once_with()
        # Sentinel is dropped since the restart did not happen.
        assert os.environ.get("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE") is None
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "automatic restart failed" in printed
        assert "Auto-update failed" not in printed

    def test_restart_after_update_confirms_launched(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The re-exec'd process rewrites `Launching...` to `Launched.`."""
        stream = StringIO()
        console = Console(file=stream, force_terminal=True, no_color=True, width=80)
        # The prior generation recorded the version it restarted into.
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(False, "9.9.9"),
            ),
            patch("deepagents_code.update_check.perform_upgrade") as upgrade,
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        # Sentinel is consumed so the confirmation only fires once.
        assert os.environ.get("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE") is None
        # The prior line is erased via a control sequence, then reprinted.
        output = stream.getvalue()
        assert output.count("\x1b[1A") == 1
        assert "Launched." in output
        assert "9.9.9" in output

    def test_update_launch_status_rewrite_handles_narrow_terminal_wrap(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The status rewrite erases every row in narrow terminal panes."""
        stream = StringIO()
        console = Console(file=stream, force_terminal=True, no_color=True, width=10)
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(False, "9.9.9"),
            ),
            patch("deepagents_code.update_check.perform_upgrade"),
            patch("deepagents_code.main._restart_current_process"),
        ):
            _run_startup_auto_update(console)

        launch_rows = _terminal_row_count(console, "Updated to v9.9.9. Launching...")
        output = stream.getvalue()
        assert output.count("\x1b[1A") == launch_rows
        assert "Launched." in output
        assert "9.9.9" in output

    def test_restart_after_update_skips_rewrite_when_not_terminal(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Redirected (non-terminal) output is not polluted with escape codes."""
        stream = StringIO()
        # `force_terminal=False` makes `is_terminal` report False, exactly as a
        # redirected stream (pipe/file) would. Asserting on the real stream
        # proves no escape bytes reach redirected output, end-to-end.
        console = Console(file=stream, force_terminal=False, no_color=True, width=80)
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(False, "9.9.9"),
            ),
            patch("deepagents_code.main._restart_current_process"),
        ):
            _run_startup_auto_update(console)

        output = stream.getvalue()
        assert "\x1b" not in output
        assert "Launched." not in output

    def test_failed_restart_does_not_confirm_launched(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A re-exec that did not change the version must not claim `Launched.`."""
        console = MagicMock()
        console.is_terminal = True
        console.width = 80
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            # The install did not change the running version.
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.upgrade_command",
                return_value="uv tool upgrade deepagents-code",
            ),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        restart.assert_not_called()
        console.control.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "Launched." not in printed

    def test_version_check_failure_skips_confirm_in_isolation(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The confirm must be gated solely by `is_installed_version_at_least`.

        With nothing available (`(False, None)`) the function returns before the
        restart-loop guard, so the only path that could print `Launched.` is the
        confirm block. This pins the `is_installed_version_at_least(restarted_for)`
        condition: dropping it would let the confirm fire here and fail the test.
        """
        stream = StringIO()
        console = Console(file=stream, force_terminal=True, no_color=True, width=80)
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            # The re-exec did not land on the recorded version.
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(False, None),
            ),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        restart.assert_not_called()
        output = stream.getvalue()
        assert "\x1b[1A" not in output
        assert "Launched." not in output

    def test_confirm_launched_then_continues_to_available_update(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Confirming the prior launch must not short-circuit a newer update.

        The sentinel is an older version (now running), while a newer version is
        available: the function should both rewrite the prior line to `Launched.`
        and proceed into the upgrade path for the newer version.
        """
        stream = StringIO()
        console = Console(file=stream, force_terminal=True, no_color=True, width=80)
        monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.8")
        upgrade = AsyncMock(return_value=(True, ""))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            # The running version satisfies the prior restart (9.9.8) but not the
            # newly available 9.9.9, so the upgrade path must still run.
            patch(
                "deepagents_code.update_check.is_installed_version_at_least",
                side_effect=lambda version: version == "9.9.8",
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch(
                "deepagents_code.main._restart_current_process",
                side_effect=SystemExit(0),
            ) as restart,
            pytest.raises(SystemExit),
        ):
            _run_startup_auto_update(console)

        upgrade.assert_awaited_once()
        restart.assert_called_once_with()
        output = stream.getvalue()
        # The prior launch is confirmed for the running version...
        assert "v9.9.8. Launched." in output
        # ...and the newer version still goes through the upgrade path.
        assert "v9.9.9. Launching..." in output

    def test_terminal_row_count_single_row(self) -> None:
        """Text that fits on one line counts as a single row."""
        console = Console(file=StringIO(), force_terminal=True, no_color=True, width=80)
        assert _terminal_row_count(console, "abc") == 1

    def test_terminal_row_count_wraps_to_multiple_rows(self) -> None:
        """Text wider than the pane counts each wrapped row."""
        console = Console(file=StringIO(), force_terminal=True, no_color=True, width=10)
        # 20 characters at width 10 wraps to exactly 2 rows.
        assert _terminal_row_count(console, "abcdefghijklmnopqrst") == 2

    def test_terminal_row_count_floors_at_one(self) -> None:
        """Empty text still reports one row, never zero."""
        console = Console(file=StringIO(), force_terminal=True, no_color=True, width=80)
        assert _terminal_row_count(console, "") == 1

    def test_startup_auto_update_wired_into_interactive_launch(self) -> None:
        """`cli_main` must invoke the startup auto-update on interactive launch.

        Without this guard the feature could be dropped from `cli_main` and
        every other unit test would still pass, silently regressing it to a
        no-op.
        """
        source = inspect.getsource(cli_main)
        assert "_run_startup_auto_update(console)" in source


class TestAutoUpdateDefaultMigration:
    """First-run consent/migration notice for the auto-update opt-out default."""

    @pytest.fixture(autouse=True)
    def _no_shadowed_dcode(self) -> Iterator[None]:
        """Default to no PATH shadow — same reasoning as `TestStartupAutoUpdate`."""
        with patch(
            "deepagents_code.update_check.detect_shadowed_dcode",
            return_value=None,
        ):
            yield

    def test_first_run_announces_and_skips_install(self) -> None:
        """An implicit (default) opt-in announces once and skips the install."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.should_announce_auto_update_default",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.mark_auto_update_default_acknowledged",
                return_value=True,
            ) as mark,
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        mark.assert_called_once_with()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "updates automatically by default" in printed
        # A successful persist must not warn about the notice repeating.
        assert "could not be saved" not in printed

    def test_first_run_persist_failure_warns_repeat(self) -> None:
        """A failed acknowledgement persist surfaces that the notice may repeat."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.should_announce_auto_update_default",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.mark_auto_update_default_acknowledged",
                return_value=False,
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "updates automatically by default" in printed
        assert "could not be saved" in printed

    def test_debug_update_does_not_suppress_first_run_notice(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The consent notice wins over the debug-skip branch on the first run.

        `should_announce_auto_update_default` is checked before the
        `DEBUG_UPDATE` short-circuit, so a first run in debug mode shows the
        migration notice (and records the acknowledgement) rather than the
        "Skipped update install (debug mode)" message.
        """
        monkeypatch.setenv("DEEPAGENTS_CODE_DEBUG_UPDATE", "1")
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.should_announce_auto_update_default",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.mark_auto_update_default_acknowledged",
                return_value=True,
            ) as mark,
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch("deepagents_code.main._restart_current_process") as restart,
        ):
            _run_startup_auto_update(console)

        upgrade.assert_not_called()
        restart.assert_not_called()
        mark.assert_called_once_with()
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "updates automatically by default" in printed
        assert "debug mode" not in printed

    def test_acknowledged_default_proceeds_with_install(self) -> None:
        """Once acknowledged, the install proceeds normally on later launches."""
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch(
                "deepagents_code.update_check.is_update_check_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.is_auto_update_enabled",
                return_value=True,
            ),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.should_announce_auto_update_default",
                return_value=False,
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
            patch(
                "deepagents_code.main._restart_current_process",
                side_effect=SystemExit(0),
            ),
            pytest.raises(SystemExit),
        ):
            _run_startup_auto_update(console)

        upgrade.assert_awaited_once()

    def test_first_run_then_next_launch_end_to_end(self, tmp_path: Path) -> None:
        """Drive the real consent state machine across two launches.

        Unlike the other tests here, this does not patch
        `should_announce_auto_update_default` / `mark_auto_update_default_acknowledged`
        — it exercises the genuine implementations against temp config/state
        files so the wiring (announce-and-skip, then proceed) is verified, not
        just the orchestration around stubbed helpers.
        """
        config_path = tmp_path / "config.toml"
        state_file = tmp_path / "update_state.json"
        console = MagicMock()
        upgrade = AsyncMock(return_value=(True, "updated"))

        with (
            patch("deepagents_code.config._is_editable_install", return_value=False),
            patch("deepagents_code.update_check.DEFAULT_CONFIG_PATH", config_path),
            patch("deepagents_code.update_check.UPDATE_STATE_FILE", state_file),
            patch(
                "deepagents_code.update_check.get_cached_update_available",
                return_value=(True, "9.9.9"),
            ),
            patch(
                "deepagents_code.update_check.format_release_age_parenthetical",
                return_value="",
            ),
            patch(
                "deepagents_code.update_check.create_update_log_path",
                return_value=Path("/tmp/dcode-update.log"),
            ),
            patch("deepagents_code.update_check.perform_upgrade", upgrade),
        ):
            # First launch: no explicit choice and no recorded acknowledgement,
            # so the migration notice fires and the install is skipped.
            _run_startup_auto_update(console)
            upgrade.assert_not_called()
            printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
            assert "updates automatically by default" in printed
            assert state_file.exists()  # acknowledgement persisted

            # Second launch: the acknowledgement is now on disk, so the install
            # proceeds and the process re-execs (simulated via SystemExit).
            with (
                patch(
                    "deepagents_code.main._restart_current_process",
                    side_effect=SystemExit(0),
                ),
                pytest.raises(SystemExit),
            ):
                _run_startup_auto_update(console)

        upgrade.assert_awaited_once()


class TestResumeHintLogic:
    """Test that resume hint logic is correct.

    The actual condition in `cli_main` is::

        thread_id and return_code == 0 and asyncio.run(thread_exists(thread_id))

    These tests mirror the three-part condition. `thread_exists` is
    represented as a boolean to keep the tests as pure unit tests.
    """

    def test_resume_hint_condition_error_case(self) -> None:
        """Resume hint should NOT be shown when return_code is non-zero."""
        thread_id = "test123"
        return_code = 1
        has_checkpoints = True

        show = bool(thread_id) and return_code == 0 and has_checkpoints
        assert not show, "Resume hint should not be shown on error"

    def test_resume_hint_condition_success_case(self) -> None:
        """Resume hint SHOULD be shown on success with checkpoints."""
        thread_id = "test123"
        return_code = 0
        has_checkpoints = True

        show = bool(thread_id) and return_code == 0 and has_checkpoints
        assert show, "Resume hint should be shown on success"

    def test_resume_hint_shown_for_resumed_threads(self) -> None:
        """Resume hint SHOULD be shown for resumed threads too."""
        thread_id = "test123"
        return_code = 0
        has_checkpoints = True

        show = bool(thread_id) and return_code == 0 and has_checkpoints
        assert show, "Resume hint should be shown for resumed threads"

    def test_resume_hint_not_shown_without_checkpoints(self) -> None:
        """Resume hint should NOT appear when thread has no checkpoints."""
        thread_id = "test123"
        return_code = 0
        has_checkpoints = False

        show = bool(thread_id) and return_code == 0 and has_checkpoints
        assert not show, "No hint when thread_exists returns False"


class TestLangSmithTeardownUrl:
    """Test LangSmith thread URL display logic on teardown."""

    def setup_method(self) -> None:
        """Clear LangSmith URL cache before each test."""
        reset_langsmith_url_cache()

    def test_thread_url_requires_all_components(self) -> None:
        """LangSmith link requires thread_id, project_name, and project_url."""
        thread_url = build_langsmith_thread_url("abc123")
        # Without LangSmith configured, should return None
        assert thread_url is None

    def test_thread_url_not_shown_for_none_thread_id(self) -> None:
        """Guard condition: thread_url and thread_exists both needed."""
        thread_url = None
        thread_exists = True
        show_link = bool(thread_url and thread_exists)
        assert not show_link

    def test_thread_url_not_shown_when_no_checkpoints(self) -> None:
        """Guard condition: thread must have checkpointed content."""
        thread_url = "https://smith.langchain.com/o/org/projects/p/proj/t/abc"
        thread_exists = False
        show_link = bool(thread_url and thread_exists)
        assert not show_link

    def test_thread_url_shown_when_all_conditions_met(self) -> None:
        """Guard condition: both thread_url and thread_exists must be truthy."""
        thread_url = "https://smith.langchain.com/o/org/projects/p/proj/t/abc"
        thread_exists = True
        show_link = bool(thread_url and thread_exists)
        assert show_link


class TestAppResult:
    """Tests for the AppResult dataclass."""

    def test_fields_accessible(self) -> None:
        """AppResult should expose return_code and thread_id."""
        result = AppResult(return_code=0, thread_id="tid-abc")
        assert result.return_code == 0
        assert result.thread_id == "tid-abc"

    def test_thread_id_none(self) -> None:
        """AppResult should accept None for thread_id."""
        result = AppResult(return_code=1, thread_id=None)
        assert result.thread_id is None

    def test_frozen(self) -> None:
        """AppResult should be immutable."""
        from dataclasses import FrozenInstanceError

        result = AppResult(return_code=0, thread_id="tid")
        with pytest.raises(FrozenInstanceError):
            result.return_code = 1  # ty: ignore


class TestRunTextualAppReturnType:
    """Test that run_textual_app returns AppResult."""

    async def test_run_textual_app_returns_app_result(self) -> None:
        """run_textual_app should return an AppResult."""
        sig = inspect.signature(run_textual_app)
        annotation = sig.return_annotation
        assert annotation in (AppResult, "AppResult"), (
            f"run_textual_app should return AppResult, got {annotation}"
        )


class TestRunTextualCliAsyncReturnType:
    """Test that run_textual_cli_async returns AppResult."""

    def test_run_textual_cli_async_returns_app_result(self) -> None:
        """run_textual_cli_async should return an AppResult."""
        sig = inspect.signature(run_textual_cli_async)
        assert sig.return_annotation in (AppResult, "AppResult"), (
            "run_textual_cli_async should return AppResult, "
            f"got {sig.return_annotation}"
        )


class TestThreadMessage:
    """Test thread info display format.

    Thread info is now displayed in the WelcomeBanner widget rather than via
    pre-TUI console output, so we verify the banner receives the thread ID.
    """

    def test_thread_id_forwarded_to_app(self) -> None:
        """run_textual_cli_async passes thread_id to run_textual_app."""
        source = inspect.getsource(run_textual_cli_async)
        assert "thread_id=thread_id" in source, (
            "thread_id should be forwarded to run_textual_app"
        )


class TestRunTextualCliAsyncMcp:
    """Tests for MCP/server kwargs forwarding in interactive server mode.

    Server startup and MCP preload now happen inside the TUI via deferred
    kwargs rather than being invoked directly in `run_textual_cli_async`.
    """

    async def test_passes_server_and_mcp_kwargs_to_textual_app(self) -> None:
        """TUI should receive server, mcp_preload, and model kwargs."""
        app_result = AppResult(return_code=0, thread_id="thread-123")
        captured_kwargs: dict[str, Any] = {}

        async def _run_textual_app_stub(**kwargs: Any) -> AppResult:
            captured_kwargs.update(kwargs)
            await asyncio.sleep(0)
            return app_result

        with patch("deepagents_code.app.run_textual_app", new=_run_textual_app_stub):
            result = await run_textual_cli_async(
                "agent",
                thread_id="thread-123",
                model_name="openai:gpt-5.5",
            )

        assert result == app_result

        # Server kwargs forwarded for deferred startup inside the TUI
        assert captured_kwargs["server_kwargs"] is not None
        assert captured_kwargs["server_kwargs"]["assistant_id"] == "agent"
        assert captured_kwargs["server_kwargs"]["interactive"] is True
        # auto_approve must NOT be in server_kwargs — the interactive server
        # must always compile with full HITL interrupts so Shift+Tab works.
        assert "auto_approve" not in captured_kwargs["server_kwargs"]

        # MCP preload kwargs forwarded (no_mcp=False by default)
        assert captured_kwargs["mcp_preload_kwargs"] is not None
        assert captured_kwargs["mcp_preload_kwargs"]["no_mcp"] is False

        # Model kwargs forwarded for deferred create_model() inside the TUI
        assert captured_kwargs["model_kwargs"] is not None
        assert captured_kwargs["model_kwargs"]["model_spec"] == "openai:gpt-5.5"
        assert captured_kwargs["model_kwargs"]["extra_kwargs"] is None

    async def test_no_mcp_kwargs_when_disabled(self) -> None:
        """mcp_preload_kwargs should be None when no_mcp=True."""
        app_result = AppResult(return_code=0, thread_id="thread-123")
        captured_kwargs: dict[str, Any] = {}

        async def _run_textual_app_stub(**kwargs: Any) -> AppResult:
            captured_kwargs.update(kwargs)
            await asyncio.sleep(0)
            return app_result

        with patch("deepagents_code.app.run_textual_app", new=_run_textual_app_stub):
            await run_textual_cli_async(
                "agent",
                thread_id="thread-123",
                model_name="openai:gpt-5.5",
                no_mcp=True,
            )

        assert captured_kwargs["mcp_preload_kwargs"] is None

    async def test_onboarding_trigger_reaches_textual_app(self) -> None:
        """First-run onboarding state should control the app launch flag."""
        app_result = AppResult(return_code=0, thread_id="thread-123")
        captured_kwargs: dict[str, Any] = {}

        async def _run_textual_app_stub(**kwargs: Any) -> AppResult:
            captured_kwargs.update(kwargs)
            await asyncio.sleep(0)
            return app_result

        with (
            patch("deepagents_code.app.run_textual_app", new=_run_textual_app_stub),
            patch(
                "deepagents_code.onboarding.should_run_onboarding", return_value=True
            ),
        ):
            await run_textual_cli_async(
                "agent",
                thread_id="thread-123",
                model_name="openai:gpt-5.5",
            )

        assert captured_kwargs["launch_init"] is True


class TestServerCleanupLifecycle:
    """Verify server_proc.stop() is guaranteed after the TUI exits."""

    async def test_server_proc_stopped_after_app_exits(self) -> None:
        """run_textual_app must call server_proc.stop() in the finally block."""
        server_proc = SimpleNamespace(stop=MagicMock())

        with patch.object(
            DeepAgentsApp,
            "run_async",
            new_callable=AsyncMock,
        ):
            await run_textual_app(server_proc=server_proc, thread_id="t-1")  # ty: ignore

        server_proc.stop.assert_called_once_with()

    async def test_server_proc_stopped_even_on_crash(self) -> None:
        """server_proc.stop() must fire even when run_async raises."""
        server_proc = SimpleNamespace(stop=MagicMock())

        with (
            patch.object(
                DeepAgentsApp,
                "run_async",
                new_callable=AsyncMock,
                side_effect=RuntimeError("boom"),
            ),
            pytest.raises(RuntimeError, match="boom"),
        ):
            await run_textual_app(server_proc=server_proc, thread_id="t-1")  # ty: ignore

        server_proc.stop.assert_called_once_with()

    async def test_deferred_server_proc_stopped_after_app_exits(self) -> None:
        """server_proc set by the background worker must still be cleaned up."""
        server_proc = SimpleNamespace(stop=MagicMock())

        async def _fake_run_async(self: DeepAgentsApp) -> None:  # noqa: RUF029
            # Simulate the background worker having set _server_proc
            self._server_proc = server_proc

        with patch.object(
            DeepAgentsApp,
            "run_async",
            new=_fake_run_async,
        ):
            await run_textual_app(
                server_kwargs={"assistant_id": "a"},
                thread_id="t-1",
            )

        server_proc.stop.assert_called_once_with()


class TestCheckOptionalTools:
    """Tests for check_optional_tools() function."""

    @pytest.fixture(autouse=True)
    def _tavily_available(self) -> Iterator[None]:
        """Patch settings.has_tavily to True so ripgrep-only tests stay isolated."""
        with patch(
            "deepagents_code.config.settings",
            SimpleNamespace(has_tavily=True),
        ):
            yield

    def test_returns_tool_name_when_rg_not_found(self) -> None:
        """Returns `['ripgrep']` when `rg` is not on PATH."""
        with patch("deepagents_code.main.shutil.which", return_value=None):
            missing = check_optional_tools()

        assert missing == ["ripgrep"]

    def test_returns_empty_when_rg_found(self) -> None:
        """Returns empty list when `rg` is found on PATH."""
        with patch("deepagents_code.main.shutil.which", return_value="/usr/bin/rg"):
            missing = check_optional_tools()

        assert missing == []

    def test_managed_rg_still_requires_validation(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Treat the managed binary as missing so `ensure_ripgrep` validates it."""
        managed = tmp_path / "bin" / "rg"
        monkeypatch.setattr(
            "deepagents_code.managed_tools.managed_rg_path",
            lambda: managed,
        )

        with patch("deepagents_code.main.shutil.which", return_value=str(managed)):
            missing = check_optional_tools()

        assert missing == ["ripgrep"]

    def test_warning_suppressed_via_config(self, tmp_path: Path) -> None:
        """Returns empty list when ripgrep warning is suppressed in config."""
        config_path = tmp_path / "config.toml"
        config_path.write_text('[warnings]\nsuppress = ["ripgrep"]\n')

        with patch("deepagents_code.main.shutil.which", return_value=None):
            missing = check_optional_tools(config_path=config_path)

        assert missing == []

    def test_malformed_config_does_not_suppress(self, tmp_path: Path) -> None:
        """Malformed TOML config degrades gracefully instead of crashing."""
        config_path = tmp_path / "config.toml"
        config_path.write_text("this is not valid toml [[[")

        with patch("deepagents_code.main.shutil.which", return_value=None):
            missing = check_optional_tools(config_path=config_path)

        assert missing == ["ripgrep"]

    def test_non_list_suppress_does_not_crash(self, tmp_path: Path) -> None:
        """Non-list `suppress` value degrades gracefully instead of crashing."""
        config_path = tmp_path / "config.toml"
        config_path.write_text("[warnings]\nsuppress = true\n")

        with patch("deepagents_code.main.shutil.which", return_value=None):
            missing = check_optional_tools(config_path=config_path)

        assert missing == ["ripgrep"]

    def test_unrelated_suppress_key_does_not_suppress(self, tmp_path: Path) -> None:
        """Suppressing a different key does not suppress the ripgrep warning."""
        config_path = tmp_path / "config.toml"
        config_path.write_text('[warnings]\nsuppress = ["something_else"]\n')

        with patch("deepagents_code.main.shutil.which", return_value=None):
            missing = check_optional_tools(config_path=config_path)

        assert missing == ["ripgrep"]

    def test_returns_tavily_when_key_missing(self) -> None:
        """Returns `'tavily'` when TAVILY_API_KEY is not set."""
        with (
            patch("deepagents_code.main.shutil.which", return_value="/usr/bin/rg"),
            patch(
                "deepagents_code.config.settings",
                SimpleNamespace(has_tavily=False),
            ),
        ):
            missing = check_optional_tools()

        assert missing == ["tavily"]

    def test_omits_tavily_when_key_present(self) -> None:
        """Does not include `'tavily'` when TAVILY_API_KEY is set."""
        with patch("deepagents_code.main.shutil.which", return_value="/usr/bin/rg"):
            missing = check_optional_tools()

        assert "tavily" not in missing

    def test_tavily_warning_suppressed_via_config(self, tmp_path: Path) -> None:
        """Returns empty list when tavily warning is suppressed in config."""
        config_path = tmp_path / "config.toml"
        config_path.write_text('[warnings]\nsuppress = ["tavily"]\n')

        with (
            patch("deepagents_code.main.shutil.which", return_value="/usr/bin/rg"),
            patch(
                "deepagents_code.config.settings",
                SimpleNamespace(has_tavily=False),
            ),
        ):
            missing = check_optional_tools(config_path=config_path)

        assert missing == []


class TestIsManagedRipgrepPath:
    """Tests for `_is_managed_ripgrep_path`."""

    def test_none_is_not_managed(self) -> None:
        """A missing `rg` (path `None`) is not the managed binary."""
        assert _is_managed_ripgrep_path(None) is False

    def test_managed_path_matches(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """The resolved managed path is recognized as managed."""
        managed = tmp_path / "bin" / "rg"
        managed.parent.mkdir(parents=True)
        managed.write_bytes(b"x")
        monkeypatch.setattr(
            "deepagents_code.managed_tools.managed_rg_path", lambda: managed
        )

        assert _is_managed_ripgrep_path(str(managed)) is True

    def test_system_path_is_not_managed(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A system `rg` elsewhere on `PATH` is not the managed binary."""
        managed = tmp_path / "bin" / "rg"
        monkeypatch.setattr(
            "deepagents_code.managed_tools.managed_rg_path", lambda: managed
        )

        assert _is_managed_ripgrep_path(str(tmp_path / "usr" / "bin" / "rg")) is False


class TestAutoInstallRipgrepCli:
    """Tests for the headless `_auto_install_ripgrep_cli` helper."""

    def test_success_drops_ripgrep_and_prepends(self) -> None:
        """A successful install prepends `PATH` and drops `ripgrep`."""
        console = MagicMock()
        prepend = MagicMock()
        with (
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(return_value=Path("/managed/rg")),
            ),
            patch(
                "deepagents_code.managed_tools.managed_rg_path",
                return_value=Path("/managed/rg"),
            ),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            result = _auto_install_ripgrep_cli(console, ["ripgrep", "tavily"])

        assert result == ["tavily"]
        prepend.assert_called_once()

    def test_system_rg_drops_ripgrep_without_prepending(self) -> None:
        """A system `rg` is usable without prepending the managed binary dir."""
        console = MagicMock()
        prepend = MagicMock()
        with (
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(return_value=Path("/usr/bin/rg")),
            ),
            patch(
                "deepagents_code.managed_tools.managed_rg_path",
                return_value=Path("/managed/rg"),
            ),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            result = _auto_install_ripgrep_cli(console, ["ripgrep", "tavily"])

        assert result == ["tavily"]
        prepend.assert_not_called()

    def test_install_returns_none_keeps_ripgrep(self) -> None:
        """A skipped/failed install leaves `ripgrep` in the missing list."""
        console = MagicMock()
        prepend = MagicMock()
        with (
            patch(
                "deepagents_code.managed_tools.ensure_ripgrep",
                AsyncMock(return_value=None),
            ),
            patch(
                "deepagents_code.managed_tools.prepend_managed_bin_to_path",
                prepend,
            ),
        ):
            result = _auto_install_ripgrep_cli(console, ["ripgrep"])

        assert result == ["ripgrep"]
        prepend.assert_not_called()

    def test_checksum_mismatch_keeps_ripgrep_and_reports(self) -> None:
        """A checksum mismatch is reported loudly and is not swallowed silently."""
        from deepagents_code.managed_tools import ChecksumMismatchError

        console = MagicMock()
        with patch(
            "deepagents_code.managed_tools.ensure_ripgrep",
            AsyncMock(side_effect=ChecksumMismatchError("bad")),
        ):
            result = _auto_install_ripgrep_cli(console, ["ripgrep"])

        assert result == ["ripgrep"]
        printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
        assert "SHA-256" in printed

    def test_unexpected_failure_keeps_ripgrep(self) -> None:
        """An unexpected error degrades gracefully to the missing-tool path."""
        console = MagicMock()
        with patch(
            "deepagents_code.managed_tools.ensure_ripgrep",
            AsyncMock(side_effect=RuntimeError("boom")),
        ):
            result = _auto_install_ripgrep_cli(console, ["ripgrep"])

        assert result == ["ripgrep"]


class TestRipgrepInstallHint:
    """Tests for platform-specific ripgrep install hints."""

    def test_macos_brew(self) -> None:
        """Returns brew command on macOS when brew is available."""

        def _which(cmd: str) -> str | None:
            return "/opt/homebrew/bin/brew" if cmd == "brew" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "darwin"
            assert _ripgrep_install_hint() == "brew install ripgrep"

    def test_macos_port(self) -> None:
        """Falls back to MacPorts when brew is absent."""

        def _which(cmd: str) -> str | None:
            return "/opt/local/bin/port" if cmd == "port" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "darwin"
            assert _ripgrep_install_hint() == "sudo port install ripgrep"

    def test_linux_apt(self) -> None:
        """Returns apt-get command on Debian/Ubuntu."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/apt-get" if cmd == "apt-get" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "sudo apt-get install ripgrep"

    def test_linux_dnf(self) -> None:
        """Returns dnf command on Fedora/RHEL."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/dnf" if cmd == "dnf" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "sudo dnf install ripgrep"

    def test_linux_pacman(self) -> None:
        """Returns pacman command on Arch."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/pacman" if cmd == "pacman" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "sudo pacman -S ripgrep"

    def test_linux_zypper(self) -> None:
        """Returns zypper command on openSUSE."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/zypper" if cmd == "zypper" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "sudo zypper install ripgrep"

    def test_linux_apk(self) -> None:
        """Returns apk command on Alpine."""

        def _which(cmd: str) -> str | None:
            return "/sbin/apk" if cmd == "apk" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "sudo apk add ripgrep"

    def test_linux_nix(self) -> None:
        """Returns nix-env command on NixOS."""

        def _which(cmd: str) -> str | None:
            if cmd == "nix-env":
                return "/nix/var/nix/profiles/default/bin/nix-env"
            return None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "linux"
            assert _ripgrep_install_hint() == "nix-env -iA nixpkgs.ripgrep"

    def test_win32_choco(self) -> None:
        """Returns choco command on Windows when available."""

        def _which(cmd: str) -> str | None:
            if cmd == "choco":
                return "C:\\ProgramData\\chocolatey\\bin\\choco.exe"
            return None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "win32"
            assert _ripgrep_install_hint() == "choco install ripgrep"

    def test_win32_scoop(self) -> None:
        """Returns scoop command on Windows when available."""

        def _which(cmd: str) -> str | None:
            if cmd == "scoop":
                return "C:\\Users\\user\\scoop\\shims\\scoop.exe"
            return None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "win32"
            assert _ripgrep_install_hint() == "scoop install ripgrep"

    def test_win32_winget(self) -> None:
        """Returns winget command on Windows when available."""

        def _which(cmd: str) -> str | None:
            return "C:\\winget.exe" if cmd == "winget" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "win32"
            assert _ripgrep_install_hint() == "winget install BurntSushi.ripgrep"

    def test_darwin_no_manager_falls_through(self) -> None:
        """Falls through to cross-platform on macOS without brew/port."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/cargo" if cmd == "cargo" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "darwin"
            assert _ripgrep_install_hint() == "cargo install ripgrep"

    def test_linux_no_manager_falls_through(self) -> None:
        """Falls through to URL on Linux without any package manager."""
        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", return_value=None),
        ):
            mock_sys.platform = "linux"
            assert "github.com/BurntSushi/ripgrep" in _ripgrep_install_hint()

    def test_cargo_fallback(self) -> None:
        """Falls back to cargo when no system package manager found."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/cargo" if cmd == "cargo" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "freebsd"
            assert _ripgrep_install_hint() == "cargo install ripgrep"

    def test_conda_fallback(self) -> None:
        """Falls back to conda when no other manager found."""

        def _which(cmd: str) -> str | None:
            return "/usr/bin/conda" if cmd == "conda" else None

        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", side_effect=_which),
        ):
            mock_sys.platform = "freebsd"
            assert _ripgrep_install_hint() == "conda install -c conda-forge ripgrep"

    def test_url_fallback(self) -> None:
        """Returns GitHub URL when nothing is detected."""
        with (
            patch("deepagents_code.main.sys") as mock_sys,
            patch("deepagents_code.main.shutil.which", return_value=None),
        ):
            mock_sys.platform = "freebsd"
            hint = _ripgrep_install_hint()
            assert hint.startswith("https://")
            assert "github.com/BurntSushi/ripgrep" in hint


class TestFormatToolWarnings:
    """Tests for the CLI warning formatter and the notification builder."""

    def test_cli_format_contains_install_hint(self) -> None:
        """CLI format includes a platform-specific install hint."""
        hint_patch = patch(
            "deepagents_code.main._ripgrep_install_hint",
            return_value="brew install ripgrep",
        )
        with hint_patch:
            msg = format_tool_warning_cli("ripgrep")
        assert "brew install ripgrep" in msg

    def test_cli_format_wraps_url_in_rich_link(self) -> None:
        """CLI format wraps URL fallback in Rich `[link]` markup."""
        url = "https://github.com/BurntSushi/ripgrep#installation"
        hint_patch = patch(
            "deepagents_code.main._ripgrep_install_hint",
            return_value=url,
        )
        with hint_patch:
            msg = format_tool_warning_cli("ripgrep")
        assert f"[link={url}]" in msg
        assert "[/link]" in msg

    def test_cli_format_contains_config_hint(self) -> None:
        """CLI format references config.toml for suppression."""
        msg = format_tool_warning_cli("ripgrep")
        assert "config.toml" in msg
        assert 'suppress = \\["ripgrep"]' in msg

    def test_cli_format_unknown_tool_fallback(self) -> None:
        """Unknown tools get a generic CLI message."""
        assert format_tool_warning_cli("foo") == "foo is not installed."

    def test_cli_format_tavily_contains_env_hint(self) -> None:
        """CLI format for tavily mentions the env var with Rich link."""
        msg = format_tool_warning_cli("tavily")
        assert "TAVILY_API_KEY" in msg
        assert "[link=https://tavily.com]" in msg

    def test_cli_format_tavily_contains_config_hint(self) -> None:
        """CLI tavily format references config.toml for suppression."""
        msg = format_tool_warning_cli("tavily")
        assert "config.toml" in msg
        assert 'suppress = \\["tavily"]' in msg


class TestBuildMissingToolNotification:
    """Tests for `build_missing_tool_notification` registry factory."""

    def test_ripgrep_with_package_manager_hint(self) -> None:
        """Ripgrep with install command offers copy + open-website + suppress."""
        from deepagents_code.main import _RIPGREP_URL
        from deepagents_code.notifications import ActionId, MissingDepPayload

        with patch(
            "deepagents_code.main._ripgrep_install_hint",
            return_value="brew install ripgrep",
        ):
            entry = build_missing_tool_notification("ripgrep")
        assert entry.key == "dep:ripgrep"
        assert isinstance(entry.payload, MissingDepPayload)
        assert entry.payload.tool == "ripgrep"
        assert entry.payload.install_command == "brew install ripgrep"
        assert entry.payload.url == _RIPGREP_URL
        action_ids = [a.action_id for a in entry.actions]
        assert action_ids == [
            ActionId.COPY_INSTALL,
            ActionId.OPEN_WEBSITE,
            ActionId.SUPPRESS,
        ]
        assert entry.actions[0].primary is True

    def test_ripgrep_url_fallback_opens_website(self) -> None:
        """Ripgrep with URL fallback offers open-website + suppress."""
        from deepagents_code.notifications import ActionId, MissingDepPayload

        url = "https://github.com/BurntSushi/ripgrep#installation"
        with patch(
            "deepagents_code.main._ripgrep_install_hint",
            return_value=url,
        ):
            entry = build_missing_tool_notification("ripgrep")
        assert isinstance(entry.payload, MissingDepPayload)
        assert entry.payload.url == url
        assert entry.payload.install_command is None
        action_ids = [a.action_id for a in entry.actions]
        assert action_ids == [ActionId.OPEN_WEBSITE, ActionId.SUPPRESS]

    def test_tavily_offers_enter_key_website_and_suppress(self) -> None:
        """Tavily entry offers entering a key, the website, and suppression."""
        from deepagents_code.notifications import ActionId, MissingDepPayload

        entry = build_missing_tool_notification("tavily")
        assert entry.key == "dep:tavily"
        assert isinstance(entry.payload, MissingDepPayload)
        assert entry.payload.tool == "tavily"
        assert entry.payload.url == "https://tavily.com"
        assert entry.payload.install_command is None
        action_ids = [a.action_id for a in entry.actions]
        assert action_ids == [
            ActionId.ENTER_API_KEY,
            ActionId.OPEN_WEBSITE,
            ActionId.SUPPRESS,
        ]
        assert entry.actions[0].primary is True
        assert "Tavily API key" in entry.body

    def test_unknown_tool_only_suppresses_and_logs(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Unknown tools fall back to a bare suppress action and log a warning."""
        import logging

        from deepagents_code.notifications import ActionId, MissingDepPayload

        with caplog.at_level(logging.WARNING, logger="deepagents_code.main"):
            entry = build_missing_tool_notification("foo")
        assert entry.key == "dep:foo"
        assert isinstance(entry.payload, MissingDepPayload)
        assert entry.payload.tool == "foo"
        assert [a.action_id for a in entry.actions] == [ActionId.SUPPRESS]
        assert any("No install hint" in record.message for record in caplog.records)


class TestRunTextualCliAsyncModelConfigError:
    """Verify default model config errors are handled before launching the TUI."""

    async def test_launches_tui_on_no_credentials(self) -> None:
        """Missing default credentials should be recoverable inside the TUI."""
        from deepagents_code.model_config import NoCredentialsConfiguredError

        app_result = AppResult(return_code=0, thread_id="t-1")
        captured_kwargs: dict[str, Any] = {}

        async def _stub(**kwargs: Any) -> AppResult:
            captured_kwargs.update(kwargs)
            await asyncio.sleep(0)
            return app_result

        with (
            patch(
                "deepagents_code.config._get_default_model_spec",
                side_effect=NoCredentialsConfiguredError("No credentials configured"),
            ),
            patch("deepagents_code.app.run_textual_app", new=_stub),
        ):
            result = await run_textual_cli_async("agent")

        assert result == app_result
        assert captured_kwargs["defer_server_start"] is True
        assert captured_kwargs["model_kwargs"] is None
        assert captured_kwargs["server_kwargs"]["model_name"] is None

    async def test_recovery_does_not_rely_on_message_text(self) -> None:
        """`NoCredentialsConfiguredError` triggers deferred start.

        Regardless of the exception message text.
        """
        from deepagents_code.model_config import NoCredentialsConfiguredError

        app_result = AppResult(return_code=0, thread_id="t-2")
        captured_kwargs: dict[str, Any] = {}

        async def _stub(**kwargs: Any) -> AppResult:
            captured_kwargs.update(kwargs)
            await asyncio.sleep(0)
            return app_result

        # Reword the message to prove we no longer string-match on prefix.
        with (
            patch(
                "deepagents_code.config._get_default_model_spec",
                side_effect=NoCredentialsConfiguredError(
                    "Setup required: please run /model"
                ),
            ),
            patch("deepagents_code.app.run_textual_app", new=_stub),
        ):
            result = await run_textual_cli_async("agent")

        assert result == app_result
        assert captured_kwargs["defer_server_start"] is True

    async def test_returns_error_code_on_other_model_config_error(self) -> None:
        """Non-recoverable default model errors should still block startup."""
        from deepagents_code.model_config import ModelConfigError

        with (
            patch(
                "deepagents_code.config._get_default_model_spec",
                side_effect=ModelConfigError("Invalid model config"),
            ),
            patch("deepagents_code.config._get_console") as mock_console_fn,
        ):
            mock_console = MagicMock()
            mock_console_fn.return_value = mock_console

            result = await run_textual_cli_async("agent")

        assert result.return_code == 1
        assert result.thread_id is None

    async def test_no_error_when_model_name_provided(self) -> None:
        """Explicit model_name bypasses _get_default_model_spec."""
        app_result = AppResult(return_code=0, thread_id="t-1")

        async def _stub(**_kwargs: Any) -> AppResult:  # noqa: RUF029  # must be async for run_textual_app signature
            return app_result

        with patch("deepagents_code.app.run_textual_app", new=_stub):
            result = await run_textual_cli_async("agent", model_name="openai:gpt-5.5")

        assert result.return_code == 0


class TestNormalizeCwdFilter:
    """Tests for `_normalize_cwd_filter`."""

    def test_none_returns_none(self) -> None:
        """No flag → no filter."""
        from deepagents_code.main import _normalize_cwd_filter

        assert _normalize_cwd_filter(None) is None

    def test_empty_string_uses_current_cwd(self) -> None:
        """Bare `--cwd` (empty-string sentinel) resolves to current working dir."""
        from deepagents_code.main import _normalize_cwd_filter

        assert _normalize_cwd_filter("") == str(Path.cwd())

    def test_explicit_path_is_made_absolute(self) -> None:
        """A user-supplied path is expanduser'd and made absolute."""
        from deepagents_code.main import _normalize_cwd_filter

        result = _normalize_cwd_filter("~/foo/bar")
        assert result is not None
        assert result == str(Path("~/foo/bar").expanduser().absolute())
        assert Path(result).is_absolute()

    def test_explicit_relative_parent_path_is_normalized(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Explicit relative paths collapse `..` without resolving symlinks."""
        from deepagents_code.main import _normalize_cwd_filter

        project = tmp_path / "project"
        subdir = project / "subdir"
        subdir.mkdir(parents=True)
        monkeypatch.chdir(subdir)

        assert _normalize_cwd_filter("..") == str(project)

    def test_explicit_path_does_not_resolve_symlinks(self, tmp_path: Path) -> None:
        """Lexical normalization (not `.resolve()`) matches storage convention."""
        from deepagents_code.main import _normalize_cwd_filter

        real = tmp_path / "real"
        real.mkdir()
        link = tmp_path / "via_link"
        try:
            link.symlink_to(real)
        except (OSError, NotImplementedError):
            pytest.skip("symlinks unsupported on this platform")

        result = _normalize_cwd_filter(str(link))
        assert result == str(link.absolute())
        # Sanity: resolve() would have collapsed the symlink to `real`.
        assert result != str(link.resolve())

    def test_cwd_unreadable_returns_none(self) -> None:
        """A deleted/unreadable cwd degrades to no filter rather than crashing."""
        from deepagents_code.main import _normalize_cwd_filter

        with patch(
            "deepagents_code.main.Path.cwd",
            side_effect=FileNotFoundError("gone"),
        ):
            assert _normalize_cwd_filter("") is None


class TestThreadsListCwdArgparse:
    """Tests for `--cwd` argparse semantics on `deepagents threads list`."""

    def _parse(self, argv: list[str]) -> Any:  # noqa: ANN401
        from deepagents_code.main import parse_args

        with patch("sys.argv", ["deepagents", *argv]):
            return parse_args()

    def test_cwd_omitted_yields_none(self) -> None:
        """Omitting --cwd leaves the namespace value at `None`."""
        ns = self._parse(["threads", "list"])
        assert getattr(ns, "cwd", "MISSING") is None

    def test_cwd_alone_yields_empty_string_const(self) -> None:
        """Bare `--cwd` stores the `const=""` sentinel for downstream resolution."""
        ns = self._parse(["threads", "list", "--cwd"])
        assert ns.cwd == ""

    def test_cwd_with_value_stores_value(self) -> None:
        """`--cwd /some/path` stores the literal value as-is."""
        ns = self._parse(["threads", "list", "--cwd", "/some/path"])
        assert ns.cwd == "/some/path"


class TestCheckMcpProjectTrustPrompt:
    """The project MCP approval prompt should surface a docs link."""

    def test_debug_env_helper_uses_truthy_parsing(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The debug helper treats common falsy strings as disabled."""
        from deepagents_code._env_vars import DEBUG_MCP_PROJECT_TRUST
        from deepagents_code.main import _debug_mcp_project_trust_enabled

        monkeypatch.setenv(DEBUG_MCP_PROJECT_TRUST, "0")

        assert _debug_mcp_project_trust_enabled() is False

        monkeypatch.setenv(DEBUG_MCP_PROJECT_TRUST, "1")

        assert _debug_mcp_project_trust_enabled() is True

    def test_debug_env_forces_prompt_without_project_config(
        self,
        capsys: pytest.CaptureFixture[str],
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """The debug env var shows a sample prompt without requiring config files."""
        from deepagents_code._env_vars import DEBUG_MCP_PROJECT_TRUST
        from deepagents_code.main import _check_mcp_project_trust

        project_context = SimpleNamespace(project_root=tmp_path, user_cwd=tmp_path)
        monkeypatch.setenv(DEBUG_MCP_PROJECT_TRUST, "1")

        with (
            patch(
                "deepagents_code.project_utils.ProjectContext.from_user_cwd",
                return_value=project_context,
            ),
            patch(
                "deepagents_code.mcp_tools.discover_mcp_configs",
                return_value=[],
            ),
            patch(
                "deepagents_code.mcp_tools.classify_discovered_configs",
                return_value=([], []),
            ),
            patch(
                "deepagents_code.mcp_trust.is_project_mcp_trusted",
                return_value=True,
            ),
            patch("deepagents_code.mcp_trust.trust_project_mcp") as trust_project_mcp,
            patch("builtins.input", return_value="y"),
        ):
            decision = _check_mcp_project_trust(trust_flag=False)

        assert decision is True
        trust_project_mcp.assert_not_called()
        captured = capsys.readouterr()
        assert "debug-project-mcp" in captured.err
        assert "Learn more:" in captured.err

    def test_prompt_includes_docs_link(
        self, capsys: pytest.CaptureFixture[str], tmp_path: Path
    ) -> None:
        """When the prompt fires, it should print the project-level-trust docs URL."""
        from deepagents_code.main import _check_mcp_project_trust

        project_root = tmp_path / "proj"
        project_root.mkdir()
        project_cfg = project_root / ".mcp.json"
        project_cfg.write_text("{}")

        project_context = SimpleNamespace(
            project_root=project_root, user_cwd=project_root
        )

        with (
            patch(
                "deepagents_code.project_utils.ProjectContext.from_user_cwd",
                return_value=project_context,
            ),
            patch(
                "deepagents_code.mcp_tools.discover_mcp_configs",
                return_value=[project_cfg],
            ),
            patch(
                "deepagents_code.mcp_tools.classify_discovered_configs",
                return_value=([], [project_cfg]),
            ),
            patch(
                "deepagents_code.mcp_tools.load_mcp_config_lenient",
                return_value={
                    "mcpServers": {"fs": {"command": "node", "args": ["server.js"]}}
                },
            ),
            patch(
                "deepagents_code.mcp_tools.extract_project_server_summaries",
                return_value=[("fs", "stdio", "node server.js")],
            ),
            patch(
                "deepagents_code.mcp_trust.is_project_mcp_trusted",
                return_value=False,
            ),
            patch("builtins.input", return_value="n"),
        ):
            decision = _check_mcp_project_trust(trust_flag=False)

        assert decision is False
        captured = capsys.readouterr()
        flattened = captured.err.replace("\n", "")
        assert (
            "https://docs.langchain.com/oss/python/deepagents/code/"
            "mcp-tools#project-level-trust" in flattened
        )
        assert "Learn more:" in captured.err

    def test_warns_when_trust_cannot_be_saved(
        self, capsys: pytest.CaptureFixture[str], tmp_path: Path
    ) -> None:
        """A failed persist still allows this session but warns it wasn't saved."""
        from deepagents_code.main import _check_mcp_project_trust

        project_root = tmp_path / "proj"
        project_root.mkdir()
        project_cfg = project_root / ".mcp.json"
        project_cfg.write_text("{}")

        project_context = SimpleNamespace(
            project_root=project_root, user_cwd=project_root
        )

        with (
            patch(
                "deepagents_code.project_utils.ProjectContext.from_user_cwd",
                return_value=project_context,
            ),
            patch(
                "deepagents_code.mcp_tools.discover_mcp_configs",
                return_value=[project_cfg],
            ),
            patch(
                "deepagents_code.mcp_tools.classify_discovered_configs",
                return_value=([], [project_cfg]),
            ),
            patch(
                "deepagents_code.mcp_tools.load_mcp_config_lenient",
                return_value={
                    "mcpServers": {"fs": {"command": "node", "args": ["server.js"]}}
                },
            ),
            patch(
                "deepagents_code.mcp_tools.extract_project_server_summaries",
                return_value=[("fs", "stdio", "node server.js")],
            ),
            patch(
                "deepagents_code.mcp_trust.is_project_mcp_trusted",
                return_value=False,
            ),
            patch(
                "deepagents_code.mcp_trust.trust_project_mcp",
                return_value=False,
            ),
            patch("builtins.input", return_value="y"),
        ):
            decision = _check_mcp_project_trust(trust_flag=False)

        assert decision is True
        assert "could not be saved" in capsys.readouterr().err


class TestCheckMcpProjectTrustDedupe:
    """Regression tests for the project MCP approval prompt deduplication.

    When the same server name appears in multiple project-level configs
    (e.g. both `.mcp.json` and `.deepagents/.mcp.json`), the approval
    prompt must list it once — not once per file.
    """

    def _write_config(self, path: Path, servers: dict[str, Any]) -> None:
        import json

        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({"mcpServers": servers}), encoding="utf-8")

    def _deny_project_mcp(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(
            "deepagents_code.mcp_trust.is_project_mcp_trusted",
            lambda *_a, **_k: False,
        )
        monkeypatch.setattr("builtins.input", lambda _prompt="": "n")

    def _captured_prompt(self, capsys: pytest.CaptureFixture[str]) -> str:
        captured = capsys.readouterr()
        return captured.out + captured.err

    def test_duplicate_server_across_configs_listed_once(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """A server defined in both project configs appears once in the prompt."""
        from deepagents_code.main import _check_mcp_project_trust

        server = {
            "fs": {
                "command": "uvx",
                "args": ["mcp-server-filesystem", "/tmp"],
            }
        }
        self._write_config(tmp_path / ".mcp.json", server)
        self._write_config(tmp_path / ".deepagents" / ".mcp.json", server)

        self._deny_project_mcp(tmp_path, monkeypatch)

        result = _check_mcp_project_trust(trust_flag=False)

        assert result is False
        combined = self._captured_prompt(capsys)
        assert combined.count('  "fs" (stdio):') == 1, combined

    def test_duplicate_server_across_configs_uses_project_root_definition(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """The higher-precedence project-root config wins for duplicate names."""
        from deepagents_code.main import _check_mcp_project_trust

        self._write_config(
            tmp_path / ".deepagents" / ".mcp.json",
            {"fs": {"command": "npx", "args": ["subdir-server", "/subdir"]}},
        )
        self._write_config(
            tmp_path / ".mcp.json",
            {"fs": {"command": "uvx", "args": ["root-server", "/root"]}},
        )

        self._deny_project_mcp(tmp_path, monkeypatch)

        result = _check_mcp_project_trust(trust_flag=False)

        assert result is False
        combined = self._captured_prompt(capsys)
        assert combined.count('  "fs" (stdio):') == 1, combined
        assert '  "fs" (stdio):  uvx root-server /root' in combined
        assert "subdir-server" not in combined

    def test_duplicate_remote_server_across_configs_listed_once(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Duplicate remote servers are deduped the same way as stdio servers."""
        from deepagents_code.main import _check_mcp_project_trust

        self._write_config(
            tmp_path / ".deepagents" / ".mcp.json",
            {
                "remote": {
                    "type": "http",
                    "url": "https://subdir.example.com/mcp",
                }
            },
        )
        self._write_config(
            tmp_path / ".mcp.json",
            {
                "remote": {
                    "type": "http",
                    "url": "https://root.example.com/mcp",
                }
            },
        )

        self._deny_project_mcp(tmp_path, monkeypatch)

        result = _check_mcp_project_trust(trust_flag=False)

        assert result is False
        combined = self._captured_prompt(capsys)
        assert combined.count('  "remote" (http):') == 1, combined
        assert '  "remote" (http):  https://root.example.com/mcp' in combined
        assert "subdir.example.com" not in combined

    def test_invalid_project_config_does_not_block_valid_config(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Malformed project configs are skipped while valid configs still prompt."""
        from deepagents_code.main import _check_mcp_project_trust

        invalid = tmp_path / ".deepagents" / ".mcp.json"
        invalid.parent.mkdir(parents=True, exist_ok=True)
        invalid.write_text("{not json", encoding="utf-8")
        self._write_config(
            tmp_path / ".mcp.json",
            {"fs": {"command": "uvx", "args": ["root-server", "/root"]}},
        )

        self._deny_project_mcp(tmp_path, monkeypatch)

        result = _check_mcp_project_trust(trust_flag=False)

        assert result is False
        combined = self._captured_prompt(capsys)
        assert combined.count('  "fs" (stdio):') == 1, combined
        assert '  "fs" (stdio):  uvx root-server /root' in combined

    def test_distinct_servers_across_configs_all_listed(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Different servers from different project configs are all shown."""
        from deepagents_code.main import _check_mcp_project_trust

        self._write_config(
            tmp_path / ".mcp.json",
            {"alpha": {"command": "uvx", "args": ["alpha"]}},
        )
        self._write_config(
            tmp_path / ".deepagents" / ".mcp.json",
            {"beta": {"command": "uvx", "args": ["beta"]}},
        )

        self._deny_project_mcp(tmp_path, monkeypatch)

        result = _check_mcp_project_trust(trust_flag=False)

        assert result is False
        combined = self._captured_prompt(capsys)
        assert combined.count('  "alpha" (stdio):') == 1, combined
        assert combined.count('  "beta" (stdio):') == 1, combined
