"""Tests for MindRoom agent functionality."""

from __future__ import annotations

import os
import re
import stat
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock, patch

import pytest
from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.knowledge.knowledge import Knowledge
from agno.learn import LearningMachine, LearningMode, UserMemoryConfig, UserProfileConfig
from agno.run import RunContext
from agno.run.agent import RunOutput
from agno.session import AgentSession
from agno.tools.function import Function
from agno.tools.toolkit import Toolkit
from pydantic import ValidationError

from mindroom import prompts
from mindroom.agent_storage import get_agent_runtime_state_dbs
from mindroom.agents import (
    _CULTURE_MANAGER_CACHE,
    _PRIVATE_CULTURE_MANAGER_CACHE,
    _AdditionalContextChunk,
    _apply_preload_cap,
    _load_context_files,
    _prune_toolkit_functions,
    _render_tool_execution_environment,
    _trim_chunk_tails,
    agent_build_can_overlap_file_memory,
    build_agent_toolkit,
    create_agent,
    get_agent_toolkit_names,
)
from mindroom.config.agent import (
    AgentConfig,
    AgentPrivateConfig,
    AgentPrivateKnowledgeConfig,
    CultureConfig,
    TeamConfig,
)
from mindroom.config.knowledge import KnowledgeBaseConfig, KnowledgeGitConfig
from mindroom.config.main import Config
from mindroom.config.models import DefaultsConfig, ModelConfig
from mindroom.constants import ROUTER_AGENT_NAME, RuntimePaths, resolve_runtime_paths
from mindroom.credentials import CredentialsManager, load_scoped_credentials
from mindroom.entity_resolution import managed_entity_power_user_ids_for_room
from mindroom.entity_rooms import get_rooms_for_entity
from mindroom.history.runtime import close_team_runtime_state_dbs
from mindroom.knowledge import resolve_agent_knowledge_access
from mindroom.knowledge.availability import KnowledgeAvailability
from mindroom.matrix.state import MatrixState
from mindroom.prompts import (
    HIDDEN_TOOL_CALLS_PROMPT,
    OPENAI_COMPAT_HISTORY_GUIDANCE,
    WORKSPACE_SKILL_AUTHORING_PROMPT,
)
from mindroom.runtime_resolution import resolve_agent_runtime
from mindroom.teams import materialize_exact_team_members
from mindroom.tool_system.output_files import OUTPUT_PATH_ARGUMENT
from mindroom.tool_system.worker_routing import (
    ToolExecutionIdentity,
    _private_instance_state_root_path,
    agent_state_root_path,
    agent_workspace_root_path,
    private_instance_scope_root_path,
    requires_explicit_private_agent_visibility,
    resolve_agent_owned_path,
    resolve_agent_state_storage_path,
    resolve_unscoped_worker_key,
    resolve_worker_key,
    shared_storage_root,
    tool_execution_identity,
    visible_state_roots_for_worker_key,
    worker_root_path,
)
from mindroom.workspaces import _copy_workspace_template
from tests.identity_helpers import persist_entity_accounts

if TYPE_CHECKING:
    from collections.abc import Callable

    from mindroom.tool_system.worker_routing import WorkerScope


def _runtime_paths(storage_path: Path, *, config_path: Path | None = None) -> RuntimePaths:
    return resolve_runtime_paths(
        config_path=config_path or storage_path / "config.yaml",
        storage_path=storage_path,
    )


def _test_config() -> Config:
    """Create a self-contained test config with standard agents."""
    runtime_paths = _runtime_paths(Path(tempfile.mkdtemp()))
    return _bind_runtime_paths(
        Config(
            agents={
                "general": AgentConfig(
                    display_name="GeneralAgent",
                    role="General assistant",
                    tools=[],
                    rooms=["lobby"],
                ),
                "summary": AgentConfig(
                    display_name="SummaryAgent",
                    role="Summarize content",
                    tools=[],
                    rooms=["lobby"],
                ),
                "calculator": AgentConfig(
                    display_name="CalculatorAgent",
                    role="Solve math problems",
                    tools=["calculator"],
                    rooms=["lobby", "science", "analysis"],
                ),
                "shell": AgentConfig(
                    display_name="ShellAgent",
                    role="Execute shell commands",
                    tools=["shell"],
                    rooms=["lobby", "dev"],
                ),
                "code": AgentConfig(
                    display_name="CodeAgent",
                    role="Generate code",
                    tools=["coding"],
                    rooms=["lobby", "dev"],
                ),
            },
            models={
                "default": ModelConfig(provider="openai", id="gpt-4o-mini"),
                "sonnet": ModelConfig(provider="anthropic", id="claude-sonnet-4-6"),
            },
        ),
        runtime_paths,
    )


def _bind_runtime_paths(config: Config, runtime_paths: RuntimePaths) -> Config:
    """Bind explicit RuntimePaths to a test config."""
    from tests.conftest import bind_runtime_paths  # noqa: PLC0415

    bound_config = bind_runtime_paths(config, runtime_paths)
    persist_entity_accounts(
        bound_config,
        runtime_paths,
        usernames={alias: f"actual_{alias}" for alias in ["router", *bound_config.agents, *bound_config.teams]},
    )
    return bound_config


def _create_agent_for_test(agent_name: str, config: Config, **kwargs: object) -> Agent:
    """Create an agent with the test config's explicit runtime context."""
    from tests.conftest import runtime_paths_for  # noqa: PLC0415

    execution_identity = cast("ToolExecutionIdentity | None", kwargs.pop("execution_identity", None))
    return create_agent(
        agent_name,
        config,
        runtime_paths_for(config),
        execution_identity=execution_identity,
        **kwargs,
    )


def test_managed_entity_power_user_ids_for_room_includes_configured_teams(tmp_path: Path) -> None:
    """Room creation power users should include team bots configured for that room."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(
        Config(
            agents={"general": AgentConfig(display_name="GeneralAgent", rooms=["lobby"])},
            teams={
                "ops": TeamConfig(
                    display_name="OpsTeam",
                    role="Coordinate operations",
                    agents=["general"],
                    rooms=["ops"],
                ),
            },
        ),
        runtime_paths,
    )

    assert managed_entity_power_user_ids_for_room("ops", config, runtime_paths) == [
        "@actual_router:localhost",
        "@actual_ops:localhost",
    ]


def test_get_rooms_for_entity_uses_authored_entity_rooms_only(tmp_path: Path) -> None:
    """Tool-managed triggers should not widen static entity room membership."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(
        Config(
            agents={"general": AgentConfig(display_name="GeneralAgent", rooms=["lobby"])},
        ),
        runtime_paths,
    )

    assert get_rooms_for_entity("general", config) == ["lobby"]
    assert get_rooms_for_entity(ROUTER_AGENT_NAME, config) == ["lobby"]


class _TestVectorDb:
    def exists(self) -> bool:
        return True

    def create(self) -> None:
        return

    def search(
        self,
        *,
        query: str,
        limit: int,
        filters: dict[str, object] | list[object] | None = None,
    ) -> list[object]:
        _ = (query, limit, filters)
        return []


def _queryable_knowledge_handle() -> Knowledge:
    return Knowledge(vector_db=_TestVectorDb())


def _patch_published_knowledge(
    monkeypatch: pytest.MonkeyPatch,
    knowledge_by_base: dict[str, Knowledge],
) -> None:
    def _get_published_index(base_id: str, **_kwargs: object) -> object:
        return SimpleNamespace(
            key=SimpleNamespace(base_id=base_id),
            index=SimpleNamespace(
                knowledge=knowledge_by_base[base_id],
                state=SimpleNamespace(last_refresh_at=None, last_published_at=None),
            ),
            availability=KnowledgeAvailability.READY,
            state=None,
        )

    monkeypatch.setattr("mindroom.knowledge.utils.get_published_index", _get_published_index)


def test_agent_identity_prompt_can_be_overridden_from_config() -> None:
    """Agent identity prompt assembly should honor the configured template override."""
    config = _test_config()
    config.prompts = {
        "AGENT_IDENTITY_CONTEXT_TEMPLATE": (
            "## Custom Identity\n"
            "Name={display_name}; Matrix={matrix_id}; Provider={model_provider}; Model={model_id}.\n"
            "{openai_compat_history_guidance}"
        ),
    }

    agent = _create_agent_for_test("general", config)
    openai_compat_agent = _create_agent_for_test(
        "general",
        config,
        include_openai_compat_guidance=True,
    )

    assert "## Custom Identity" in agent.role
    assert "Name=GeneralAgent;" in agent.role
    assert "## Your Identity" not in agent.role
    assert "## Custom Identity" in openai_compat_agent.role
    assert "Matrix=not available in OpenAI-compatible API" in openai_compat_agent.role
    assert "OpenAI-compatible API" in openai_compat_agent.role


def test_default_mind_role_includes_effective_matrix_homeserver(tmp_path: Path) -> None:
    """The default Mind should receive the homeserver resolved from its runtime environment."""
    (tmp_path / ".env").write_text("MATRIX_HOMESERVER=https://from-env-file.example\n", encoding="utf-8")
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={"MATRIX_HOMESERVER": "https://matrix.self-hosted.example"},
    )
    config = _bind_runtime_paths(
        Config(
            agents={
                "mind": AgentConfig(display_name="Mind", role="Setup assistant", tools=[]),
                "general": AgentConfig(display_name="General", role="General assistant", tools=[]),
            },
            models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
        ),
        runtime_paths,
    )

    mind = _create_agent_for_test("mind", config)
    general = _create_agent_for_test("general", config)

    assert "## MindRoom Runtime" in mind.role
    assert "Active Matrix homeserver: `https://matrix.self-hosted.example`" in mind.role
    assert "## MindRoom Runtime" not in general.role


def test_agent_identity_prompt_uses_persisted_current_matrix_id(tmp_path: Path) -> None:
    """Agent identity prompt should describe the live persisted Matrix account ID."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(
        Config(
            agents={
                "general": AgentConfig(
                    display_name="GeneralAgent",
                    role="General assistant",
                    tools=[],
                    rooms=["lobby"],
                ),
            },
            models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
        ),
        runtime_paths,
    )
    state = MatrixState.load(runtime_paths=runtime_paths)
    state.add_account("agent_general", "mindroom_general_oldns", "pw", domain="localhost")
    state.save(runtime_paths=runtime_paths)

    agent = _create_agent_for_test("general", config)

    assert "@mindroom_general_oldns:localhost" in agent.role
    assert "@mindroom_general:localhost" not in agent.role


def test_create_agent_includes_openai_compat_guidance_only_when_requested() -> None:
    """OpenAI-compatible prompt guidance should be opt-in at agent construction time."""
    config = _test_config()

    matrix_agent = _create_agent_for_test("general", config)
    openai_compat_agent = _create_agent_for_test(
        "general",
        config,
        include_openai_compat_guidance=True,
    )

    assert OPENAI_COMPAT_HISTORY_GUIDANCE not in matrix_agent.role
    assert OPENAI_COMPAT_HISTORY_GUIDANCE in openai_compat_agent.role
    assert "OpenAI-compatible API" in openai_compat_agent.role
    assert "Matrix ID:" not in openai_compat_agent.role
    assert "## Matrix Reply Targeting" not in openai_compat_agent.role


def test_create_agent_includes_matrix_reply_targeting_policy() -> None:
    """Agents should understand when the dispatcher requires explicit Matrix mentions."""
    config = _test_config()

    agent = _create_agent_for_test("general", config)

    assert "## Matrix Reply Targeting" in agent.role
    assert "explicit Matrix mention" in agent.role
    assert "multi-agent, multi-team, or multi-human" in agent.role
    assert "not dispatched" in agent.role
    assert "natural-language addressing" in agent.role


def test_config_round_trips_structured_agent_tool_entries() -> None:
    """Structured tool entries should stay authored while runtime access stays name-based."""
    runtime_paths = _runtime_paths(Path(tempfile.mkdtemp()))
    config = Config.validate_with_runtime(
        {
            "agents": {
                "openclaw": {
                    "display_name": "OpenClaw",
                    "role": "Coding agent",
                    "tools": [
                        {
                            "shell": {
                                "extra_env_passthrough": "GITEA_TOKEN, WHISPER_URL",
                                "shell_path_prepend": "/run/wrappers/bin",
                            },
                        },
                        "browser",
                    ],
                    "rooms": ["lobby"],
                },
            },
            "models": {
                "default": {
                    "provider": "openai",
                    "id": "gpt-4o-mini",
                },
            },
        },
        runtime_paths,
    )

    agent_config = config.agents["openclaw"]
    assert agent_config.tool_names == ["shell", "browser"]
    assert agent_config.get_tool_overrides("shell") == {
        "extra_env_passthrough": ["GITEA_TOKEN", "WHISPER_URL"],
        "shell_path_prepend": ["/run/wrappers/bin"],
    }
    assert config.authored_model_dump()["agents"]["openclaw"]["tools"] == [
        {
            "shell": {
                "extra_env_passthrough": "GITEA_TOKEN, WHISPER_URL",
                "shell_path_prepend": "/run/wrappers/bin",
            },
        },
        "browser",
    ]
    assert config.resolve_entity("openclaw").tool_runtime_overrides("shell") == {
        "extra_env_passthrough": "GITEA_TOKEN, WHISPER_URL",
        "shell_path_prepend": "/run/wrappers/bin",
    }


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_calculator(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that the calculator agent is created correctly."""
    config = _test_config()
    agent = _create_agent_for_test("calculator", config=config)
    assert isinstance(agent, Agent)
    assert agent.name == "CalculatorAgent"


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_general(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that the general agent is created correctly."""
    config = _test_config()
    config.agents["general"].instructions = ["Use the configured instruction."]
    agent = _create_agent_for_test("general", config=config)
    assert isinstance(agent, Agent)
    assert agent.name == "GeneralAgent"
    assert "General assistant" in agent.role
    assert "## Core Expertise" not in agent.role
    assert "Use the configured instruction." in agent.instructions
    assert isinstance(agent.learning, LearningMachine)
    assert isinstance(agent.learning.user_profile, UserProfileConfig)
    assert agent.learning.user_profile.mode is LearningMode.ALWAYS
    assert isinstance(agent.learning.user_memory, UserMemoryConfig)
    assert agent.learning.user_memory.mode is LearningMode.ALWAYS


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_role_describes_local_tool_execution_environment(
    _mock_storage: MagicMock,  # noqa: PT019
    tmp_path: Path,
) -> None:
    """The prompt should identify an entirely local effective tool surface."""
    config = _test_config()
    config.agents["calculator"].include_default_tools = False
    runtime_paths = _runtime_paths(tmp_path)

    agent = _create_agent_for_test("calculator", config=_bind_runtime_paths(config, runtime_paths))

    assert "## Tool Execution Environment" in agent.role
    assert "All available tools run in the primary MindRoom runtime: `calculator`." in agent.role
    assert "No tools use a worker runtime." in agent.role
    assert "Worker backend:" not in agent.role


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_agent_role_describes_mixed_dedicated_worker_routing(
    _mock_storage: MagicMock,  # noqa: PT019
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """The prompt should identify mixed local and dedicated-worker tools."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.agents["general"].tools = ["shell", "calculator"]
    config.agents["general"].include_default_tools = False
    config.agents["general"].worker_tools = ["shell"]
    config.agents["general"].worker_scope = "user_agent"
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={
            "MINDROOM_WORKER_BACKEND": "kubernetes",
            "MINDROOM_SANDBOX_PROXY_TOKEN": "test-token",
        },
    )

    agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, runtime_paths))

    assert "Local tools (primary MindRoom runtime): `calculator`." in agent.role
    assert "Worker-routed tools: `shell`." in agent.role
    assert "Worker backend: `kubernetes`." in agent.role
    assert "Worker reuse: one runtime used only by this user-agent pair." in agent.role
    assert "Worker state is reused across turns for that scope." in agent.role
    assert "After the configured idle timeout, the deployment scales to zero" in agent.role
    assert "persisted files and caches remain until an operator deletes that worker state." in agent.role
    assert "Execution location is determined per tool; this agent is not sandboxed as a whole." in agent.role


@pytest.mark.parametrize(
    ("worker_scope", "expected_reuse"),
    [
        (None, "one runtime for this agent within the current tenant or account"),
        ("shared", "one runtime for this agent, shared by all users"),
        ("user", "one runtime for this user, shared across this user's agents"),
        ("user_agent", "one runtime used only by this user-agent pair"),
    ],
)
def test_tool_execution_environment_explains_dedicated_worker_scope(
    tmp_path: Path,
    worker_scope: WorkerScope | None,
    expected_reuse: str,
) -> None:
    """Each dedicated-worker scope should be translated into its runtime boundary."""
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={"MINDROOM_WORKER_BACKEND": "kubernetes"},
    )

    rendered = _render_tool_execution_environment(
        runtime_paths=runtime_paths,
        local_tool_names=(),
        worker_routed_tool_names=("shell",),
        worker_scope=worker_scope,
    )

    assert f"Worker reuse: {expected_reuse}." in rendered
    assert "Worker state is reused across turns for that scope." in rendered
    assert "After the configured idle timeout, the deployment scales to zero" in rendered


def test_tool_execution_environment_explains_static_runner_without_persistence_claim(tmp_path: Path) -> None:
    """The shared static runner should not claim dedicated persisted state."""
    runtime_paths = _runtime_paths(tmp_path)

    rendered = _render_tool_execution_environment(
        runtime_paths=runtime_paths,
        local_tool_names=(),
        worker_routed_tool_names=("shell",),
        worker_scope="user_agent",
    )

    assert "Worker backend: `static_runner`." in rendered
    assert "requests use the configured static runner" in rendered
    assert "no per-user or per-agent worker state boundary is selected" in rendered
    assert "persisted files and caches" not in rendered


def test_tool_execution_environment_explains_docker_idle_lifecycle(tmp_path: Path) -> None:
    """Docker wording should state exactly what idle cleanup retains."""
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={"MINDROOM_WORKER_BACKEND": "docker"},
    )

    rendered = _render_tool_execution_environment(
        runtime_paths=runtime_paths,
        local_tool_names=(),
        worker_routed_tool_names=("shell",),
        worker_scope="user_agent",
    )

    assert "After the configured idle timeout, the container stops" in rendered
    assert "persisted files and caches remain until an operator deletes that worker state." in rendered


@patch("mindroom.agents.get_tool_by_name", side_effect=ImportError("dependency missing"))
@patch("mindroom.agent_storage.SqliteDb")
def test_agent_role_omits_tool_that_failed_to_build(
    _mock_storage: MagicMock,  # noqa: PT019
    _mock_get_tool_by_name: MagicMock,  # noqa: PT019
) -> None:
    """The prompt should not advertise a toolkit that is unavailable at runtime."""
    config = _test_config()
    config.agents["calculator"].include_default_tools = False

    agent = _create_agent_for_test("calculator", config=config)

    assert agent.tools == []
    assert "No tools are available in this runtime." in agent.role
    assert "Worker backend:" not in agent.role


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_role_keeps_direct_toolkit_local_when_worker_routing_is_requested(
    _mock_storage: MagicMock,  # noqa: PT019
    tmp_path: Path,
) -> None:
    """Agent-context toolkits should stay local because they bypass registry proxy wrapping."""
    config = _test_config()
    config.agents["general"].tools = ["report_publishing"]
    config.agents["general"].include_default_tools = False
    config.agents["general"].worker_tools = ["report_publishing"]
    config.agents["general"].worker_scope = "user_agent"
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={
            "MINDROOM_WORKER_BACKEND": "kubernetes",
            "MINDROOM_SANDBOX_PROXY_TOKEN": "test-token",
        },
    )

    agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, runtime_paths))

    assert "All available tools run in the primary MindRoom runtime: `report_publishing`." in agent.role
    assert "No tools use a worker runtime." in agent.role
    assert "Worker backend:" not in agent.role


@patch("mindroom.agent_storage.SqliteDb")
def test_restricted_agent_omits_tool_execution_environment(
    _mock_storage: MagicMock,  # noqa: PT019
) -> None:
    """Restricted in-process agents should not advertise unavailable capabilities."""
    config = _test_config()

    agent = _create_agent_for_test("general", config=config, disable_runtime_capabilities=True)

    assert "## Tool Execution Environment" not in agent.role


def test_get_agent_runtime_state_dbs_accepts_backend_neutral_agno_storage() -> None:
    """Runtime DB discovery should not require SQLite-specific storage handles."""
    history_db = InMemoryDb()
    learning_db = InMemoryDb()
    agent = Agent(db=history_db, learning=LearningMachine(db=learning_db))

    assert get_agent_runtime_state_dbs(agent) == (history_db, learning_db)


def test_get_agent_runtime_state_dbs_includes_learning_storage(tmp_path: Path) -> None:
    """Runtime-owned agent DB cleanup should include the separate learning storage."""
    config = _test_config()
    config.models = {"default": ModelConfig(provider="ollama", id="test-model")}
    config = _bind_runtime_paths(config, _runtime_paths(tmp_path))

    agent = _create_agent_for_test("general", config=config)

    assert isinstance(agent.learning, LearningMachine)
    history_db, learning_db = get_agent_runtime_state_dbs(agent)
    assert history_db is agent.db
    assert learning_db is agent.learning.db
    assert history_db is not learning_db

    if learning_db is not None:
        learning_db.close()
    if history_db is not None:
        history_db.close()


@patch("mindroom.agent_storage.SqliteDb")
def test_hidden_tool_calls_prompt_is_injected(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Agents with hidden tool calls get a prompt hint to avoid narrating tool usage."""
    config = _test_config()
    config.agents["general"].show_tool_calls = False

    agent = _create_agent_for_test("general", config=config)

    assert HIDDEN_TOOL_CALLS_PROMPT in agent.instructions


@pytest.mark.parametrize(
    ("factory", "field_name"),
    [
        (lambda: DefaultsConfig(num_history_runs=0), "num_history_runs"),
        (lambda: DefaultsConfig(num_history_messages=0), "num_history_messages"),
        (lambda: AgentConfig(display_name="Agent", num_history_runs=0), "num_history_runs"),
        (lambda: AgentConfig(display_name="Agent", num_history_messages=0), "num_history_messages"),
        (
            lambda: TeamConfig(
                display_name="Team",
                role="Coordinate work",
                agents=["general"],
                num_history_runs=0,
            ),
            "num_history_runs",
        ),
        (
            lambda: TeamConfig(
                display_name="Team",
                role="Coordinate work",
                agents=["general"],
                num_history_messages=0,
            ),
            "num_history_messages",
        ),
    ],
)
def test_history_limits_require_positive_values(factory: Callable[[], object], field_name: str) -> None:
    """Zero history limits are rejected so they cannot diverge from Agno semantics."""
    with pytest.raises(ValidationError, match=field_name):
        factory()


@patch("mindroom.agent_storage.SqliteDb")
def test_scheduler_tool_enabled_by_default(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """All agents should get the scheduler tool even when not explicitly configured."""
    config = _test_config()
    config.agents["summary"].tools = []

    agent = _create_agent_for_test("summary", config=config)
    tool_names = [tool.name for tool in agent.tools]

    assert "scheduler" in tool_names


@patch("mindroom.agent_storage.SqliteDb")
def test_configurable_default_tools_are_applied(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """defaults.tools should be merged into every agent's configured tools."""
    config = _test_config()
    config.defaults.tools = ["scheduler", "calculator"]
    config.agents["summary"].tools = []

    agent = _create_agent_for_test("summary", config=config)
    tool_names = [tool.name for tool in agent.tools]

    assert "scheduler" in tool_names
    assert "calculator" in tool_names


@patch("mindroom.agent_storage.SqliteDb")
def test_default_tools_do_not_duplicate_agent_tools(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """An agent tool already present should not be duplicated by defaults.tools."""
    config = _test_config()
    config.defaults.tools = ["scheduler"]
    config.agents["summary"].tools = ["scheduler"]

    agent = _create_agent_for_test("summary", config=config)
    tool_names = [tool.name for tool in agent.tools]

    assert tool_names.count("scheduler") == 1


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_include_default_tools_false_skips_config_defaults(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Agent include_default_tools=False should skip defaults.tools entirely."""
    config = _test_config()
    config.defaults.tools = ["scheduler", "calculator"]
    config.agents["summary"].tools = []
    config.agents["summary"].include_default_tools = False

    agent = _create_agent_for_test("summary", config=config)
    tool_names = [tool.name for tool in agent.tools]

    assert "scheduler" not in tool_names
    assert "calculator" not in tool_names


def test_openclaw_compat_expands_to_implied_tools() -> None:
    """openclaw_compat should stay in the list and bring its implied tools."""
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False

    assert config.resolve_entity("summary").available_tools == [
        "openclaw_compat",
        "shell",
        "coding",
        "duckduckgo",
        "website",
        "browser",
        "scheduler",
        "subagents",
        "matrix_message",
        "attachments",
        "matrix_room",
    ]


def test_openclaw_compat_expansion_dedupes_preserving_order() -> None:
    """Implied tool expansion should preserve first-seen order while deduping entries."""
    config = _test_config()
    config.agents["summary"].tools = [
        "browser",
        "openclaw_compat",
        "shell",
        "coding",
    ]
    config.defaults.tools = ["openclaw_compat", "python", "scheduler"]

    assert config.resolve_entity("summary").available_tools == [
        "browser",
        "openclaw_compat",
        "shell",
        "coding",
        "python",
        "scheduler",
        "duckduckgo",
        "website",
        "subagents",
        "matrix_message",
        "attachments",
        "matrix_room",
    ]


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_uses_native_tool_lookups_for_openclaw_compat(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Agent construction should look up openclaw_compat and all its implied tools."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False

    _create_agent_for_test("summary", config=config)

    looked_up_tools = [call.args[0] for call in mock_get_tool_by_name.call_args_list]
    assert looked_up_tools == config.resolve_entity("summary").available_tools


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_passes_merged_tool_config_overrides_to_registered_tools(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Agent construction should merge defaults and agent overrides before tool lookup."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.defaults.tools = [
        {"shell": {"extra_env_passthrough": "DAWARICH_*", "enable_run_shell_command": False}},
    ]
    config.agents["general"].tools = [{"shell": {"enable_run_shell_command": True}}]

    _create_agent_for_test("general", config=config)

    shell_call = next(call for call in mock_get_tool_by_name.call_args_list if call.args[0] == "shell")
    assert shell_call.kwargs["tool_config_overrides"] == {
        "extra_env_passthrough": "DAWARICH_*",
        "enable_run_shell_command": True,
    }


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_bootstraps_tool_registry_once_for_multiple_tools(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Agent construction should not reload plugin/MCP registry state per toolkit."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.agents["general"].tools = ["shell", "coding", "duckduckgo", "website"]
    config.agents["general"].include_default_tools = False

    with patch("mindroom.agents.ensure_tool_registry_loaded") as mock_ensure_registry:
        _create_agent_for_test("general", config=config)

    assert len(mock_get_tool_by_name.call_args_list) == 4
    assert mock_ensure_registry.call_count == 1


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_keeps_runtime_base_dir_separate_from_authored_tool_config(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """Workspace base_dir should stay runtime-only while authored config uses its own override channel."""
    mock_get_tool_by_name.return_value = MagicMock()

    workspace = agent_workspace_root_path(tmp_path, "general")
    workspace.mkdir(parents=True, exist_ok=True)
    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].tools = [{"shell": {"enable_run_shell_command": False}}]
    config.agents["general"].include_default_tools = False

    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert mock_get_tool_by_name.call_args is not None
    assert mock_get_tool_by_name.call_args.kwargs["tool_config_overrides"] == {
        "enable_run_shell_command": False,
    }
    assert mock_get_tool_by_name.call_args.kwargs["tool_init_overrides"] == {"base_dir": str(workspace)}


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_continues_when_implied_tool_import_fails(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Optional dependency import failures should not abort agent creation with implied tools."""

    def _lookup_tool(
        name: str,
        _runtime_paths: object = None,
        *,
        credentials_manager: object | None = None,
        tool_config_overrides: dict[str, object] | None = None,
        tool_init_overrides: dict[str, object] | None = None,
        runtime_overrides: dict[str, object] | None = None,
        shared_storage_root_path: object | None = None,
        worker_tools_override: list[str] | None = None,
        allowed_shared_services: frozenset[str] | None = None,
        tool_output_workspace_root: object | None = None,
        tool_output_auto_save_threshold_bytes: int = 50 * 1024,
        worker_target: object | None = None,
    ) -> MagicMock:
        del (
            _runtime_paths,
            credentials_manager,
            tool_config_overrides,
            tool_init_overrides,
            runtime_overrides,
            shared_storage_root_path,
            worker_tools_override,
            allowed_shared_services,
            tool_output_workspace_root,
            tool_output_auto_save_threshold_bytes,
            worker_target,
        )
        if name == "browser":
            missing_dependency_message = "No module named 'playwright'"
            raise ImportError(missing_dependency_message)
        tool = MagicMock()
        tool.name = name
        return tool

    mock_get_tool_by_name.side_effect = _lookup_tool

    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False

    agent = _create_agent_for_test("summary", config=config)

    tool_names = [tool.name for tool in agent.tools]
    assert "browser" not in tool_names
    assert "openclaw_compat" in tool_names
    assert "shell" in tool_names
    assert "matrix_message" in tool_names


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_continues_when_tool_lookup_reports_unknown_tool(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Unknown or stale tool names should still be warned about and skipped."""

    def _lookup_tool(
        name: str,
        _runtime_paths: object = None,
        *,
        credentials_manager: object | None = None,
        tool_config_overrides: dict[str, object] | None = None,
        tool_init_overrides: dict[str, object] | None = None,
        runtime_overrides: dict[str, object] | None = None,
        shared_storage_root_path: object | None = None,
        worker_tools_override: list[str] | None = None,
        allowed_shared_services: frozenset[str] | None = None,
        tool_output_workspace_root: object | None = None,
        tool_output_auto_save_threshold_bytes: int = 50 * 1024,
        worker_target: object | None = None,
    ) -> MagicMock:
        del (
            _runtime_paths,
            credentials_manager,
            tool_config_overrides,
            tool_init_overrides,
            runtime_overrides,
            shared_storage_root_path,
            worker_tools_override,
            allowed_shared_services,
            tool_output_workspace_root,
            tool_output_auto_save_threshold_bytes,
            worker_target,
        )
        if name == "stale_tool":
            msg = "Unknown tool: stale_tool"
            raise ValueError(msg)
        tool = MagicMock()
        tool.name = name
        return tool

    mock_get_tool_by_name.side_effect = _lookup_tool

    config = _test_config()
    config.agents["general"].tools = ["stale_tool", "shell"]
    config.agents["general"].include_default_tools = False

    agent = _create_agent_for_test("general", config=config)

    assert [tool.name for tool in agent.tools] == ["shell"]


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_expands_openclaw_compat_for_worker_tool_overrides(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Worker override list should receive expanded tool names including openclaw_compat."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False
    config.agents["summary"].worker_tools = ["openclaw_compat"]

    _create_agent_for_test("summary", config=config)

    expected_worker_tools = config.expand_tool_names(["openclaw_compat"])
    worker_overrides = [call.kwargs["worker_tools_override"] for call in mock_get_tool_by_name.call_args_list]
    assert worker_overrides
    assert all(override == expected_worker_tools for override in worker_overrides)


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_uses_memory_file_workspace_for_base_dir_tools(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """Shared file-backed agents should point tools at the canonical workspace root."""
    mock_get_tool_by_name.return_value = MagicMock()

    workspace = agent_workspace_root_path(tmp_path, "general")
    workspace.mkdir(parents=True, exist_ok=True)
    (workspace / "README.md").write_text("Canonical workspace.\n", encoding="utf-8")
    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].tools = ["coding", "shell", "duckduckgo"]
    config.agents["general"].include_default_tools = False

    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    overrides_by_tool = {
        call.args[0]: call.kwargs.get("tool_init_overrides") for call in mock_get_tool_by_name.call_args_list
    }
    assert overrides_by_tool["coding"] == {"base_dir": str(workspace)}
    assert overrides_by_tool["shell"] == {"base_dir": str(workspace)}
    assert overrides_by_tool["duckduckgo"] is None


def test_direct_agent_toolkit_exposes_output_redirect_for_workspace_agent(tmp_path: Path) -> None:
    """MindRoom-owned direct toolkits should use the same central output-file wrapper."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].memory_backend = "file"
    agent_runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=None,
        create=True,
    )

    toolkit = build_agent_toolkit(
        "memory",
        agent_name="general",
        config=config,
        runtime_paths=runtime_paths,
        worker_tools=[],
        runtime_overrides=None,
        agent_runtime=agent_runtime,
        execution_identity=None,
    )

    assert toolkit is not None
    function = toolkit.async_functions["list_memories"].model_copy(deep=True)
    function.process_entrypoint()
    assert OUTPUT_PATH_ARGUMENT in function.parameters["properties"]


def test_memory_toolkit_is_omitted_when_agent_memory_is_disabled(tmp_path: Path) -> None:
    """Agents with an effective disabled memory backend should not receive MemoryTools."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.memory.backend = "mem0"
    config.agents["general"].memory_backend = "none"
    agent_runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=None,
        create=True,
    )

    toolkit = build_agent_toolkit(
        "memory",
        agent_name="general",
        config=config,
        runtime_paths=runtime_paths,
        worker_tools=[],
        runtime_overrides=None,
        agent_runtime=agent_runtime,
        execution_identity=None,
    )

    assert toolkit is None


def test_resolve_agent_workspace_uses_canonical_agent_workspace_for_file_memory(tmp_path: Path) -> None:
    """Shared file-backed agents should resolve to the canonical agent workspace root."""
    config = _test_config()
    config.agents["general"].memory_backend = "file"
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)

    workspace = resolve_agent_runtime(
        "general",
        bound_config,
        runtime_paths,
        execution_identity=None,
        create=True,
    ).workspace

    expected_workspace = agent_workspace_root_path(tmp_path, "general")
    assert workspace is not None
    assert workspace.root == expected_workspace
    assert not (runtime_paths.config_dir / "workspace").exists()


def test_resolve_agent_workspace_rejects_private_root_symlink_escape(tmp_path: Path) -> None:
    """Private roots must not resolve outside the canonical private-instance state root."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(per="user", root="mind_data")
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="$thread",
    )
    state_root = resolve_agent_runtime(
        "general",
        bound_config,
        runtime_paths,
        execution_identity=identity,
    ).state_root
    state_root.mkdir(parents=True, exist_ok=True)
    outside_root = tmp_path / "outside"
    outside_root.mkdir(parents=True, exist_ok=True)
    (state_root / "mind_data").symlink_to(outside_root, target_is_directory=True)

    with pytest.raises(ValueError, match=re.escape("private.root must stay within the workspace root")):
        resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
        )


def test_resolve_agent_workspace_rejects_private_state_root_symlink_escape(tmp_path: Path) -> None:
    """Private state roots must not be symlinked outside their canonical scope root."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(per="user", root="mind_data")
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="$thread",
    )
    worker_key = resolve_worker_key("user", identity, agent_name="general")
    assert worker_key is not None
    canonical_state_root = _private_instance_state_root_path(
        runtime_paths.storage_root,
        worker_key=worker_key,
        agent_name="general",
    )
    canonical_state_root.parent.mkdir(parents=True, exist_ok=True)
    outside_root = tmp_path / "outside"
    outside_root.mkdir(parents=True, exist_ok=True)
    canonical_state_root.symlink_to(outside_root, target_is_directory=True)

    with pytest.raises(ValueError, match="Private state root must stay within the private scope root"):
        resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
        )


def test_resolve_agent_runtime_rejects_private_scope_root_symlink_escape(tmp_path: Path) -> None:
    """Private state roots must stay under the resolved storage root even if the scope root is symlinked."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(per="user", root="mind_data")
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="$thread",
    )
    worker_key = resolve_worker_key("user", identity, agent_name="general")
    assert worker_key is not None
    scope_root = private_instance_scope_root_path(runtime_paths.storage_root, worker_key)
    scope_root.parent.mkdir(parents=True, exist_ok=True)
    outside_root = tmp_path / "outside"
    outside_root.mkdir(parents=True, exist_ok=True)
    scope_root.symlink_to(outside_root, target_is_directory=True)

    with pytest.raises(ValueError, match="Private scope root must stay within the canonical root"):
        resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
        )


def test_resolve_agent_workspace_rejects_private_context_symlink_escape(tmp_path: Path) -> None:
    """Private context files must not resolve outside the private workspace root."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        context_files=["notes/SOUL.md"],
    )
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="$thread",
    )
    workspace = resolve_agent_runtime(
        "general",
        bound_config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    ).workspace
    assert workspace is not None
    outside_root = tmp_path / "outside"
    outside_root.mkdir(parents=True, exist_ok=True)
    notes_link = workspace.root / "notes"
    notes_link.symlink_to(outside_root, target_is_directory=True)

    with pytest.raises(
        ValueError,
        match=re.escape("private.context_files must stay within the workspace root"),
    ):
        resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
        )


def test_resolve_agent_runtime_uses_shared_agent_roots_for_shared_agents(tmp_path: Path) -> None:
    """Shared agents should resolve one canonical shared state root with no worker key."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.execution.is_private is False
    assert runtime.execution.worker_key is None
    assert runtime.state_root == agent_state_root_path(tmp_path, "general")
    assert runtime.workspace is None
    assert runtime.tool_base_dir is None
    assert runtime.file_memory_root is None


def test_runtime_resolution_exports_public_resolved_agent_execution_contract(tmp_path: Path) -> None:
    """The runtime-resolution seam should return a public result type."""
    from mindroom import runtime_resolution  # noqa: PLC0415

    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)

    assert "ResolvedAgentExecution" in runtime_resolution.__all__

    resolved_execution = runtime_resolution.resolve_agent_execution(
        "general",
        config,
        execution_identity=None,
    )

    assert type(resolved_execution) is runtime_resolution.ResolvedAgentExecution
    assert resolved_execution.agent_name == "general"
    assert resolved_execution.is_private is False


def test_resolve_agent_runtime_keeps_user_scope_worker_key_for_shared_agents(tmp_path: Path) -> None:
    """Shared scoped agents should still resolve their worker key from execution identity."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].worker_scope = "user"
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="s1",
    )

    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    )

    assert runtime.execution.is_private is False
    assert runtime.execution.execution_scope == "user"
    assert runtime.execution.worker_key == resolve_worker_key("user", identity, agent_name="general")
    assert runtime.state_root == agent_state_root_path(tmp_path, "general")
    assert runtime.workspace is None
    assert runtime.tool_base_dir is None
    assert runtime.file_memory_root is None


def test_resolve_agent_runtime_requires_explicit_shared_execution_identity(tmp_path: Path) -> None:
    """Shared worker scope should not infer worker keys from ambient runtime context."""
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={"CUSTOMER_ID": "tenant-123"},
    )
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].worker_scope = "shared"

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.execution.is_private is False
    assert runtime.execution.execution_scope == "shared"
    assert runtime.execution.worker_key is None
    assert runtime.state_root == agent_state_root_path(tmp_path, "general")
    assert runtime.workspace is None


def test_resolve_agent_runtime_uses_private_instance_roots_for_private_agents(
    tmp_path: Path,
    build_private_template_dir: Callable[..., Path],
) -> None:
    """Private agents should resolve requester-local state, workspace, and worker key together."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    template_dir = build_private_template_dir("runtime_template")
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir=str(template_dir),
        context_files=["USER.md"],
    )
    config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="s1",
    )

    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    )
    expected_worker_key = resolve_worker_key("user", identity, agent_name="general")
    assert expected_worker_key is not None
    assert runtime.execution.is_private is True
    assert runtime.execution.worker_key == expected_worker_key
    assert runtime.state_root == _private_instance_state_root_path(
        tmp_path,
        worker_key=expected_worker_key,
        agent_name="general",
    )
    assert runtime.workspace is not None
    assert runtime.workspace.root == runtime.state_root / "mind_data"
    assert runtime.tool_base_dir == runtime.workspace.root
    assert runtime.file_memory_root == runtime.workspace.root


def test_resolve_agent_runtime_creates_workspace_knowledge_links_for_workspace_local_shared_bases(
    tmp_path: Path,
) -> None:
    """Workspace-local shared knowledge should be exposed through a canonical workspace symlink."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    knowledge_root = tmp_path / "agents" / "general" / "workspace" / "research"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)
    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert knowledge_link.is_symlink()
    assert knowledge_link.resolve() == knowledge_root.resolve()


def test_resolve_agent_runtime_skips_workspace_knowledge_links_for_external_shared_bases(tmp_path: Path) -> None:
    """Shared knowledge outside the workspace should not get a misleading in-workspace alias."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    knowledge_root = tmp_path / "research"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert not knowledge_link.exists()
    assert not knowledge_link.is_symlink()


def test_resolve_agent_runtime_creates_workspace_knowledge_links_for_private_bases(tmp_path: Path) -> None:
    """PrivateAgentKnowledge should also surface through workspace-local symlinks."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        knowledge=AgentPrivateKnowledgeConfig(path="kb_repo"),
    )
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="s1",
    )

    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    )
    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    )

    assert runtime.workspace is not None
    private_base_id = config.resolve_entity("general").private_knowledge_base_id
    assert private_base_id is not None
    knowledge_link = runtime.workspace.root / "knowledge" / private_base_id
    assert knowledge_link.is_symlink()
    assert knowledge_link.resolve() == (runtime.workspace.root / "kb_repo").resolve()


def test_resolve_agent_runtime_removes_stale_workspace_knowledge_links(tmp_path: Path) -> None:
    """Removing a bound knowledge base should remove its canonical workspace alias."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    knowledge_root = tmp_path / "agents" / "general" / "workspace" / "research"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert knowledge_link.is_symlink()

    config.agents["general"].knowledge_bases = []

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert not knowledge_link.exists()
    assert not knowledge_link.is_symlink()


def test_resolve_agent_runtime_reuses_existing_workspace_local_knowledge_directory(tmp_path: Path) -> None:
    """A knowledge base already rooted at the canonical path should be reused as-is."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    knowledge_root = tmp_path / "agents" / "general" / "workspace" / "knowledge" / "research"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert knowledge_link.exists()
    assert not knowledge_link.is_symlink()


def test_resolve_agent_runtime_skips_workspace_knowledge_links_for_targets_below_canonical_alias_path(
    tmp_path: Path,
) -> None:
    """A knowledge base already nested under the canonical alias path should be reused without a new alias."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    knowledge_root = tmp_path / "agents" / "general" / "workspace" / "knowledge" / "research" / "docs"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert knowledge_link.exists()
    assert knowledge_link.is_dir()
    assert not knowledge_link.is_symlink()
    assert (knowledge_link / "docs").resolve() == knowledge_root.resolve()


def test_resolve_agent_runtime_preserves_configured_external_workspace_knowledge_symlink(tmp_path: Path) -> None:
    """A configured canonical knowledge symlink should not be deleted just because it points outside the workspace."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    external_root = tmp_path / "external_repo"
    external_root.mkdir(parents=True, exist_ok=True)
    knowledge_root = tmp_path / "agents" / "general" / "workspace" / "knowledge" / "research"
    knowledge_root.parent.mkdir(parents=True, exist_ok=True)
    knowledge_root.symlink_to(external_root, target_is_directory=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    knowledge_link = runtime.workspace.root / "knowledge" / "research"
    assert knowledge_link.is_symlink()
    assert knowledge_link.resolve() == external_root.resolve()


def test_resolve_agent_runtime_preserves_configured_workspace_local_knowledge_symlink_when_unbound(
    tmp_path: Path,
) -> None:
    """A configured canonical knowledge symlink should survive agent unbinding when it is the real knowledge root."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    workspace_root = tmp_path / "agents" / "general" / "workspace"
    target_root = workspace_root / "docs" / "research"
    target_root.mkdir(parents=True, exist_ok=True)
    knowledge_root = workspace_root / "knowledge" / "research"
    knowledge_root.parent.mkdir(parents=True, exist_ok=True)
    knowledge_root.symlink_to(target_root, target_is_directory=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(path=str(knowledge_root))

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    assert knowledge_root.is_symlink()
    assert knowledge_root.resolve() == target_root.resolve()

    config.agents["general"].knowledge_bases = []

    runtime = resolve_agent_runtime("general", config, runtime_paths, execution_identity=None, create=True)

    assert runtime.workspace is not None
    assert knowledge_root.is_symlink()
    assert knowledge_root.resolve() == target_root.resolve()


def test_resolve_agent_runtime_skips_workspace_knowledge_links_for_private_root_dot_path(tmp_path: Path) -> None:
    """A private knowledge path of '.' should not mirror the whole workspace inside itself."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        knowledge=AgentPrivateKnowledgeConfig(path="."),
    )
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="s1",
    )

    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    )

    assert runtime.workspace is not None
    private_base_id = config.resolve_entity("general").private_knowledge_base_id
    assert private_base_id is not None
    knowledge_link = runtime.workspace.root / "knowledge" / private_base_id
    assert not knowledge_link.exists()
    assert not knowledge_link.is_symlink()


def test_private_workspace_template_preserves_metadata_and_backfills_missing_files(tmp_path: Path) -> None:
    """Private templates should preserve file metadata and backfill new files without overwriting edits."""
    template_dir = tmp_path / "template"
    template_dir.mkdir(parents=True, exist_ok=True)
    script_path = template_dir / "bootstrap.sh"
    script_path.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
    script_path.chmod(0o755)

    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir=str(template_dir),
    )
    runtime_paths = _runtime_paths(tmp_path / "storage", config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )

    with patch("mindroom.workspaces._copy_workspace_template", wraps=_copy_workspace_template) as copy_template:
        first_workspace = resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
            create=True,
        ).workspace
        assert first_workspace is not None
        copied_script = first_workspace.root / "bootstrap.sh"
        assert copied_script.exists()
        assert stat.S_IMODE(copied_script.stat().st_mode) == stat.S_IMODE(script_path.stat().st_mode)
        copied_script.write_text("#!/bin/sh\necho edited\n", encoding="utf-8")
        later_file = template_dir / "LATER.md"
        later_file.write_text("later\n", encoding="utf-8")
        second_workspace = resolve_agent_runtime(
            "general",
            bound_config,
            runtime_paths,
            execution_identity=identity,
            create=True,
        ).workspace

    assert second_workspace is not None
    assert first_workspace.root == second_workspace.root
    assert copy_template.call_count == 2
    assert copied_script.read_text(encoding="utf-8") == "#!/bin/sh\necho edited\n"
    assert (first_workspace.root / "LATER.md").read_text(encoding="utf-8") == "later\n"


def test_private_workspace_template_initializes_missing_files_in_partially_populated_root(tmp_path: Path) -> None:
    """First-use template initialization should fill missing files even if the root already exists."""
    template_dir = tmp_path / "template"
    template_dir.mkdir(parents=True, exist_ok=True)
    (template_dir / "SOUL.md").write_text("soul\n", encoding="utf-8")
    (template_dir / "USER.md").write_text("user\n", encoding="utf-8")

    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir=str(template_dir),
    )
    runtime_paths = _runtime_paths(tmp_path / "storage", config_path=tmp_path / "cfg" / "config.yaml")
    bound_config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )
    state_root = resolve_agent_runtime(
        "general",
        bound_config,
        runtime_paths,
        execution_identity=identity,
    ).state_root
    workspace_root = state_root / "mind_data"
    workspace_root.mkdir(parents=True, exist_ok=True)
    existing_file = workspace_root / "existing.txt"
    existing_file.write_text("keep\n", encoding="utf-8")

    workspace = resolve_agent_runtime(
        "general",
        bound_config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    ).workspace

    assert workspace is not None
    assert existing_file.read_text(encoding="utf-8") == "keep\n"
    assert (workspace.root / "SOUL.md").read_text(encoding="utf-8") == "soul\n"
    assert (workspace.root / "USER.md").read_text(encoding="utf-8") == "user\n"


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_does_not_pass_browser_specific_runtime_overrides(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """Agent construction should not special-case browser artifact paths anymore."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].tools = ["browser", "visualization"]
    config.agents["general"].include_default_tools = False

    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    runtime_overrides_by_tool = {
        call.args[0]: call.kwargs.get("runtime_overrides") for call in mock_get_tool_by_name.call_args_list
    }
    assert runtime_overrides_by_tool["browser"] is None
    assert runtime_overrides_by_tool["visualization"] is None


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_passes_authored_shell_runtime_overrides(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Authored per-agent shell overrides should be converted into runtime kwargs."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].tools = [
        {
            "shell": {
                "extra_env_passthrough": ["GITEA_TOKEN", "WHISPER_URL"],
                "shell_path_prepend": ["/run/wrappers/bin"],
            },
        },
    ]
    config.agents["general"].include_default_tools = False

    _create_agent_for_test("general", config=config)

    runtime_overrides_by_tool = {
        call.args[0]: call.kwargs.get("runtime_overrides") for call in mock_get_tool_by_name.call_args_list
    }
    assert runtime_overrides_by_tool["shell"] == {
        "extra_env_passthrough": "GITEA_TOKEN, WHISPER_URL",
        "shell_path_prepend": "/run/wrappers/bin",
    }


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_keeps_tool_default_base_dir_without_memory_workspace(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Agents without file-backed workspace semantics should keep tool defaults."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].tools = ["coding", "shell", "duckduckgo"]
    config.agents["general"].include_default_tools = False

    _create_agent_for_test("general", config=config)

    overrides_by_tool = {
        call.args[0]: call.kwargs.get("tool_init_overrides") for call in mock_get_tool_by_name.call_args_list
    }
    assert overrides_by_tool["coding"] is None
    assert overrides_by_tool["shell"] is None
    assert overrides_by_tool["duckduckgo"] is None


@patch("mindroom.agents.load_plugins")
def test_create_agent_threads_config_path_to_plugin_loading(
    mock_load_plugins: MagicMock,
    tmp_path: Path,
) -> None:
    """Agent creation should resolve relative plugin paths from the active config file."""
    config_path = tmp_path / "cfg" / "config.yaml"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config = _test_config()
    runtime_paths = _runtime_paths(tmp_path, config_path=config_path)

    with patch("mindroom.agent_storage.SqliteDb"):
        _create_agent_for_test("general", config=_bind_runtime_paths(config, runtime_paths))

    mock_load_plugins.assert_called_once()
    assert mock_load_plugins.call_args.args[0] is not None  # config
    assert mock_load_plugins.call_args.args[1] is not None  # runtime_paths


def test_config_rejects_removed_memory_file_path_field() -> None:
    """Legacy memory_file_path should fail fast with a directed migration error."""
    with pytest.raises(ValidationError, match="memory_file_path"):
        Config(
            agents={
                "general": {
                    "display_name": "General",
                    "memory_backend": "file",
                    "memory_file_path": "mind_data",
                },
            },
        )


def test_create_agent_rejects_absolute_context_files(tmp_path: Path) -> None:
    """Absolute context_files should fail fast instead of creating copied state."""
    config = _test_config()

    with pytest.raises(ValidationError, match="workspace-relative"):
        config.agents["general"].context_files = [str(tmp_path / "SOUL.md")]


def test_create_agent_rejects_env_var_context_files() -> None:
    """Env-var context_files should fail fast instead of becoming literal workspace segments."""
    config = _test_config()

    with pytest.raises(ValidationError, match="env-variable references"):
        config.agents["general"].context_files = ["${MINDROOM_STORAGE_PATH}/SOUL.md"]


def test_create_agent_rejects_bare_env_var_context_files() -> None:
    """Agent-owned paths should reject bare `$NAME/...` forms too."""
    config = _test_config()

    with pytest.raises(ValidationError, match="env-variable references"):
        config.agents["general"].context_files = ["$MINDROOM_STORAGE_PATH/SOUL.md"]


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_applies_agent_workspace_override_for_worker_routed_scoped_tools(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """Worker-routed scoped tools should receive the same workspace override as local tools."""
    mock_get_tool_by_name.return_value = MagicMock()

    workspace = agent_workspace_root_path(tmp_path, "general")
    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].tools = ["coding", "shell"]
    config.agents["general"].include_default_tools = False
    config.agents["general"].worker_scope = "user"
    config.agents["general"].worker_tools = ["coding"]

    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    overrides_by_tool = {
        call.args[0]: call.kwargs.get("tool_init_overrides") for call in mock_get_tool_by_name.call_args_list
    }
    assert overrides_by_tool["coding"] == {"base_dir": str(workspace)}
    assert overrides_by_tool["shell"] == {"base_dir": str(workspace)}


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_uses_default_worker_tool_policy_when_unset(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
) -> None:
    """Agent creation should pass the built-in default worker-routing policy when worker_tools is omitted."""
    mock_get_tool_by_name.return_value = MagicMock()
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False
    config.agents["summary"].worker_tools = None

    _create_agent_for_test("summary", config=config)

    worker_overrides = [call.kwargs["worker_tools_override"] for call in mock_get_tool_by_name.call_args_list]
    assert worker_overrides
    assert all(override == ["shell", "coding"] for override in worker_overrides)


@patch("mindroom.agent_storage.SqliteDb")
def test_openclaw_compat_implies_matrix_message_tool(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """openclaw_compat should stay in the runtime toolkit list and imply matrix_message."""
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat"]
    config.agents["summary"].include_default_tools = False

    effective_tools = config.resolve_entity("summary").available_tools
    assert "openclaw_compat" in effective_tools
    assert "matrix_message" in effective_tools

    runtime_toolkits = get_agent_toolkit_names("summary", config)
    assert "openclaw_compat" in runtime_toolkits
    assert "matrix_message" in runtime_toolkits


def test_openclaw_compat_implied_matrix_message_does_not_duplicate() -> None:
    """Implied matrix_message should not duplicate explicit configuration."""
    config = _test_config()
    config.agents["summary"].tools = ["openclaw_compat", "matrix_message"]
    config.agents["summary"].include_default_tools = False

    effective_tools = config.resolve_entity("summary").available_tools
    assert effective_tools.count("matrix_message") == 1


def test_matrix_message_implies_attachments_and_matrix_room_tools() -> None:
    """matrix_message should automatically include its implied companion tools."""
    config = _test_config()
    config.agents["summary"].tools = ["matrix_message"]
    config.agents["summary"].include_default_tools = False

    effective_tools = config.resolve_entity("summary").available_tools
    assert effective_tools == ["matrix_message", "attachments", "matrix_room"]


def test_matrix_message_implied_attachments_does_not_duplicate() -> None:
    """Explicit attachments should not duplicate implied attachments."""
    config = _test_config()
    config.agents["summary"].tools = ["matrix_message", "attachments"]
    config.agents["summary"].include_default_tools = False

    effective_tools = config.resolve_entity("summary").available_tools
    assert effective_tools.count("attachments") == 1


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_code(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that the code agent is created correctly."""
    config = _test_config()
    agent = _create_agent_for_test("code", config=config)
    assert isinstance(agent, Agent)
    assert agent.name == "CodeAgent"


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_shell(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that the shell agent is created correctly."""
    config = _test_config()
    agent = _create_agent_for_test("shell", config=config)
    assert isinstance(agent, Agent)
    assert agent.name == "ShellAgent"


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_summary(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that the summary agent is created correctly."""
    config = _test_config()
    agent = _create_agent_for_test("summary", config=config)
    assert isinstance(agent, Agent)
    assert agent.name == "SummaryAgent"


def test_get_agent_unknown() -> None:
    """Tests that an unknown agent raises a ValueError."""
    config = _test_config()
    with pytest.raises(ValueError, match="Unknown agent: unknown"):
        _create_agent_for_test("unknown", config=config)


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_learning_can_be_disabled(mock_storage: MagicMock) -> None:
    """Tests that learning can be disabled per agent."""
    config = _test_config()
    config.agents["general"].learning = False
    agent = _create_agent_for_test("general", config=config)
    assert isinstance(agent, Agent)
    assert agent.learning is False
    assert mock_storage.call_count == 1


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_learning_defaults_fallback_when_agent_setting_omitted(mock_storage: MagicMock) -> None:
    """Tests that defaults.learning is used when per-agent learning is omitted."""
    config = _test_config()
    config.defaults.learning = False
    config.agents["general"].learning = None

    agent = _create_agent_for_test("general", config=config)

    assert isinstance(agent, Agent)
    assert agent.learning is False
    # Learning storage should not be created when defaults disable learning.
    assert mock_storage.call_count == 1


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_learning_agentic_mode(mock_storage: MagicMock) -> None:  # noqa: ARG001
    """Tests that learning mode can be configured as agentic."""
    config = _test_config()
    config.agents["general"].learning_mode = "agentic"
    agent = _create_agent_for_test("general", config=config)
    assert isinstance(agent, Agent)
    assert isinstance(agent.learning, LearningMachine)
    assert isinstance(agent.learning.user_profile, UserProfileConfig)
    assert agent.learning.user_profile.mode is LearningMode.AGENTIC
    assert isinstance(agent.learning.user_memory, UserMemoryConfig)
    assert agent.learning.user_memory.mode is LearningMode.AGENTIC


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_learning_inherits_defaults(mock_storage: MagicMock) -> None:
    """Tests that learning mode falls back to defaults when agent config is None."""
    config = _test_config()
    # Agent has no explicit learning settings (None), defaults say enabled + agentic.
    config.agents["general"].learning = None
    config.agents["general"].learning_mode = None
    config.defaults.learning = True
    config.defaults.learning_mode = "agentic"

    agent = _create_agent_for_test("general", config=config)

    assert isinstance(agent, Agent)
    assert isinstance(agent.learning, LearningMachine)
    assert isinstance(agent.learning.user_profile, UserProfileConfig)
    assert agent.learning.user_profile.mode is LearningMode.AGENTIC
    assert isinstance(agent.learning.user_memory, UserMemoryConfig)
    assert agent.learning.user_memory.mode is LearningMode.AGENTIC
    assert mock_storage.call_count == 2


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_uses_storage_path_for_sessions_and_learning(mock_storage: MagicMock, tmp_path: Path) -> None:
    """Session and learning databases should live under the canonical agent state root."""
    config = _test_config()
    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    agent_root = agent_state_root_path(tmp_path, "general")
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_root / "sessions" / "general.db" in db_files
    assert agent_root / "learning" / "general.db" in db_files


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_uses_worker_storage_for_sessions_and_learning(mock_storage: MagicMock, tmp_path: Path) -> None:
    """Worker scope should not change the canonical session and learning paths."""
    config = _test_config()
    config.agents["general"].worker_scope = "user"
    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )
    worker_key = resolve_worker_key("user", execution_identity, agent_name="general")
    assert worker_key is not None

    with tool_execution_identity(execution_identity):
        _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    agent_root = agent_state_root_path(tmp_path, "general")
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_root / "sessions" / "general.db" in db_files
    assert agent_root / "learning" / "general.db" in db_files


@patch("mindroom.agent_storage.SqliteDb")
def test_get_agent_uses_shared_worker_storage_without_execution_identity(
    mock_storage: MagicMock,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Shared scope should still use canonical agent state before a live request context exists."""
    config = _test_config()
    config.agents["general"].worker_scope = "shared"
    monkeypatch.setenv("CUSTOMER_ID", "tenant-123")

    _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    shared_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id=None,
        room_id=None,
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
        tenant_id="tenant-123",
        account_id=None,
    )
    worker_key = resolve_worker_key("shared", shared_identity, agent_name="general")
    assert worker_key is not None

    agent_root = agent_state_root_path(tmp_path, "general")
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_root / "sessions" / "general.db" in db_files
    assert agent_root / "learning" / "general.db" in db_files


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_loads_shared_worker_scoped_tool_credentials_with_explicit_shared_identity(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Shared worker credentials should be available when ingress passes explicit shared identity."""
    monkeypatch.setenv("CUSTOMER_ID", "tenant-123")
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={"CUSTOMER_ID": "tenant-123"},
    )
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.defaults.tools = []
    config.agents["general"].tools = ["credentialed_toolkit"]
    config.agents["general"].worker_scope = "shared"

    credentials_manager = CredentialsManager(tmp_path / "credentials")
    shared_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id=None,
        room_id=None,
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
        tenant_id="tenant-123",
        account_id=None,
    )
    worker_key = resolve_worker_key("shared", shared_identity, agent_name="general")
    assert worker_key is not None
    credentials_manager.for_worker(worker_key).save_credentials(
        "credentialed_toolkit",
        {"api_key": "worker-key", "_source": "ui"},
    )

    def _get_tool_by_name(
        tool_name: str,
        _runtime_paths: object = None,
        *,
        credentials_manager: object | None = None,
        tool_config_overrides: dict[str, object] | None = None,
        tool_init_overrides: dict[str, object] | None = None,
        runtime_overrides: dict[str, object] | None = None,
        shared_storage_root_path: object | None = None,
        worker_tools_override: list[str] | None = None,
        allowed_shared_services: frozenset[str] | None = None,
        tool_output_workspace_root: object | None = None,
        tool_output_auto_save_threshold_bytes: int = 50 * 1024,
        worker_target: object | None = None,
    ) -> MagicMock:
        del (
            _runtime_paths,
            tool_config_overrides,
            tool_init_overrides,
            runtime_overrides,
            shared_storage_root_path,
            worker_tools_override,
            allowed_shared_services,
            tool_output_workspace_root,
            tool_output_auto_save_threshold_bytes,
        )
        credentials = load_scoped_credentials(
            tool_name,
            credentials_manager=cast("CredentialsManager", credentials_manager),
            worker_target=worker_target,
        )
        if not isinstance(credentials, dict) or "api_key" not in credentials:
            msg = "API key required"
            raise ValueError(msg)
        tool = MagicMock()
        tool.name = tool_name
        return tool

    monkeypatch.setattr("mindroom.agents.get_tool_by_name", _get_tool_by_name)

    agent = _create_agent_for_test("general", config=config, execution_identity=shared_identity)

    assert [tool.name for tool in agent.tools] == ["credentialed_toolkit"]


def test_resolve_worker_key_rejects_unknown_scope() -> None:
    """Unknown worker scopes should fail loudly instead of silently falling back."""
    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )

    with pytest.raises(ValueError, match="Unknown worker scope"):
        resolve_worker_key(cast("WorkerScope", "bogus"), execution_identity)


def test_resolve_agent_owned_path_resolves_workspace_relative_path(tmp_path: Path) -> None:
    """Agent-owned paths should resolve directly inside the canonical workspace."""
    resolved = resolve_agent_owned_path(
        "SOUL.md",
        agent_name="general",
        base_storage_path=tmp_path,
    )

    assert resolved.is_relative_to(agent_state_root_path(tmp_path, "general"))
    assert resolved == agent_workspace_root_path(tmp_path, "general") / "SOUL.md"


def test_agent_owned_validation_matches_runtime_resolution(tmp_path: Path) -> None:
    """Validation and runtime resolution should share the same normalization contract."""
    config = _test_config()
    config.agents["general"].context_files = ["./SOUL.md"]

    validated_context = config.agents["general"].context_files[0]

    assert validated_context == "SOUL.md"
    assert (
        resolve_agent_owned_path(
            validated_context,
            agent_name="general",
            base_storage_path=tmp_path,
        )
        == agent_workspace_root_path(tmp_path, "general") / "SOUL.md"
    )


def test_resolve_worker_key_encodes_tenant_parts_that_would_break_round_tripping(tmp_path: Path) -> None:
    """Worker keys should stay parseable even when tenant/account identifiers contain ':'."""
    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
        tenant_id="tenant:west",
    )

    worker_key = resolve_worker_key("shared", execution_identity, agent_name="general")

    assert worker_key == "v1:tenant_west:shared:general"
    assert visible_state_roots_for_worker_key(tmp_path, worker_key) == (agent_state_root_path(tmp_path, "general"),)


def test_visible_state_roots_for_user_worker_include_private_instance_namespace(tmp_path: Path) -> None:
    """User workers should see shared agent roots plus their own private-instance namespace."""
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id="session-1",
    )

    worker_key = resolve_worker_key("user", identity)

    assert worker_key is not None
    assert visible_state_roots_for_worker_key(tmp_path, worker_key) == (
        shared_storage_root(tmp_path) / "agents",
        private_instance_scope_root_path(tmp_path, worker_key),
    )


def test_worker_visibility_policy_requires_explicit_private_names_only_for_user_agent_scope() -> None:
    """Only user-agent scoped workers need caller-provided private-agent visibility."""
    assert requires_explicit_private_agent_visibility("v1:tenant:user_agent:@alice:example.org:mind")
    assert not requires_explicit_private_agent_visibility("v1:tenant:user:@alice:example.org")
    assert not requires_explicit_private_agent_visibility("v1:tenant:shared:mind")
    assert not requires_explicit_private_agent_visibility("v1:tenant:unscoped:mind")
    assert not requires_explicit_private_agent_visibility("legacy-worker-key")


def test_visible_state_roots_for_private_user_agent_workers_hide_shared_agent_root(
    tmp_path: Path,
) -> None:
    """Private requester-scoped workers should only see their addressed private state root."""
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="mind",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )

    worker_key = resolve_worker_key("user_agent", identity, agent_name="mind")

    assert worker_key is not None
    assert visible_state_roots_for_worker_key(
        tmp_path,
        worker_key,
        private_agent_names=frozenset({"mind"}),
    ) == (_private_instance_state_root_path(tmp_path, worker_key=worker_key, agent_name="mind"),)


def test_shared_storage_root_does_not_peel_false_positive_agents_parent(tmp_path: Path) -> None:
    """A storage root nested under a directory named `agents` should remain unchanged."""
    storage_root = tmp_path / "agents" / "mindroom_data"

    assert shared_storage_root(storage_root) == storage_root.resolve()


def test_resolve_agent_state_storage_path_accepts_pre_resolved_agent_root(tmp_path: Path) -> None:
    """Already-resolved canonical agent roots should not gain an extra `agents/<name>` layer."""
    agent_root = agent_state_root_path(tmp_path, "general")

    assert resolve_agent_state_storage_path(agent_name="general", base_storage_path=agent_root) == agent_root


def test_resolve_agent_owned_path_rejects_absolute_paths(tmp_path: Path) -> None:
    """Agent-owned paths must not point outside the canonical workspace."""
    with pytest.raises(ValueError, match="workspace-relative"):
        resolve_agent_owned_path(
            str(tmp_path / "external" / "SOUL.md"),
            agent_name="general",
            base_storage_path=tmp_path,
        )


def test_resolve_agent_owned_path_rejects_path_traversal(tmp_path: Path) -> None:
    """Agent-owned paths must stay inside the canonical agent workspace."""
    with pytest.raises(ValueError, match="stay within the agent workspace"):
        resolve_agent_owned_path(
            "../escape.md",
            agent_name="general",
            base_storage_path=tmp_path,
        )


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_reads_canonical_context_files_and_reloads_from_agent_root(
    mock_storage: MagicMock,  # noqa: ARG001
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
) -> None:
    """Context files should be read live from the canonical agent root across scopes."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].context_files = ["SOUL.md"]
    config.agents["general"].tools = ["coding"]
    config.agents["general"].include_default_tools = False
    config.agents["general"].worker_scope = "user"
    config.agents["general"].worker_tools = ["coding"]

    canonical_workspace = agent_workspace_root_path(tmp_path, "general")
    canonical_workspace.mkdir(parents=True, exist_ok=True)
    canonical_soul = canonical_workspace / "SOUL.md"
    canonical_soul.write_text("Canonical soul context.", encoding="utf-8")

    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )

    with tool_execution_identity(execution_identity):
        agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "Canonical soul context." in agent.role
    assert mock_get_tool_by_name.call_args is not None
    assert mock_get_tool_by_name.call_args.kwargs["tool_init_overrides"] == {"base_dir": str(canonical_workspace)}

    canonical_soul.write_text("Updated canonical soul context.", encoding="utf-8")

    with tool_execution_identity(execution_identity):
        updated_agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "Updated canonical soul context." in updated_agent.role

    canonical_soul.unlink()

    with tool_execution_identity(execution_identity):
        deleted_agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert not canonical_soul.exists()
    assert "Canonical soul context." not in deleted_agent.role
    assert "Updated canonical soul context." not in deleted_agent.role


def test_load_context_files_prefers_projected_assets_over_workspace_shadows(
    tmp_path: Path,
) -> None:
    """Projected worker assets should stay authoritative even if writable workspace state shadows the same path."""
    storage_path = tmp_path / "storage"
    config_dir = tmp_path / "cfg"
    config_dir.mkdir(parents=True, exist_ok=True)
    config_path = config_dir / "config.yaml"
    config_path.write_text("models: {}\nagents: {}\n", encoding="utf-8")
    runtime_paths = _runtime_paths(storage_path, config_path=config_path)
    workspace_context = agent_workspace_root_path(storage_path, "general") / ".mindroom-worker-assets" / "note.md"
    workspace_context.parent.mkdir(parents=True, exist_ok=True)
    workspace_context.write_text("workspace\n", encoding="utf-8")
    projected_context = config_dir / ".mindroom-worker-assets" / "note.md"
    projected_context.parent.mkdir(parents=True, exist_ok=True)
    projected_context.write_text("config\n", encoding="utf-8")

    loaded = _load_context_files(
        [".mindroom-worker-assets/note.md"],
        runtime_paths,
        agent_name="general",
        storage_path=storage_path,
    )

    assert len(loaded) == 1
    assert loaded[0].body == "config"


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_scaffolds_default_mind_workspace_under_runtime_storage_root(
    _mock_storage: MagicMock,  # noqa: PT019
    tmp_path: Path,
) -> None:
    """The default starter Mind profile should materialize its workspace under the active runtime root."""
    runtime_storage = tmp_path / "runtime-storage"
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                role="Personal assistant",
                model="default",
                rooms=["personal"],
                tools=[],
                include_default_tools=False,
                learning=False,
                memory_backend="file",
                context_files=[
                    "SOUL.md",
                    "AGENTS.md",
                    "USER.md",
                    "IDENTITY.md",
                    "TOOLS.md",
                    "HEARTBEAT.md",
                ],
            ),
        },
        models={"default": ModelConfig(provider="openai", id="gpt-4")},
    )

    assert not agent_build_can_overlap_file_memory("mind", config, runtime_storage)
    agent = _create_agent_for_test("mind", config=_bind_runtime_paths(config, _runtime_paths(runtime_storage)))

    workspace = runtime_storage / "agents" / "mind" / "workspace"
    assert (workspace / "SOUL.md").exists()
    assert (workspace / "AGENTS.md").exists()
    assert (workspace / "USER.md").exists()
    assert (workspace / "IDENTITY.md").exists()
    assert (workspace / "TOOLS.md").exists()
    assert (workspace / "HEARTBEAT.md").exists()
    assert (workspace / "MEMORY.md").exists()
    assert "## Personality Context" in agent.role
    assert agent_build_can_overlap_file_memory("mind", config, runtime_storage)


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_uses_unscoped_kubernetes_worker_workspace_for_dedicated_tools(
    mock_storage: MagicMock,
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Kubernetes-backed unscoped agents should still use the canonical agent workspace."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].tools = ["coding"]
    config.agents["general"].include_default_tools = False

    config_dir = tmp_path / "cfg"
    config_dir.mkdir(parents=True, exist_ok=True)
    monkeypatch.setenv("MINDROOM_WORKER_BACKEND", "kubernetes")
    monkeypatch.setenv("CUSTOMER_ID", "tenant-123")
    runtime_paths = _runtime_paths(tmp_path, config_path=config_dir / "config.yaml")

    _create_agent_for_test("general", config=_bind_runtime_paths(config, runtime_paths))

    agent_root = agent_state_root_path(tmp_path, "general")
    canonical_workspace = agent_workspace_root_path(tmp_path, "general")
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_root / "sessions" / "general.db" in db_files
    assert agent_root / "learning" / "general.db" in db_files
    assert mock_get_tool_by_name.call_args is not None
    assert mock_get_tool_by_name.call_args.kwargs["tool_init_overrides"] == {"base_dir": str(canonical_workspace)}


@patch("mindroom.agents.get_tool_by_name")
@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_uses_mounted_dedicated_worker_root_for_unscoped_agent_state(
    mock_storage: MagicMock,
    mock_get_tool_by_name: MagicMock,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Dedicated worker runtime roots should not change the canonical agent-owned paths."""
    mock_get_tool_by_name.return_value = MagicMock()

    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].tools = ["coding"]
    config.agents["general"].include_default_tools = False

    shared_root = tmp_path / "shared-storage"
    worker_key = resolve_unscoped_worker_key(agent_name="general")
    dedicated_root = worker_root_path(shared_root, worker_key)
    config_dir = tmp_path / "cfg"
    config_dir.mkdir(parents=True, exist_ok=True)
    monkeypatch.delenv("MINDROOM_WORKER_BACKEND", raising=False)
    monkeypatch.setenv("MINDROOM_SANDBOX_DEDICATED_WORKER_KEY", worker_key)
    monkeypatch.setenv("MINDROOM_SANDBOX_DEDICATED_WORKER_ROOT", str(dedicated_root))
    runtime_paths = _runtime_paths(shared_root, config_path=config_dir / "config.yaml")

    _create_agent_for_test("general", config=_bind_runtime_paths(config, runtime_paths))

    agent_root = agent_state_root_path(shared_root, "general")
    canonical_workspace = agent_workspace_root_path(shared_root, "general")
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_root / "sessions" / "general.db" in db_files
    assert agent_root / "learning" / "general.db" in db_files
    assert not any(path.is_relative_to(dedicated_root) for path in db_files)
    assert mock_get_tool_by_name.call_args is not None
    assert mock_get_tool_by_name.call_args.kwargs["tool_init_overrides"] == {"base_dir": str(canonical_workspace)}


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_context_files_are_loaded_into_role(mock_storage: MagicMock, tmp_path: Path) -> None:  # noqa: ARG001
    """Context files should load directly from the canonical workspace."""
    config = _test_config()
    workspace = agent_workspace_root_path(tmp_path, "general")
    soul_path = workspace / "SOUL.md"
    user_path = workspace / "USER.md"
    workspace.mkdir(parents=True, exist_ok=True)
    soul_path.write_text("Core personality directive.", encoding="utf-8")
    user_path.write_text("User preference: concise answers.", encoding="utf-8")

    config.agents["general"].context_files = ["SOUL.md", "USER.md"]

    agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "## Personality Context" in agent.role
    assert f"### {soul_path}" in agent.role
    assert "Core personality directive." in agent.role
    assert f"### {user_path}" in agent.role
    assert "User preference: concise answers." in agent.role
    soul_path.write_text("Canonical soul directive.", encoding="utf-8")

    updated_agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "Canonical soul directive." in updated_agent.role


def _preload_chunks() -> list[_AdditionalContextChunk]:
    return [
        _AdditionalContextChunk(title="/ws/FIRST.md", body="FIRST_START " + "A" * 600),
        _AdditionalContextChunk(title="/ws/SECOND.md", body="SECOND_START " + "B" * 600),
    ]


def _apply_test_preload_cap(chunks: list[_AdditionalContextChunk], max_preload_chars: int) -> tuple[str, int]:
    return _apply_preload_cap(
        chunks,
        max_preload_chars,
        section_heading=prompts.PERSONALITY_CONTEXT_SECTION_HEADING,
        truncation_marker_template=prompts.CONTEXT_TRUNCATION_MARKER_TEMPLATE,
        chunk_marker_template=prompts.CONTEXT_CHUNK_OMITTED_MARKER_TEMPLATE,
    )


def test_trim_chunk_tails_counts_whitespace_removed_by_cleanup() -> None:
    """Tail trimming must count whitespace removed after the requested slice."""
    chunk = _AdditionalContextChunk(title="/ws/SOUL.md", body="alpha   beta")

    omitted_chars = _trim_chunk_tails(
        [chunk],
        len("alpha   "),
        render=lambda chunks: chunks[0].body,
    )

    assert chunk.body == "alpha"
    assert chunk.omitted_chars == 7
    assert omitted_chars == 7


def test_preload_cap_truncates_context_files_in_order() -> None:
    """Preload cap should drop earlier context files before later ones."""
    chunks = _preload_chunks()
    heading_and_markers = len(prompts.PERSONALITY_CONTEXT_SECTION_HEADING) + 400

    rendered, omitted_chars = _apply_test_preload_cap(chunks, len(chunks[1].body) + heading_and_markers)

    assert omitted_chars >= len(_preload_chunks()[0].body)
    assert "FIRST_START" not in rendered
    assert "SECOND_START" in rendered
    assert "[Context files exceeded the preload budget - " in rendered


def test_preload_cap_keeps_the_dropped_file_path_visible() -> None:
    """A dropped context file must stay visible as its path plus an omission marker."""
    chunks = _preload_chunks()
    dropped_body_length = len(chunks[0].body)
    heading_and_markers = len(prompts.PERSONALITY_CONTEXT_SECTION_HEADING) + 400

    rendered, _ = _apply_test_preload_cap(chunks, len(chunks[1].body) + heading_and_markers)

    assert "### /ws/FIRST.md" in rendered
    assert f"[Truncated - {dropped_body_length} chars omitted. Read /ws/FIRST.md for the rest.]" in rendered


def test_preload_cap_rejects_budget_too_small_for_required_markers() -> None:
    """A hard cap must fail explicitly rather than slice away required file markers."""
    chunks = [
        _AdditionalContextChunk(
            title=f"/workspace/context/file-{index}.md",
            body="x" * 700,
        )
        for index in range(6)
    ]

    with pytest.raises(ValueError, match="cannot fit required context headings and omission markers"):
        _apply_test_preload_cap(chunks, 400)


def test_preload_cap_trims_last_context_body_instead_of_dropping_it() -> None:
    """After dropping earlier files, the final file should keep as much leading content as fits."""
    rendered, omitted_chars = _apply_test_preload_cap(_preload_chunks(), 1_000)

    assert len(_preload_chunks()[0].body) < omitted_chars < sum(len(chunk.body) for chunk in _preload_chunks())
    assert "FIRST_START" not in rendered
    assert "SECOND_START" in rendered
    assert "### /ws/FIRST.md" in rendered
    assert "### /ws/SECOND.md" in rendered
    assert len(rendered) <= 1_000


def test_preload_cap_leaves_untruncated_context_unmarked() -> None:
    """Context that fits the budget must carry no omission markers."""
    rendered, omitted_chars = _apply_test_preload_cap(_preload_chunks(), 50_000)

    assert omitted_chars == 0
    assert "Truncated" not in rendered
    assert "FIRST_START" in rendered
    assert "SECOND_START" in rendered


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_context_section_states_files_are_preloaded(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """The context section must tell the model these files are already inlined."""
    config = _test_config()
    workspace = agent_workspace_root_path(tmp_path, "general")
    workspace.mkdir(parents=True, exist_ok=True)
    (workspace / "SOUL.md").write_text("Core personality directive.", encoding="utf-8")

    config.agents["general"].context_files = ["SOUL.md"]

    agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "headed by the path of the file it was read from" in agent.role
    assert "Do not re-read a file" in agent.role


@patch("mindroom.agent_storage.SqliteDb")
def test_agent_missing_context_file_is_ignored(mock_storage: MagicMock, tmp_path: Path) -> None:  # noqa: ARG001
    """Missing context files should not prevent agent creation."""
    config = _test_config()
    config.agents["general"].context_files = ["does-not-exist.md"]

    agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))

    assert "## Personality Context" not in agent.role
    assert "does-not-exist.md" not in agent.role


def test_agent_relative_context_paths_resolve_from_workspace_not_cwd(tmp_path: Path) -> None:
    """Relative context paths should resolve from the canonical workspace, not CWD."""
    config = _test_config()
    workspace = agent_workspace_root_path(tmp_path, "general")
    workspace.mkdir(parents=True, exist_ok=True)
    soul_path = workspace / "SOUL.md"
    soul_path.write_text("Relative soul context.", encoding="utf-8")

    config.agents["general"].context_files = ["SOUL.md"]

    original_cwd = Path.cwd()
    other_cwd = tmp_path / "other"
    other_cwd.mkdir(parents=True, exist_ok=True)
    os.chdir(other_cwd)
    try:
        with patch("mindroom.agent_storage.SqliteDb"):
            agent = _create_agent_for_test("general", config=_bind_runtime_paths(config, _runtime_paths(tmp_path)))
    finally:
        os.chdir(original_cwd)

    assert "Relative soul context." in agent.role


def test_bind_runtime_paths_rejects_missing_private_template_dir(tmp_path: Path) -> None:
    """Runtime-bound config validation should reject missing private template directories."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./missing-template",
    )

    with pytest.raises(ValueError, match=re.escape("invalid private.template_dir")):
        _bind_runtime_paths(config, _runtime_paths(tmp_path))


def test_bind_runtime_paths_allows_missing_private_template_dir_for_dedicated_sandbox_worker(tmp_path: Path) -> None:
    """Dedicated sandbox workers should not validate control-plane private template paths."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./missing-template",
    )
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=tmp_path,
        process_env={
            "MINDROOM_SANDBOX_RUNNER_MODE": "true",
            "MINDROOM_SANDBOX_DEDICATED_WORKER_KEY": "v1:tenant-123:user:alice",
        },
    )

    bound = _bind_runtime_paths(config, runtime_paths)

    assert bound.get_agent("general").private is not None


def test_resolve_agent_runtime_skips_missing_private_template_copy_for_dedicated_sandbox_worker(
    tmp_path: Path,
) -> None:
    """Dedicated workers should not need control-plane-only private templates at tool execution time."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user_agent",
        root="mind_data",
        template_dir="./missing-template",
    )
    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
        tenant_id="tenant-123",
    )
    worker_key = resolve_worker_key("user_agent", execution_identity, agent_name="general")
    assert worker_key is not None
    shared_root = tmp_path / "shared-storage"
    runtime_paths = resolve_runtime_paths(
        config_path=tmp_path / "config.yaml",
        storage_path=shared_root,
        process_env={
            "MINDROOM_SANDBOX_RUNNER_MODE": "true",
            "MINDROOM_SANDBOX_DEDICATED_WORKER_KEY": worker_key,
        },
    )
    bound = _bind_runtime_paths(config, runtime_paths)

    agent_runtime = resolve_agent_runtime(
        "general",
        bound,
        runtime_paths,
        execution_identity=execution_identity,
        create=True,
    )

    expected_workspace = (
        _private_instance_state_root_path(
            shared_root,
            worker_key=worker_key,
            agent_name="general",
        )
        / "mind_data"
    )
    assert agent_runtime.workspace is not None
    assert agent_runtime.workspace.root == expected_workspace
    assert expected_workspace.is_dir()


def test_bind_runtime_paths_rejects_private_template_dir_with_symlinked_content(tmp_path: Path) -> None:
    """Private templates must reject symlinked content instead of copying host files."""
    template_dir = tmp_path / "mind_template"
    template_dir.mkdir(parents=True, exist_ok=True)
    secret_file = tmp_path / "secret.txt"
    secret_file.write_text("secret\n", encoding="utf-8")
    (template_dir / "linked.txt").symlink_to(secret_file)

    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./mind_template",
    )

    with pytest.raises(ValueError, match=re.escape("invalid private.template_dir")):
        _bind_runtime_paths(config, _runtime_paths(tmp_path, config_path=tmp_path / "config.yaml"))


def test_bind_runtime_paths_rejects_private_template_dir_when_template_root_is_symlink(tmp_path: Path) -> None:
    """Private templates must reject symlinked template roots before dereferencing them."""
    real_template_dir = tmp_path / "real_template"
    real_template_dir.mkdir(parents=True, exist_ok=True)
    (real_template_dir / "README.md").write_text("template\n", encoding="utf-8")
    (tmp_path / "mind_template").symlink_to(real_template_dir, target_is_directory=True)

    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./mind_template",
    )

    with pytest.raises(ValueError, match=re.escape("invalid private.template_dir")):
        _bind_runtime_paths(config, _runtime_paths(tmp_path, config_path=tmp_path / "config.yaml"))


def test_copy_workspace_template_rejects_destination_symlink_escape(tmp_path: Path) -> None:
    """Template backfill must refuse to write through symlinked workspace subdirectories."""
    template_dir = tmp_path / "template"
    (template_dir / "notes").mkdir(parents=True, exist_ok=True)
    (template_dir / "notes" / "NEW.md").write_text("later\n", encoding="utf-8")

    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir(parents=True, exist_ok=True)
    outside_root = tmp_path / "outside"
    outside_root.mkdir(parents=True, exist_ok=True)
    (workspace_root / "notes").symlink_to(outside_root, target_is_directory=True)

    with pytest.raises(ValueError, match="workspace template destination must stay within the workspace root"):
        _copy_workspace_template(workspace_root, template_dir=template_dir)


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_private_root_loads_requester_context_from_isolated_workspace(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
    build_private_template_dir: Callable[..., Path],
) -> None:
    """Private per-user roots should copy their configured template and isolate private context files."""
    config = _test_config()
    config_dir = tmp_path / "cfg"
    config_dir.mkdir(parents=True, exist_ok=True)
    template_dir = build_private_template_dir(
        "cfg/mind_template",
        files={
            "USER.md": "Template user.\n",
            "MEMORY.md": "# Memory\n",
            "memory/notes.md": "Private note.\n",
        },
    )
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./mind_template",
        context_files=["USER.md"],
    )

    alice_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id="session-alice",
    )
    bob_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@bob:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id="session-bob",
    )

    runtime_paths = _runtime_paths(tmp_path, config_path=config_dir / "config.yaml")
    config = _bind_runtime_paths(config, runtime_paths)
    assert template_dir == (config_dir / "mind_template").resolve()

    create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=alice_identity,
    )
    alice_worker_key = resolve_worker_key("user", alice_identity)
    assert alice_worker_key is not None
    alice_workspace = (
        _private_instance_state_root_path(
            tmp_path,
            worker_key=alice_worker_key,
            agent_name="general",
        )
        / "mind_data"
    )
    assert (alice_workspace / "USER.md").exists()
    assert (alice_workspace / "MEMORY.md").exists()
    (alice_workspace / "USER.md").write_text("Alice private root context.", encoding="utf-8")
    alice_agent = create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=alice_identity,
    )

    bob_agent = create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=bob_identity,
    )
    bob_worker_key = resolve_worker_key("user", bob_identity)
    assert bob_worker_key is not None
    bob_workspace = (
        _private_instance_state_root_path(
            tmp_path,
            worker_key=bob_worker_key,
            agent_name="general",
        )
        / "mind_data"
    )

    assert alice_workspace != bob_workspace
    assert "Alice private root context." in alice_agent.role
    assert (bob_workspace / "USER.md").exists()
    assert "Alice private root context." not in bob_agent.role
    assert alice_workspace.parent == private_instance_scope_root_path(tmp_path, alice_worker_key) / "general"


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_private_template_dir_does_not_imply_context_files(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
    build_private_template_dir: Callable[..., Path],
) -> None:
    """Private template directories should not implicitly load Mind-style context files."""
    config = _test_config()
    config_dir = tmp_path / "cfg"
    config_dir.mkdir(parents=True, exist_ok=True)
    build_private_template_dir(
        "cfg/mind_template",
        files={
            "USER.md": "Template user.\n",
            "MEMORY.md": "# Memory\n",
        },
    )
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir="./mind_template",
    )

    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id="session-alice",
    )

    runtime_paths = _runtime_paths(tmp_path, config_path=config_dir / "config.yaml")
    config = _bind_runtime_paths(config, runtime_paths)

    agent = create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
    )

    assert "Template user." not in agent.role


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_loads_private_workspace_skills(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """Private agents should load skills from the resolved requester workspace."""
    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(per="user", root="mind_data")
    runtime_paths = _runtime_paths(tmp_path, config_path=tmp_path / "cfg" / "config.yaml")
    config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="$thread",
    )
    workspace = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=True,
    ).workspace
    assert workspace is not None
    skill_dir = workspace.root / "skills" / "private-skill"
    skill_dir.mkdir(parents=True)
    (skill_dir / "SKILL.md").write_text(
        "---\nname: private-skill\ndescription: Private workspace skill\n---\n\n# Body\n",
        encoding="utf-8",
    )

    agent = create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
    )

    assert agent.skills is not None
    assert agent.skills.get_skill_names() == ["private-skill"]


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_private_root_requires_execution_identity(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """Private agents should fail closed instead of falling back to shared config-relative state."""
    config = _test_config()
    config.agents["general"].memory_backend = "file"
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
    )

    with pytest.raises(ValueError, match="requires an active execution identity"):
        create_agent("general", config=config, runtime_paths=_runtime_paths(tmp_path), execution_identity=None)

    assert not (tmp_path / "mind_data").exists()


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_disabled_tool_names_omit_resolved_tools(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """Call-scoped agent construction can omit mutating tools from borrowed agents."""
    config = _test_config()
    config.agents["general"].tools = ["memory", "calculator"]
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(config, runtime_paths)
    built_tools: list[str] = []

    def fake_build_agent_toolkit(tool_name: str, **_kwargs: object) -> None:
        built_tools.append(tool_name)

    with patch("mindroom.agents.build_agent_toolkit", side_effect=fake_build_agent_toolkit):
        create_agent(
            "general",
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=None,
            disabled_tool_names=frozenset({"memory"}),
        )

    assert "calculator" in built_tools
    assert "memory" not in built_tools


def test_tool_function_filter_prunes_resolved_functions() -> None:
    """Channel policies filter actual functions without dropping a safe toolkit peer."""
    safe = Function(name="safe", entrypoint=lambda: "safe")
    unsafe = Function(name="unsafe", entrypoint=lambda: "unsafe")
    toolkit = Toolkit(name="mixed", tools=[safe, unsafe])

    filtered = _prune_toolkit_functions(toolkit, lambda function: function.name == "safe")

    assert filtered is toolkit
    assert set(toolkit.functions) == {"safe"}
    assert toolkit.async_functions == {}


@pytest.mark.asyncio
async def test_create_agent_tool_filter_applies_to_agno_generated_knowledge_function(tmp_path: Path) -> None:
    """The stored channel policy filters functions Agno adds after construction."""
    config = _test_config()
    config.agents["general"].knowledge_bases = ["docs"]
    config.knowledge_bases = {
        "docs": KnowledgeBaseConfig(description="Reference docs.", path="./knowledge_docs/docs"),
    }
    config = _bind_runtime_paths(config, _runtime_paths(tmp_path))
    seen: list[str] = []

    def allow_call_function(function: Function) -> bool:
        seen.append(function.name)
        return function.name != "search_knowledge_base"

    agent = _create_agent_for_test(
        "general",
        config,
        knowledge=Knowledge(name="docs"),
        tool_function_filter=allow_call_function,
    )
    run_output = RunOutput(
        run_id="run-call-policy",
        agent_id="general",
        agent_name="GeneralAgent",
        session_id="session-call-policy",
    )
    run_context = RunContext(run_id="run-call-policy", session_id="session-call-policy")
    session = AgentSession(
        session_id="session-call-policy",
        agent_id="general",
        created_at=1,
        updated_at=1,
    )

    tools = await agent.aget_tools(run_output, run_context, session)

    assert "search_knowledge_base" in seen
    assert all(not isinstance(tool, Function) or tool.name != "search_knowledge_base" for tool in tools)


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_disable_runtime_capabilities_omits_all_tools_and_skills(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """Restricted in-process agent construction should reuse persona/model without capabilities."""
    config = _test_config()
    config.agents["general"].tools = ["memory", "calculator"]
    workspace = agent_workspace_root_path(tmp_path, "general")
    workspace.mkdir(parents=True, exist_ok=True)
    (workspace / "SOUL.md").write_text("Private workspace directive.", encoding="utf-8")
    config.agents["general"].context_files = ["SOUL.md"]
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(config, runtime_paths)
    built_tools: list[str] = []

    def fake_build_agent_toolkit(tool_name: str, **_kwargs: object) -> None:
        built_tools.append(tool_name)

    with (
        patch("mindroom.agents.build_agent_toolkit", side_effect=fake_build_agent_toolkit),
        patch("mindroom.agents._load_agent_skills") as load_agent_skills,
    ):
        agent = create_agent(
            "general",
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=None,
            disable_runtime_capabilities=True,
        )

    assert built_tools == []
    load_agent_skills.assert_not_called()
    assert agent.tools is None or agent.tools == []
    assert agent.skills is None
    assert "Private workspace directive." not in agent.role
    assert "## Personality Context" not in agent.role


@patch("mindroom.agent_storage.SqliteDb")
def test_create_agent_disable_runtime_capabilities_does_not_materialize_private_workspace(
    mock_storage: MagicMock,  # noqa: ARG001
    tmp_path: Path,
) -> None:
    """Restricted in-process agent construction should resolve private paths without creating state."""
    template_dir = tmp_path / "template"
    template_dir.mkdir(parents=True, exist_ok=True)
    (template_dir / "PRIVATE.md").write_text("Private template file.", encoding="utf-8")

    config = _test_config()
    config.agents["general"].private = AgentPrivateConfig(
        per="user",
        root="mind_data",
        template_dir=str(template_dir),
    )
    runtime_paths = _runtime_paths(tmp_path / "storage", config_path=tmp_path / "cfg" / "config.yaml")
    config = _bind_runtime_paths(config, runtime_paths)
    identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id="$thread",
        resolved_thread_id="$thread",
        session_id="session-1",
    )
    runtime = resolve_agent_runtime(
        "general",
        config,
        runtime_paths,
        execution_identity=identity,
        create=False,
    )
    assert runtime.workspace is not None
    assert not runtime.workspace.root.exists()

    agent = create_agent(
        "general",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
        session_id="session-1",
        persist_runtime_state=False,
        disable_runtime_capabilities=True,
    )

    assert agent.tools is None or agent.tools == []
    assert not runtime.workspace.root.exists()


def test_config_rejects_unknown_agent_knowledge_base_assignment() -> None:
    """Agents must not reference unknown knowledge bases."""
    with pytest.raises(ValidationError, match="Agents reference unknown knowledge bases: calculator -> research"):
        Config(
            agents={
                "calculator": AgentConfig(
                    display_name="CalculatorAgent",
                    knowledge_bases=["research"],
                ),
            },
            knowledge_bases={},
        )


def test_config_rejects_legacy_agent_knowledge_base_field() -> None:
    """Legacy singular knowledge_base field must fail fast to avoid silent drops."""
    with pytest.raises(
        ValidationError,
        match=re.escape("Agent field 'knowledge_base' was removed. Use 'knowledge_bases' (list) instead."),
    ):
        Config(
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                    "knowledge_base": "research",
                },
            },
            knowledge_bases={
                "research": KnowledgeBaseConfig(
                    path="./knowledge_docs/research",
                    watch=False,
                ),
            },
        )


def test_config_rejects_removed_knowledge_git_startup_behavior_field() -> None:
    """Removed Git startup behavior field must fail fast to avoid inert config."""
    with pytest.raises(ValidationError, match="startup_behavior"):
        KnowledgeGitConfig.model_validate(
            {
                "repo_url": "https://github.com/example/repo",
                "startup_behavior": "background",
            },
        )


def test_config_rejects_legacy_agent_memory_dir_field() -> None:
    """Legacy memory_dir field must fail fast to avoid silent drops."""
    with pytest.raises(
        ValidationError,
        match=re.escape("Agent field 'memory_dir' was removed. Use 'context_files' and memory.backend=file instead."),
    ):
        Config(
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                    "memory_dir": "./memory",
                },
            },
        )


def test_config_rejects_legacy_agent_sandbox_tools_field() -> None:
    """Legacy sandbox_tools field must fail fast to avoid silent drops."""
    with pytest.raises(
        ValidationError,
        match=re.escape("Agent field 'sandbox_tools' was removed. Use 'worker_tools' instead."),
    ):
        Config(
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                    "sandbox_tools": ["shell"],
                },
            },
        )


def test_config_rejects_legacy_defaults_sandbox_tools_field() -> None:
    """Legacy defaults.sandbox_tools field must fail fast to avoid silent drops."""
    with pytest.raises(
        ValidationError,
        match=re.escape("defaults.sandbox_tools was removed. Use defaults.worker_tools instead."),
    ):
        Config(
            defaults={
                "sandbox_tools": ["shell"],
            },
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                },
            },
        )


def test_config_rejects_legacy_defaults_toolkit_fields() -> None:
    """Removed defaults toolkit knobs should fail fast instead of disappearing."""
    with pytest.raises(
        ValidationError,
        match=re.escape("defaults.allowed_toolkits was removed. Use defaults.tools instead."),
    ):
        Config(
            defaults={"allowed_toolkits": ["shell"]},
            agents={"calculator": {"display_name": "CalculatorAgent"}},
        )

    with pytest.raises(
        ValidationError,
        match=re.escape("defaults.initial_toolkits was removed. Use defaults.tools instead."),
    ):
        Config(
            defaults={"initial_toolkits": ["shell"]},
            agents={"calculator": {"display_name": "CalculatorAgent"}},
        )


def test_config_rejects_legacy_agent_toolkit_fields_with_bundle_safe_hint() -> None:
    """Removed agent toolkit knobs should point users to expand presets before lazy flags."""
    with pytest.raises(
        ValidationError,
        match=re.escape(
            "Agent field 'allowed_toolkits' was removed. Expand toolkit/preset/bundle entries into individual "
            "tools before applying per-tool defer flags in tools.",
        ),
    ):
        Config(
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                    "allowed_toolkits": ["shell"],
                },
            },
        )

    with pytest.raises(
        ValidationError,
        match=re.escape(
            "Agent field 'initial_toolkits' was removed. Expand toolkit/preset/bundle entries into individual "
            "tools before applying per-tool initial flags in tools.",
        ),
    ):
        Config(
            agents={
                "calculator": {
                    "display_name": "CalculatorAgent",
                    "initial_toolkits": ["shell"],
                },
            },
        )


def test_config_rejects_duplicate_agent_knowledge_base_assignment() -> None:
    """Each agent knowledge base assignment should be unique."""
    with pytest.raises(ValidationError, match="Duplicate knowledge bases are not allowed: research"):
        Config(
            agents={
                "calculator": AgentConfig(
                    display_name="CalculatorAgent",
                    knowledge_bases=["research", "research"],
                ),
            },
            knowledge_bases={
                "research": KnowledgeBaseConfig(
                    path="./knowledge_docs/research",
                    watch=False,
                ),
            },
        )


def test_config_resolves_per_agent_memory_backend_override() -> None:
    """Per-agent memory backend overrides should take precedence over global defaults."""
    config = Config(
        agents={
            "general": AgentConfig(display_name="General"),
            "writer": AgentConfig(display_name="Writer", memory_backend="file"),
        },
        memory={"backend": "mem0"},
    )

    assert config.resolve_entity("general").memory_backend == "mem0"
    assert config.resolve_entity("writer").memory_backend == "file"


def test_config_reports_mixed_memory_backend_usage() -> None:
    """Config helper methods should report effective mixed backend usage."""
    config = Config(
        agents={
            "general": AgentConfig(display_name="General", memory_backend="file"),
            "writer": AgentConfig(display_name="Writer", memory_backend="mem0"),
        },
        memory={"backend": "mem0"},
    )

    assert config.uses_file_memory() is True
    assert config.resolve_entity("general").memory_backend == "file"
    assert config.resolve_entity("writer").memory_backend == "mem0"


def test_config_rejects_memory_file_path_even_with_mem0_backend() -> None:
    """memory_file_path should fail fast because the field was removed."""
    with pytest.raises(
        ValidationError,
        match="memory_file_path",
    ):
        Config(
            agents={
                "general": {
                    "display_name": "General",
                    "memory_file_path": "./openclaw_data",
                },
            },
            memory={"backend": "mem0"},
        )


def test_config_rejects_memory_file_path_even_with_file_backend() -> None:
    """memory_file_path should stay removed even when the agent uses file memory."""
    with pytest.raises(ValidationError, match="memory_file_path"):
        Config(
            agents={
                "general": {
                    "display_name": "General",
                    "memory_backend": "file",
                    "memory_file_path": "./openclaw_data",
                },
            },
            memory={"backend": "mem0"},
        )


def test_config_accepts_valid_agent_knowledge_base_assignment() -> None:
    """Agent knowledge base assignment is valid when the base is configured."""
    config = Config(
        agents={
            "calculator": AgentConfig(
                display_name="CalculatorAgent",
                knowledge_bases=["research"],
            ),
        },
        knowledge_bases={
            "research": KnowledgeBaseConfig(
                path="./knowledge_docs/research",
                watch=False,
            ),
        },
    )

    assert config.agents["calculator"].knowledge_bases == ["research"]


def test_knowledge_base_config_preserves_description() -> None:
    """Knowledge bases should carry a user-authored description."""
    config = Config(
        agents={
            "calculator": AgentConfig(
                display_name="CalculatorAgent",
                knowledge_bases=["research"],
            ),
        },
        knowledge_bases={
            "research": KnowledgeBaseConfig(
                description="Research plans, experiment notes, and decision records.",
                path="./knowledge_docs/research",
                watch=False,
            ),
        },
    )

    assert config.knowledge_bases["research"].description == ("Research plans, experiment notes, and decision records.")


def test_knowledge_base_config_defaults_to_semantic_mode() -> None:
    """Existing knowledge bases should keep semantic search unless configured otherwise."""
    config = Config(
        agents={"calculator": AgentConfig(display_name="Calculator", knowledge_bases=["research"])},
        models={},
        knowledge_bases={
            "research": KnowledgeBaseConfig(
                path="./knowledge_docs/research",
            ),
        },
    )

    assert config.knowledge_bases["research"].mode == "semantic"


def test_file_mode_agent_instructions_list_workspace_knowledge_path(tmp_path: Path) -> None:
    """File-only knowledge should tell workspace-aware agents where to use grep/read tools."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    workspace_root = tmp_path / "agents" / "general" / "workspace"
    knowledge_root = workspace_root / "research"
    knowledge_root.mkdir(parents=True, exist_ok=True)
    config.agents["general"].memory_backend = "file"
    config.agents["general"].knowledge_bases = ["research"]
    config.knowledge_bases["research"] = KnowledgeBaseConfig(
        description="Research notes and decision records.",
        path=str(knowledge_root),
        mode="files",
    )

    agent = _create_agent_for_test("general", config)

    rendered_instructions = "\n".join(str(instruction) for instruction in agent.instructions)
    assert "File-only knowledge bases are available in the workspace." in rendered_instructions
    assert "- research: `knowledge/research`" in rendered_instructions
    assert "Research notes and decision records." in rendered_instructions
    assert "search_knowledge_base" in rendered_instructions


def test_workspace_agent_instructions_include_skill_authoring_guidance(tmp_path: Path) -> None:
    """Agents with workspace-rooted tools should learn they can author skills in their workspace."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].memory_backend = "file"

    agent = _create_agent_for_test("general", config)

    assert WORKSPACE_SKILL_AUTHORING_PROMPT in agent.instructions


def test_agent_without_workspace_omits_skill_authoring_guidance(tmp_path: Path) -> None:
    """Agents without a workspace should not be told to write workspace skill files."""
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(_test_config(), runtime_paths)
    config.agents["general"].memory_backend = "mem0"

    agent = _create_agent_for_test("general", config)

    assert WORKSPACE_SKILL_AUTHORING_PROMPT not in agent.instructions


@pytest.mark.parametrize("has_memory_tool", [False, True])
@pytest.mark.parametrize("async_mode", [False, True])
@pytest.mark.asyncio
async def test_agent_knowledge_search_tool_description_lists_configured_sources_by_capability(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    has_memory_tool: bool,
    async_mode: bool,
) -> None:
    """Sync and async knowledge tools recommend memory search only when that function exists."""
    config = _test_config()
    config.agents["general"].tools = ["memory"] if has_memory_tool else []
    config.agents["general"].knowledge_bases = ["engineering", "product"]
    config.knowledge_bases = {
        "engineering": KnowledgeBaseConfig(
            description="Architecture docs, ADRs, deployment runbooks, and coding conventions.",
            path="./knowledge_docs/engineering",
        ),
        "product": KnowledgeBaseConfig(
            description="Product requirements, feature specs, roadmap notes, and user-facing behavior decisions.",
            path="./knowledge_docs/product",
        ),
    }
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(config, runtime_paths)
    _patch_published_knowledge(
        monkeypatch,
        {
            "engineering": _queryable_knowledge_handle(),
            "product": _queryable_knowledge_handle(),
        },
    )

    knowledge = resolve_agent_knowledge_access("general", config, runtime_paths).knowledge
    assert knowledge is not None
    agent = _create_agent_for_test("general", config, knowledge=knowledge)
    if has_memory_tool and not async_mode:
        agent.tools = [
            *(agent.tools or []),
            Function(name="search_memories", entrypoint=lambda: "memory result"),
        ]
    run_output = RunOutput(
        run_id="run-knowledge-description",
        agent_id="general",
        agent_name="GeneralAgent",
        session_id="session-knowledge-description",
        input="hello",
        content="ok",
    )
    run_context = RunContext(run_id="run-knowledge-description", session_id="session-knowledge-description")
    session = AgentSession(
        session_id="session-knowledge-description",
        agent_id="general",
        created_at=1,
        updated_at=1,
    )

    tools = (
        await agent.aget_tools(run_output, run_context, session)
        if async_mode
        else agent.get_tools(run_output, run_context, session)
    )
    search_tools = [tool for tool in tools if isinstance(tool, Function) and tool.name == "search_knowledge_base"]

    assert len(search_tools) == 1
    description = search_tools[0].description
    assert description is not None
    assert "Search this agent's configured knowledge bases" in description
    assert "- engineering: Architecture docs, ADRs, deployment runbooks, and coding conventions." in description
    assert (
        "- product: Product requirements, feature specs, roadmap notes, and user-facing behavior decisions."
        in description
    )
    assert "This list only describes sources available through search_knowledge_base." in description
    recommendation = "For resilient memory search, team-visible memory, and memory IDs, use search_memories."
    assert (recommendation in description) is has_memory_tool


def test_memory_only_agent_gets_semantic_knowledge_search_description(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A ready file-memory index should enable and describe knowledge search without authored bases."""
    config = _test_config()
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(config, runtime_paths)
    config.memory.backend = "file"
    config.memory.search.mode = "semantic"
    seen_base_ids: list[str] = []

    def _get_published_index(base_id: str, **kwargs: object) -> object:
        effective_config = kwargs["config"]
        assert isinstance(effective_config, Config)
        base_config = effective_config.knowledge_bases[base_id]
        assert base_config.path == str(agent_workspace_root_path(runtime_paths.storage_root, "general").resolve())
        seen_base_ids.append(base_id)
        return SimpleNamespace(
            key=SimpleNamespace(base_id=base_id),
            index=SimpleNamespace(
                knowledge=_queryable_knowledge_handle(),
                state=SimpleNamespace(last_refresh_at=None, last_published_at=None),
            ),
            availability=KnowledgeAvailability.READY,
            state=None,
        )

    monkeypatch.setattr("mindroom.knowledge.utils.get_published_index", _get_published_index)

    knowledge = resolve_agent_knowledge_access("general", config, runtime_paths).knowledge
    assert knowledge is not None
    agent = _create_agent_for_test("general", config, knowledge=knowledge)
    run_output = RunOutput(
        run_id="run-memory-knowledge-description",
        agent_id="general",
        agent_name="GeneralAgent",
        session_id="session-memory-knowledge-description",
        input="hello",
        content="ok",
    )
    run_context = RunContext(
        run_id="run-memory-knowledge-description",
        session_id="session-memory-knowledge-description",
    )
    session = AgentSession(
        session_id="session-memory-knowledge-description",
        agent_id="general",
        created_at=1,
        updated_at=1,
    )

    search_tools = [
        tool
        for tool in agent.get_tools(run_output, run_context, session)
        if isinstance(tool, Function) and tool.name == "search_knowledge_base"
    ]

    assert len(search_tools) == 1
    assert len(seen_base_ids) == 1
    assert seen_base_ids[0].startswith("file_memory_agent_general_")
    description = search_tools[0].description
    assert description is not None
    assert (
        f"- {seen_base_ids[0]}: Configured file memory for this agent. "
        "Read-only semantic search over configured Markdown paths (default: memory/**/*.md)."
    ) in description
    assert "use search_memories" not in description


def test_agent_knowledge_search_tool_description_preserves_colon_space_source_ids(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A valid knowledge base ID containing ': ' should render as the same source ID."""
    base_id = "foo: bar"
    config = _test_config()
    config.agents["general"].knowledge_bases = [base_id, "product"]
    config.knowledge_bases = {
        base_id: KnowledgeBaseConfig(
            description="Special source with punctuation in its ID.",
            path="./knowledge_docs/special",
        ),
        "product": KnowledgeBaseConfig(
            description="Product requirements and feature specs.",
            path="./knowledge_docs/product",
        ),
    }
    runtime_paths = _runtime_paths(tmp_path)
    config = _bind_runtime_paths(config, runtime_paths)
    _patch_published_knowledge(
        monkeypatch,
        {
            base_id: _queryable_knowledge_handle(),
            "product": _queryable_knowledge_handle(),
        },
    )

    knowledge = resolve_agent_knowledge_access("general", config, runtime_paths).knowledge
    assert knowledge is not None
    agent = _create_agent_for_test("general", config, knowledge=knowledge)
    run_output = RunOutput(
        run_id="run-knowledge-description-colon",
        agent_id="general",
        agent_name="GeneralAgent",
        session_id="session-knowledge-description-colon",
        input="hello",
        content="ok",
    )
    run_context = RunContext(run_id="run-knowledge-description-colon", session_id="session-knowledge-description-colon")
    session = AgentSession(
        session_id="session-knowledge-description-colon",
        agent_id="general",
        created_at=1,
        updated_at=1,
    )

    search_tools = [
        tool
        for tool in agent.get_tools(run_output, run_context, session)
        if isinstance(tool, Function) and tool.name == "search_knowledge_base"
    ]

    assert len(search_tools) == 1
    description = search_tools[0].description
    assert description is not None
    assert "- foo: bar: Special source with punctuation in its ID." in description


def test_agent_knowledge_search_tool_description_excludes_unavailable_sources(tmp_path: Path) -> None:
    """The model-facing knowledge search tool should only list queryable sources."""
    config = _test_config()
    config.agents["general"].knowledge_bases = ["engineering", "legal"]
    config.knowledge_bases = {
        "engineering": KnowledgeBaseConfig(
            description="Architecture docs, ADRs, deployment runbooks, and coding conventions.",
            path="./knowledge_docs/engineering",
        ),
        "legal": KnowledgeBaseConfig(
            description="Contracts, regulatory notes, and legal review records.",
            path="./knowledge_docs/legal",
        ),
    }
    config = _bind_runtime_paths(config, _runtime_paths(tmp_path))
    ready_knowledge = Knowledge(
        name="engineering",
        description="Architecture docs, ADRs, deployment runbooks, and coding conventions.",
    )

    agent = _create_agent_for_test("general", config, knowledge=ready_knowledge)
    run_output = RunOutput(
        run_id="run-knowledge-description-mixed",
        agent_id="general",
        agent_name="GeneralAgent",
        session_id="session-knowledge-description-mixed",
        input="hello",
        content="ok",
    )
    run_context = RunContext(run_id="run-knowledge-description-mixed", session_id="session-knowledge-description-mixed")
    session = AgentSession(
        session_id="session-knowledge-description-mixed",
        agent_id="general",
        created_at=1,
        updated_at=1,
    )

    search_tools = [
        tool
        for tool in agent.get_tools(run_output, run_context, session)
        if isinstance(tool, Function) and tool.name == "search_knowledge_base"
    ]

    assert len(search_tools) == 1
    description = search_tools[0].description
    assert description is not None
    assert "- engineering: Architecture docs, ADRs, deployment runbooks, and coding conventions." in description
    assert "legal" not in description


def test_agent_accepts_custom_knowledge_protocol_without_source_metadata(tmp_path: Path) -> None:
    """Custom knowledge implementations need not expose Agno Knowledge metadata fields."""

    def search_knowledge_base(query: str) -> str:
        """Search the custom knowledge backend."""
        return f"custom result for {query}"

    class CustomKnowledge:
        def build_context(self, **_kwargs: object) -> str:
            return "Use the custom knowledge backend."

        def get_tools(self, **_kwargs: object) -> list[object]:
            return [search_knowledge_base]

        async def aget_tools(self, **_kwargs: object) -> list[object]:
            return [search_knowledge_base]

    config = _test_config()
    config.agents["general"].knowledge_bases = ["custom"]
    config.knowledge_bases = {
        "custom": KnowledgeBaseConfig(
            description="Custom protocol-backed search.",
            path="./knowledge_docs/custom",
        ),
    }
    config = _bind_runtime_paths(config, _runtime_paths(tmp_path))

    agent = _create_agent_for_test("general", config, knowledge=CustomKnowledge())

    assert agent.knowledge is not None


@pytest.mark.parametrize("base_id", ["foo\nbar", "foo\rbar"])
def test_config_rejects_knowledge_base_ids_with_line_breaks(base_id: str) -> None:
    """Knowledge base IDs must not inject extra model-facing source-list lines."""
    with pytest.raises(
        ValidationError,
        match=re.escape(
            f"knowledge_bases keys must not contain line breaks; invalid keys: {base_id}",
        ),
    ):
        Config(
            agents={
                "calculator": AgentConfig(
                    display_name="CalculatorAgent",
                    knowledge_bases=[base_id],
                ),
            },
            knowledge_bases={
                base_id: KnowledgeBaseConfig(
                    path="./knowledge_docs/research",
                    watch=False,
                ),
            },
        )


@pytest.mark.parametrize("base_id", ["", ".", "..", "group/research"])
def test_config_rejects_knowledge_base_ids_that_are_not_normal_single_path_components(base_id: str) -> None:
    """Knowledge base IDs must stay single-component and avoid dot-segment aliases."""
    with pytest.raises(
        ValidationError,
        match=re.escape(
            "knowledge_bases keys must be non-empty single path components without path separators or dot segments; "
            f"invalid keys: {base_id}",
        ),
    ):
        Config(
            agents={
                "calculator": AgentConfig(
                    display_name="CalculatorAgent",
                    knowledge_bases=[base_id],
                ),
            },
            knowledge_bases={
                base_id: KnowledgeBaseConfig(
                    path="./knowledge_docs/research",
                    watch=False,
                ),
            },
        )


def test_config_rejects_reserved_private_knowledge_base_prefix() -> None:
    """Top-level knowledge base IDs must not collide with synthetic private IDs."""
    with pytest.raises(
        ValidationError,
        match=re.escape(
            "knowledge_bases keys must not use the reserved private prefix '__agent_private__:'; "
            "invalid keys: __agent_private__:mind",
        ),
    ):
        Config(
            agents={
                "mind": AgentConfig(display_name="Mind"),
            },
            knowledge_bases={
                "__agent_private__:mind": KnowledgeBaseConfig(path="./company_docs"),
            },
        )


def test_config_private_knowledge_requires_path_without_template_default() -> None:
    """Private knowledge needs an explicit path whenever it is enabled."""
    with pytest.raises(
        ValidationError,
        match=re.escape(
            "agents.<name>.private.knowledge.path is required when private.knowledge is enabled; invalid agents: mind",
        ),
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        knowledge=AgentPrivateKnowledgeConfig(watch=False),
                    ),
                ),
            },
        )


@pytest.mark.parametrize(
    ("root", "expected_message"),
    [
        ("", "private.root must not be empty"),
        ("   ", "private.root must not be empty"),
        (".", "private.root must not be the workspace root"),
        ("sessions", "private.root must not use reserved runtime directory 'sessions'"),
        ("sessions/nested", "private.root must not use reserved runtime directory 'sessions'"),
        ("learning", "private.root must not use reserved runtime directory 'learning'"),
        ("knowledge_db", "private.root must not use reserved runtime directory 'knowledge_db'"),
        ("chroma", "private.root must not use reserved runtime directory 'chroma'"),
        ("culture", "private.root must not use reserved runtime directory 'culture'"),
    ],
)
def test_config_rejects_invalid_private_root_values(root: str, expected_message: str) -> None:
    """Private roots must stay out of runtime-managed directories and the private-instance root itself."""
    with pytest.raises(ValidationError, match=re.escape(expected_message)):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        root=root,
                    ),
                ),
            },
        )


def test_config_rejects_removed_room_thread_private_scope() -> None:
    """Private requester scopes should no longer accept room-thread isolation."""
    with pytest.raises(ValidationError, match="Input should be 'user' or 'user_agent'"):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=cast("AgentPrivateConfig", {"per": "room_thread"}),
                ),
            },
        )


def test_config_rejects_removed_room_thread_worker_scope() -> None:
    """Worker scope should no longer accept room-thread reuse."""
    with pytest.raises(ValidationError, match="Input should be 'shared', 'user' or 'user_agent'"):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    worker_scope=cast("WorkerScope", "room_thread"),
                ),
            },
        )


@pytest.mark.parametrize("path", ["", "   "])
def test_config_rejects_blank_private_knowledge_path(path: str) -> None:
    """Blank private knowledge paths should be rejected explicitly."""
    with pytest.raises(ValidationError, match=re.escape("private.knowledge.path must not be empty")):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        knowledge=AgentPrivateKnowledgeConfig(path=path),
                    ),
                ),
            },
        )


def test_config_accepts_private_knowledge_path_dot_for_private_root() -> None:
    """A dot path is allowed to index the entire private root explicitly."""
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                private=AgentPrivateConfig(
                    per="user",
                    root="mind_data",
                    knowledge=AgentPrivateKnowledgeConfig(path="."),
                ),
            ),
        },
    )

    private_base_id = config.resolve_entity("mind").private_knowledge_base_id
    assert private_base_id is not None
    assert config.get_knowledge_base_config(private_base_id).path == "."


def test_config_rejects_private_agents_in_teams() -> None:
    """Configured teams must not include private agents."""
    with pytest.raises(
        ValidationError,
        match="Team 'mixed_team' includes private agent 'mind'; private agents are only supported in explicit Matrix ad hoc teams with requester identity",
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(per="user", root="mind_data"),
                ),
                "calculator": AgentConfig(display_name="Calculator"),
            },
            teams={
                "mixed_team": TeamConfig(
                    display_name="Mixed Team",
                    role="Mixed team",
                    agents=["mind", "calculator"],
                ),
            },
        )


def test_config_rejects_empty_teams() -> None:
    """Configured teams must name at least one member."""
    with pytest.raises(ValidationError, match="List should have at least 1 item"):
        Config(
            agents={"calculator": AgentConfig(display_name="Calculator")},
            teams={
                "empty_team": TeamConfig(
                    display_name="Empty Team",
                    role="Nobody home",
                    agents=[],
                ),
            },
        )


def test_config_rejects_duplicate_team_members() -> None:
    """Configured teams must preserve an exact member set with no duplicates."""
    with pytest.raises(ValidationError, match="Duplicate agents are not allowed in a team: calculator"):
        Config(
            agents={"calculator": AgentConfig(display_name="Calculator")},
            teams={
                "duplicate_team": TeamConfig(
                    display_name="Duplicate Team",
                    role="Repeated member",
                    agents=["calculator", "calculator"],
                ),
            },
        )


def test_config_rejects_teams_with_members_that_delegate_to_private_agents() -> None:
    """Configured teams must reject shared members that reach private agents via delegation."""
    with pytest.raises(
        ValidationError,
        match=(
            "Team 'mixed_team' includes agent 'leader' which reaches private agent 'mind' "
            "via delegation; private delegation is not supported for teams"
        ),
    ):
        Config(
            agents={
                "leader": AgentConfig(display_name="Leader", delegate_to=["mind"]),
                "helper": AgentConfig(display_name="Helper"),
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(per="user", root="mind_data"),
                ),
            },
            teams={
                "mixed_team": TeamConfig(
                    display_name="Mixed Team",
                    role="Mixed team",
                    agents=["leader", "helper"],
                ),
            },
        )


def test_config_rejects_shared_only_integrations_for_isolating_worker_scope() -> None:
    """Agents with isolating worker scope must not use shared-only integrations."""
    with pytest.raises(
        ValidationError,
        match=r"general -> homeassistant \(worker_scope=user\)",
    ):
        Config(
            agents={
                "general": AgentConfig(
                    display_name="General",
                    tools=["homeassistant"],
                    worker_scope="user",
                ),
            },
        )


def test_config_rejects_shared_only_integrations_inherited_from_defaults() -> None:
    """Shared-only defaults.tools must still be rejected for isolating agents."""
    with pytest.raises(
        ValidationError,
        match=r"mind -> homeassistant \(private\.per=user_agent\)",
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(per="user_agent", root="mind_data"),
                ),
            },
            defaults=DefaultsConfig(tools=["homeassistant"]),
        )


def test_config_private_and_shared_knowledge_coexist() -> None:
    """Agents can combine requester-private knowledge with shared top-level knowledge bases."""
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                private=AgentPrivateConfig(
                    per="user",
                    template_dir="./mind_template",
                    knowledge=AgentPrivateKnowledgeConfig(path="memory"),
                ),
                knowledge_bases=["company_docs"],
            ),
        },
        knowledge_bases={
            "company_docs": KnowledgeBaseConfig(path="./company_docs"),
        },
    )

    private_base_id = config.resolve_entity("mind").private_knowledge_base_id
    assert private_base_id is not None
    assert config.resolve_entity("mind").knowledge_base_ids == ["company_docs", private_base_id]
    private_config = config.get_knowledge_base_config(private_base_id)
    assert private_config.path == "memory"


def test_template_dir_does_not_imply_private_knowledge() -> None:
    """Copying from a template directory alone should not create a private knowledge base."""
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                private=AgentPrivateConfig(
                    per="user",
                    template_dir="./mind_template",
                ),
            ),
        },
    )

    assert config.resolve_entity("mind").private_knowledge_base_id is None
    assert config.resolve_entity("mind").knowledge_base_ids == []


def test_get_private_knowledge_base_agent_requires_active_private_knowledge() -> None:
    """Synthetic private base IDs should resolve only while private knowledge is actually active."""
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                private=AgentPrivateConfig(
                    per="user",
                    template_dir="./mind_template",
                    knowledge=AgentPrivateKnowledgeConfig(path="memory"),
                ),
            ),
            "assistant": AgentConfig(display_name="Assistant"),
        },
        knowledge_bases={
            "company_docs": KnowledgeBaseConfig(path="./company_docs"),
        },
    )

    assert config.get_private_knowledge_base_agent("__agent_private__:mind") == "mind"
    assert config.get_private_knowledge_base_agent("__agent_private__:assistant") is None
    assert config.get_private_knowledge_base_agent("__agent_private__:missing") is None


def test_config_rejects_duplicate_default_tools() -> None:
    """Default tools should be unique."""
    with pytest.raises(ValidationError, match="Duplicate default tools are not allowed: scheduler"):
        Config(
            defaults={"tools": ["scheduler", "scheduler"]},
        )


def test_config_rejects_culture_with_unknown_agent() -> None:
    """Culture assignments must reference configured agents."""
    with pytest.raises(ValidationError, match="Cultures reference unknown agents: engineering -> missing_agent"):
        Config(
            agents={
                "calculator": AgentConfig(display_name="CalculatorAgent"),
            },
            cultures={
                "engineering": CultureConfig(
                    description="Engineering standards",
                    agents=["missing_agent"],
                    mode="automatic",
                ),
            },
        )


def test_config_rejects_agents_in_multiple_cultures() -> None:
    """An agent can belong to at most one culture."""
    with pytest.raises(
        ValidationError,
        match="Agents cannot belong to multiple cultures: calculator -> engineering, support",
    ):
        Config(
            agents={
                "calculator": AgentConfig(display_name="CalculatorAgent"),
            },
            cultures={
                "engineering": CultureConfig(agents=["calculator"]),
                "support": CultureConfig(agents=["calculator"]),
            },
        )


def test_config_accepts_valid_culture_assignment() -> None:
    """Config should expose culture assignment helpers for valid culture definitions."""
    config = Config(
        agents={
            "calculator": AgentConfig(display_name="CalculatorAgent"),
            "summary": AgentConfig(display_name="SummaryAgent"),
        },
        cultures={
            "engineering": CultureConfig(
                description="Shared engineering practices",
                agents=["calculator", "summary"],
                mode="automatic",
            ),
        },
    )

    assignment = config.resolve_entity("calculator").culture
    assert assignment is not None
    culture_name, culture_config = assignment
    assert culture_name == "engineering"
    assert culture_config.mode == "automatic"
    assert config.resolve_entity("unknown").culture is None


def test_config_rejects_git_backed_private_knowledge_inside_private_memory_tree() -> None:
    """Git-backed private knowledge must use a dedicated subtree outside private writable content."""
    with pytest.raises(
        ValidationError,
        match=r"git-backed private knowledge at 'memory'.*dedicated subtree",
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        template_dir="./mind_template",
                        knowledge=AgentPrivateKnowledgeConfig(
                            path="memory",
                            git=KnowledgeGitConfig(repo_url="https://github.com/example/repo", branch="main"),
                        ),
                    ),
                    memory_backend="file",
                ),
            },
            models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
        )


def test_config_rejects_git_backed_private_knowledge_at_memory_entrypoint() -> None:
    """Git-backed private knowledge must not target the file-memory entrypoint file."""
    with pytest.raises(
        ValidationError,
        match=r"git-backed private knowledge at 'MEMORY.md'.*dedicated subtree",
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        template_dir="./mind_template",
                        knowledge=AgentPrivateKnowledgeConfig(
                            path="MEMORY.md",
                            git=KnowledgeGitConfig(repo_url="https://github.com/example/repo", branch="main"),
                        ),
                    ),
                    memory_backend="file",
                ),
            },
            models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
        )


def test_config_allows_git_backed_private_knowledge_in_dedicated_subtree() -> None:
    """Dedicated private knowledge subtrees remain valid for git-backed sync."""
    config = Config(
        agents={
            "mind": AgentConfig(
                display_name="Mind",
                private=AgentPrivateConfig(
                    per="user",
                    root="mind_data",
                    template_dir="./mind_template",
                    knowledge=AgentPrivateKnowledgeConfig(
                        path="kb_repo",
                        git=KnowledgeGitConfig(repo_url="https://github.com/example/repo", branch="main"),
                    ),
                ),
                memory_backend="file",
            ),
        },
        models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
    )

    assert config.agents["mind"].private is not None
    assert config.agents["mind"].private.knowledge is not None
    assert config.agents["mind"].private.knowledge.path == "kb_repo"


def test_config_rejects_git_backed_private_knowledge_at_private_root() -> None:
    """Git-backed private knowledge must never target the private root itself."""
    with pytest.raises(
        ValidationError,
        match=r"git-backed private knowledge at '\.'.*dedicated subtree",
    ):
        Config(
            agents={
                "mind": AgentConfig(
                    display_name="Mind",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(
                            path=".",
                            git=KnowledgeGitConfig(repo_url="https://github.com/example/repo", branch="main"),
                        ),
                    ),
                    memory_backend="mem0",
                ),
            },
            models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
        )


def test_config_rejects_git_backed_private_knowledge_overlapping_template_content(tmp_path: Path) -> None:
    """Git-backed private knowledge must not overlap any template-seeded subtree."""
    from tests.conftest import bind_runtime_paths  # noqa: PLC0415

    template_dir = tmp_path / "mind_template"
    (template_dir / "docs").mkdir(parents=True)
    (template_dir / "docs" / "README.md").write_text("seeded\n", encoding="utf-8")
    runtime_paths = _runtime_paths(tmp_path)

    with pytest.raises(
        ValidationError,
        match=r"git-backed private knowledge at 'docs'.*scaffolded private workspace content",
    ):
        bind_runtime_paths(
            Config(
                agents={
                    "mind": AgentConfig(
                        display_name="Mind",
                        private=AgentPrivateConfig(
                            per="user",
                            root="mind_data",
                            template_dir="./mind_template",
                            knowledge=AgentPrivateKnowledgeConfig(
                                path="docs",
                                git=KnowledgeGitConfig(repo_url="https://github.com/example/repo", branch="main"),
                            ),
                        ),
                    ),
                },
                models={"default": ModelConfig(provider="openai", id="gpt-4o-mini")},
            ),
            runtime_paths,
        )


@patch("mindroom.agent_storage.SqliteDb")
@patch("mindroom.agents.CultureManager")
@patch("mindroom.agents.Agent")
def test_create_agent_shares_culture_manager_for_same_culture(
    mock_agent_class: MagicMock,
    mock_culture_manager_class: MagicMock,
    mock_storage: MagicMock,
    tmp_path: Path,
) -> None:
    """Agents in the same culture should share one CultureManager and culture DB."""
    _CULTURE_MANAGER_CACHE.clear()
    config = Config(
        agents={
            "agent_one": AgentConfig(
                display_name="Agent One",
                role="First",
                learning=False,
                include_default_tools=False,
            ),
            "agent_two": AgentConfig(
                display_name="Agent Two",
                role="Second",
                learning=False,
                include_default_tools=False,
            ),
        },
        cultures={
            "engineering": CultureConfig(
                description="Engineering best practices",
                agents=["agent_one", "agent_two"],
                mode="automatic",
            ),
        },
        models={
            "default": ModelConfig(provider="openai", id="gpt-4o-mini"),
        },
    )

    model = MagicMock()
    model.id = "gpt-4o-mini"
    runtime_paths = _runtime_paths(tmp_path)
    bound_config = _bind_runtime_paths(config, runtime_paths)
    with patch("mindroom.model_loading.get_model_instance", return_value=model):
        _create_agent_for_test(
            "agent_one",
            config=bound_config,
            include_interactive_questions=False,
        )
        _create_agent_for_test(
            "agent_two",
            config=bound_config,
            include_interactive_questions=False,
        )

    assert mock_culture_manager_class.call_count == 1
    assert len(_CULTURE_MANAGER_CACHE) == 1
    first_kwargs = mock_agent_class.call_args_list[0].kwargs
    second_kwargs = mock_agent_class.call_args_list[1].kwargs

    assert first_kwargs["culture_manager"] is second_kwargs["culture_manager"]
    assert first_kwargs["add_culture_to_context"] is True
    assert first_kwargs["update_cultural_knowledge"] is True
    assert first_kwargs["enable_agentic_culture"] is False

    culture_db_calls = [
        call
        for call in mock_storage.call_args_list
        if str(call.kwargs.get("db_file", "")).endswith("/culture/engineering.db")
    ]
    assert len(culture_db_calls) == 1


@patch("mindroom.agent_storage.SqliteDb")
@patch("mindroom.agents.CultureManager")
@patch("mindroom.agents.Agent")
def test_create_agent_culture_uses_agent_model_when_default_missing(
    mock_agent_class: MagicMock,
    mock_culture_manager_class: MagicMock,
    mock_storage: MagicMock,
    tmp_path: Path,
) -> None:
    """Culture manager should not require models.default when an agent model is configured."""
    _CULTURE_MANAGER_CACHE.clear()
    config = Config(
        agents={
            "agent_one": AgentConfig(
                display_name="Agent One",
                role="First",
                model="m1",
                learning=False,
                include_default_tools=False,
            ),
        },
        cultures={
            "engineering": CultureConfig(
                description="Engineering best practices",
                agents=["agent_one"],
                mode="automatic",
            ),
        },
        models={
            "m1": ModelConfig(provider="openai", id="gpt-4o-mini"),
        },
    )

    model = MagicMock()
    model.id = "gpt-4o-mini"
    runtime_paths = _runtime_paths(tmp_path)
    with patch("mindroom.model_loading.get_model_instance", return_value=model) as mock_get_model_instance:
        _create_agent_for_test(
            "agent_one",
            config=_bind_runtime_paths(config, runtime_paths),
            include_interactive_questions=False,
        )

    mock_get_model_instance.assert_called_once()
    call_args = mock_get_model_instance.call_args
    assert call_args.args[2] == "m1"  # model_name
    assert mock_agent_class.call_count == 1
    db_files = [Path(str(call.kwargs["db_file"])) for call in mock_storage.call_args_list]
    assert agent_state_root_path(tmp_path, "agent_one") / "sessions" / "agent_one.db" in db_files
    assert tmp_path / "culture" / "engineering.db" in db_files
    assert mock_culture_manager_class.call_args is not None
    assert mock_culture_manager_class.call_args.kwargs["model"] is model


@patch("mindroom.agent_storage.SqliteDb")
@patch("mindroom.agents.CultureManager")
@patch("mindroom.agents.Agent")
def test_create_private_agent_scopes_culture_storage_per_requester(
    mock_agent_class: MagicMock,
    mock_culture_manager_class: MagicMock,
    mock_storage: MagicMock,
    tmp_path: Path,
) -> None:
    """Private agents should not share culture storage across requester instances."""
    _CULTURE_MANAGER_CACHE.clear()
    _PRIVATE_CULTURE_MANAGER_CACHE.clear()
    config = Config(
        agents={
            "general": AgentConfig(
                display_name="GeneralAgent",
                role="General assistant",
                learning=False,
                include_default_tools=False,
                private=AgentPrivateConfig(per="user", root="mind_data"),
            ),
        },
        cultures={
            "engineering": CultureConfig(
                description="Engineering best practices",
                agents=["general"],
                mode="automatic",
            ),
        },
        models={
            "default": ModelConfig(provider="openai", id="gpt-4o-mini"),
        },
    )

    runtime_paths = _runtime_paths(tmp_path)
    bound_config = _bind_runtime_paths(config, runtime_paths)
    model = MagicMock()
    model.id = "gpt-4o-mini"
    created_culture_managers = [MagicMock(name="alice_culture_manager"), MagicMock(name="bob_culture_manager")]
    mock_culture_manager_class.side_effect = created_culture_managers

    alice_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
    )
    bob_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="general",
        requester_id="@bob:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
    )

    with patch("mindroom.model_loading.get_model_instance", return_value=model):
        _create_agent_for_test(
            "general",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=alice_identity,
        )
        _create_agent_for_test(
            "general",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=bob_identity,
        )

    assert mock_culture_manager_class.call_count == 2
    culture_db_calls = [
        str(call.kwargs.get("db_file", ""))
        for call in mock_storage.call_args_list
        if str(call.kwargs.get("db_file", "")).endswith("/culture/engineering.db")
    ]
    assert len(culture_db_calls) == 2
    assert culture_db_calls[0] != culture_db_calls[1]
    assert "/private_instances/" in culture_db_calls[0]
    assert "/private_instances/" in culture_db_calls[1]
    assert _CULTURE_MANAGER_CACHE == {}
    first_kwargs = mock_agent_class.call_args_list[0].kwargs
    second_kwargs = mock_agent_class.call_args_list[1].kwargs
    assert first_kwargs["culture_manager"] is created_culture_managers[0]
    assert second_kwargs["culture_manager"] is created_culture_managers[1]


@patch("mindroom.agent_storage.SqliteDb")
@patch("mindroom.agents.CultureManager")
@patch("mindroom.agents.Agent")
def test_private_agents_share_culture_manager_within_same_requester_scope(
    mock_agent_class: MagicMock,
    mock_culture_manager_class: MagicMock,
    mock_storage: MagicMock,
    tmp_path: Path,
) -> None:
    """Private agents in the same culture should share one requester-scoped culture manager."""
    _CULTURE_MANAGER_CACHE.clear()
    _PRIVATE_CULTURE_MANAGER_CACHE.clear()
    config = Config(
        agents={
            "agent_one": AgentConfig(
                display_name="Agent One",
                role="First",
                learning=False,
                include_default_tools=False,
                private=AgentPrivateConfig(per="user", root="mind_data"),
            ),
            "agent_two": AgentConfig(
                display_name="Agent Two",
                role="Second",
                learning=False,
                include_default_tools=False,
                private=AgentPrivateConfig(per="user", root="mind_data"),
            ),
        },
        cultures={
            "engineering": CultureConfig(
                description="Engineering best practices",
                agents=["agent_one", "agent_two"],
                mode="automatic",
            ),
        },
        models={
            "default": ModelConfig(provider="openai", id="gpt-4o-mini"),
        },
    )

    runtime_paths = _runtime_paths(tmp_path)
    bound_config = _bind_runtime_paths(config, runtime_paths)
    model = MagicMock()
    model.id = "gpt-4o-mini"
    execution_identity = ToolExecutionIdentity(
        channel="matrix",
        agent_name="agent_one",
        requester_id="@alice:example.org",
        room_id="!room:example.org",
        thread_id=None,
        resolved_thread_id=None,
        session_id=None,
    )
    created_culture_manager = MagicMock(name="shared_private_culture_manager")
    mock_culture_manager_class.return_value = created_culture_manager

    with patch("mindroom.model_loading.get_model_instance", return_value=model):
        _create_agent_for_test(
            "agent_one",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=execution_identity,
        )
        _create_agent_for_test(
            "agent_two",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=ToolExecutionIdentity(
                channel="matrix",
                agent_name="agent_two",
                requester_id="@alice:example.org",
                room_id="!room:example.org",
                thread_id=None,
                resolved_thread_id=None,
                session_id=None,
            ),
        )

    assert mock_culture_manager_class.call_count == 1
    culture_db_calls = [
        str(call.kwargs.get("db_file", ""))
        for call in mock_storage.call_args_list
        if str(call.kwargs.get("db_file", "")).endswith("/culture/engineering.db")
    ]
    assert len(culture_db_calls) == 1
    assert "/private_instances/" in culture_db_calls[0]
    assert "/agent_one/" not in culture_db_calls[0]
    assert "/agent_two/" not in culture_db_calls[0]
    first_kwargs = mock_agent_class.call_args_list[0].kwargs
    second_kwargs = mock_agent_class.call_args_list[1].kwargs
    assert first_kwargs["culture_manager"] is created_culture_manager
    assert second_kwargs["culture_manager"] is created_culture_manager


@patch("mindroom.agent_storage.SqliteDb")
@patch("mindroom.agents.CultureManager")
@patch("mindroom.agents.Agent")
def test_private_user_agent_agents_share_culture_manager_within_same_requester_scope(
    mock_agent_class: MagicMock,
    mock_culture_manager_class: MagicMock,
    mock_storage: MagicMock,
    tmp_path: Path,
) -> None:
    """Private user_agent cultures should share one requester-scoped culture manager."""
    _CULTURE_MANAGER_CACHE.clear()
    _PRIVATE_CULTURE_MANAGER_CACHE.clear()
    config = Config(
        agents={
            "agent_one": AgentConfig(
                display_name="Agent One",
                role="First",
                learning=False,
                include_default_tools=False,
                private=AgentPrivateConfig(per="user_agent", root="mind_data"),
            ),
            "agent_two": AgentConfig(
                display_name="Agent Two",
                role="Second",
                learning=False,
                include_default_tools=False,
                private=AgentPrivateConfig(per="user_agent", root="mind_data"),
            ),
        },
        cultures={
            "engineering": CultureConfig(
                description="Engineering best practices",
                agents=["agent_one", "agent_two"],
                mode="automatic",
            ),
        },
        models={
            "default": ModelConfig(provider="openai", id="gpt-4o-mini"),
        },
    )

    runtime_paths = _runtime_paths(tmp_path)
    bound_config = _bind_runtime_paths(config, runtime_paths)
    model = MagicMock()
    model.id = "gpt-4o-mini"
    created_culture_manager = MagicMock(name="shared_private_culture_manager")
    mock_culture_manager_class.return_value = created_culture_manager

    with patch("mindroom.model_loading.get_model_instance", return_value=model):
        _create_agent_for_test(
            "agent_one",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=ToolExecutionIdentity(
                channel="matrix",
                agent_name="agent_one",
                requester_id="@alice:example.org",
                room_id="!room:example.org",
                thread_id=None,
                resolved_thread_id=None,
                session_id=None,
            ),
        )
        _create_agent_for_test(
            "agent_two",
            config=bound_config,
            include_interactive_questions=False,
            execution_identity=ToolExecutionIdentity(
                channel="matrix",
                agent_name="agent_two",
                requester_id="@alice:example.org",
                room_id="!room:example.org",
                thread_id=None,
                resolved_thread_id=None,
                session_id=None,
            ),
        )

    assert mock_culture_manager_class.call_count == 1
    culture_db_calls = [
        str(call.kwargs.get("db_file", ""))
        for call in mock_storage.call_args_list
        if str(call.kwargs.get("db_file", "")).endswith("/culture/engineering.db")
    ]
    assert len(culture_db_calls) == 1
    assert "/private_instances/" in culture_db_calls[0]
    assert "/agent_one/" not in culture_db_calls[0]
    assert "/agent_two/" not in culture_db_calls[0]
    first_kwargs = mock_agent_class.call_args_list[0].kwargs
    second_kwargs = mock_agent_class.call_args_list[1].kwargs
    assert first_kwargs["culture_manager"] is created_culture_manager
    assert second_kwargs["culture_manager"] is created_culture_manager


def test_team_member_matches_solo_agent_construction() -> None:
    """A team-materialized member must equal the same agent built solo, modulo explicit deltas."""
    from tests.conftest import runtime_paths_for  # noqa: PLC0415

    config = _test_config()
    runtime_paths = runtime_paths_for(config)

    solo = create_agent("calculator", config, runtime_paths, execution_identity=None)
    member = materialize_exact_team_members(
        ["calculator"],
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=None,
    ).agents[0]
    try:
        assert member.role == solo.role
        assert member.name == solo.name
        assert member.id == solo.id
        assert type(member.model) is type(solo.model)
        assert member.model is not None
        assert solo.model is not None
        assert member.model.id == solo.model.id
        assert [tool.name for tool in member.tools or []] == [tool.name for tool in solo.tools or []]
        assert member.markdown == solo.markdown
        assert type(member.learning) is type(solo.learning)
        assert member.add_history_to_context == solo.add_history_to_context
        assert member.num_history_runs == solo.num_history_runs
        assert member.num_history_messages == solo.num_history_messages
        assert member.max_tool_calls_from_history == solo.max_tool_calls_from_history
        assert member.knowledge == solo.knowledge
        assert member.culture_manager == solo.culture_manager
        assert member.add_culture_to_context == solo.add_culture_to_context
        assert member.update_cultural_knowledge == solo.update_cultural_knowledge
        assert member.enable_agentic_culture == solo.enable_agentic_culture
        assert member.compress_tool_results == solo.compress_tool_results

        # The only authored construction delta: members never get the Matrix
        # reaction-based interactive question prompt.
        interactive_prompt = config.get_prompt("INTERACTIVE_QUESTION_PROMPT")
        assert interactive_prompt in solo.instructions
        assert list(member.instructions or []) == [
            instruction for instruction in solo.instructions or [] if instruction != interactive_prompt
        ]
    finally:
        close_team_runtime_state_dbs(agents=[solo, member], team_db=None)
