use std::sync::Arc;

use async_trait::async_trait;

use crate::agents::{
    McpAccessPolicy, agent_spec, agent_specs, agent_specs_prompt, default_agent_spec_id,
};
use crate::app::domain::event::SessionEvent;
use crate::app::domain::runtime::RuntimeService;
use crate::app::domain::types::SessionId;
use crate::app::validation::ValidatorRegistry;
use crate::config::model::builtin::claude_sonnet_4_5 as default_model;
use crate::runners::OneShotRunner;
use crate::session::state::BackendConfig;
use crate::tools::builtin_tool::{
    BuiltinTool, BuiltinToolContext, BuiltinToolError, schema_with_description,
};
use crate::tools::capability::Capabilities;
use crate::tools::services::{SubAgentConfig, SubAgentError, ToolServices};
use crate::tools::{BackendRegistry, ToolExecutor, ToolRegistry};
use crate::workspace::{
    CreateWorkspaceRequest, EnvironmentId, RepoRef, VcsKind, VcsStatus, Workspace,
    WorkspaceCreateStrategy, WorkspaceRef, create_workspace_from_session_config,
};
use steer_tools::result::{AgentResult, AgentWorkspaceInfo, AgentWorkspaceRevision};
use steer_tools::tools::dispatch_agent::{
    DispatchAgentError, DispatchAgentParams, DispatchAgentTarget, DispatchAgentToolSpec,
    WorkspaceTarget,
};
use steer_tools::tools::{GREP_TOOL_NAME, LS_TOOL_NAME, VIEW_TOOL_NAME};
use tracing::warn;

use super::{
    register_builtin_tools_for_visibility, workspace_manager_op_error, workspace_op_error,
};

fn dispatch_agent_description() -> String {
    let agent_specs = agent_specs_prompt();
    let agent_specs_block = if agent_specs.is_empty() {
        "No agent specs registered.".to_string()
    } else {
        agent_specs
    };

    format!(
        r#"Launch a new agent to help with a focused task. Delegate work to sub-agents when you want to keep your own context window focused, or when tasks can run in parallel.

When to use this tool:
- If you need to edit files for a focused task (a feature, bug fix, or refactor), dispatch a sub-agent with the task and all relevant context so your own context stays clean
- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", dispatch a sub-agent to search
- If a task can be split into independent subtasks, dispatch multiple sub-agents concurrently

When NOT to use this tool:
- If you want to read a specific file path, use the {} or {} tool instead, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the {} tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the {} tool instead, to find the match more quickly
- Don't dispatch a sub-agent for a one-line fix you can make directly

How to write an effective sub-agent prompt:
1. Start with the goal and expected output format
2. Include concrete context you've already gathered (file paths, symbol names, error messages, constraints, and acceptance criteria) so the sub-agent does not need to re-gather it
3. Name exactly which files or directories to inspect first when known
4. State whether the sub-agent should only explore or is expected to edit/build/test
5. Do NOT include synthetic path headers like `Repo: <path>` or `CWD: <path>`; working-directory context is injected automatically

Example of a strong sub-agent prompt:
  "The login endpoint at `src/api/auth.rs:142` returns 401 for valid tokens because `validate_token` checks expiry with `>` instead of `>=`. Change the comparison to `>=` and verify the existing test in `tests/auth_test.rs` still passes."

Compare with a weak prompt that forces the sub-agent to rediscover context:
  "Fix the bug in auth"

Usage:
1. Launch multiple agents concurrently whenever possible; use a single message with multiple tool uses
2. The result returned by the agent is not visible to the user. Summarize it for the user in a text message.
3. Use `location: "new"` when dispatching multiple sub-agents that may edit overlapping files, to avoid conflicts.
4. IMPORTANT: Only some agent specs include write tools. Use a build agent if the task requires editing files.

Reference:
- Each invocation returns a session_id. Pass it back via `target: {{ "session": "resume", "session_id": "<uuid>" }}` to continue the conversation with the same agent.
- When `target.session` is `resume`, the session_id must refer to a child of the current session. The `agent` and `workspace` options are ignored and the existing session config is used.
- The agent's outputs should generally be trusted.
- New workspaces are preserved (not auto-deleted). Clean them up manually if needed.
- If the agent spec omits a model, the parent session's default model is used.
- If `target.session` is `new` and `workspace.location` is `new`, the sub-agent runs in the newly created workspace path, which may differ from the caller's current directory.

Workspace options:
- `workspace: {{ "location": "current" }}` to run in the current workspace
- `workspace: {{ "location": "new", "name": "..." }}` to run in a fresh workspace (jj workspace or git worktree)
- `location` is a logical workspace selector, not a filesystem path

Session options:
- `target: {{ "session": "resume", "session_id": "<uuid>" }}` to continue a prior dispatch_agent session

New session options:
- `target: {{ "session": "new", "workspace": {{ "location": "current" }} }}` to run in the current workspace
- `target: {{ "session": "new", "workspace": {{ "location": "new", "name": "..." }} }}` to run in a new workspace
- `target: {{ "session": "new", "workspace": {{ "location": "current" }}, "agent": "<id>" }}` selects an agent spec (defaults to "{default_agent}")

{agent_specs_block}"#,
        VIEW_TOOL_NAME,
        LS_TOOL_NAME,
        GREP_TOOL_NAME,
        GREP_TOOL_NAME,
        default_agent = default_agent_spec_id(),
        agent_specs_block = agent_specs_block
    )
}

pub struct DispatchAgentTool;

#[async_trait]
impl BuiltinTool for DispatchAgentTool {
    type Params = DispatchAgentParams;
    type Output = AgentResult;
    type Spec = DispatchAgentToolSpec;

    const DESCRIPTION: &'static str = "Launch a sub-agent with full context for focused search, implementation, or parallel subtasks";
    const REQUIRES_APPROVAL: bool = false;
    const REQUIRED_CAPABILITIES: Capabilities = Capabilities::AGENT;

    fn schema() -> steer_tools::ToolSchema {
        schema_with_description::<Self::Params, Self::Spec>(dispatch_agent_description())
    }

    async fn execute(
        &self,
        params: Self::Params,
        ctx: &BuiltinToolContext,
    ) -> Result<Self::Output, BuiltinToolError<DispatchAgentError>> {
        let DispatchAgentParams { prompt, target } = params;

        let (workspace_target, agent) = match target {
            DispatchAgentTarget::Resume { session_id } => {
                let session_id = SessionId::parse(&session_id).ok_or_else(|| {
                    BuiltinToolError::invalid_params(format!("Invalid session_id '{session_id}'"))
                })?;
                return resume_agent_session(session_id, prompt, ctx).await;
            }
            DispatchAgentTarget::New { workspace, agent } => (workspace, agent),
        };

        let spawner = ctx
            .services
            .agent_spawner()
            .ok_or_else(|| BuiltinToolError::missing_capability("agent_spawner"))?;

        let base_workspace = ctx.services.workspace.clone();
        let base_path = base_workspace.working_directory().to_path_buf();

        let mut workspace = base_workspace.clone();
        let mut workspace_ref = None;
        let mut workspace_id = None;
        let mut workspace_name = None;
        let mut repo_id = None;
        let mut repo_ref = None;

        if let Some(manager) = ctx.services.workspace_manager()
            && let Ok(info) = manager.resolve_workspace(&base_path).await
        {
            workspace_id = Some(info.workspace_id);
            workspace_name.clone_from(&info.name);
            repo_id = Some(info.repo_id);
            workspace_ref = Some(WorkspaceRef {
                environment_id: info.environment_id,
                workspace_id: info.workspace_id,
                repo_id: info.repo_id,
            });
        }

        if let Some(manager) = ctx.services.repo_manager() {
            let repo_env_id = workspace_ref
                .as_ref()
                .map_or_else(EnvironmentId::local, |reference| reference.environment_id);
            if let Ok(info) = manager.resolve_repo(repo_env_id, &base_path).await {
                if repo_id.is_none() {
                    repo_id = Some(info.repo_id);
                }
                repo_ref = Some(RepoRef {
                    environment_id: info.environment_id,
                    repo_id: info.repo_id,
                    root_path: info.root_path,
                    vcs_kind: info.vcs_kind,
                });
            }
        }

        let mut new_workspace = false;
        let mut requested_workspace_name = None;

        match &workspace_target {
            WorkspaceTarget::Current => {}
            WorkspaceTarget::New { name } => {
                new_workspace = true;
                requested_workspace_name = Some(name.clone());
            }
        }

        let mut created_workspace_id = None;
        let mut status_manager = None;

        if new_workspace {
            let manager = ctx
                .services
                .workspace_manager()
                .ok_or_else(|| BuiltinToolError::missing_capability("workspace_manager"))?;
            status_manager = Some(manager.clone());

            let base_repo_id = repo_id.ok_or_else(|| {
                BuiltinToolError::execution(DispatchAgentError::WorkspaceUnavailable {
                    message:
                        "Current path is not a supported workspace; cannot create new workspace"
                            .to_string(),
                })
            })?;

            let strategy = match repo_ref
                .as_ref()
                .and_then(|reference| reference.vcs_kind.as_ref())
            {
                Some(VcsKind::Git) => WorkspaceCreateStrategy::GitWorktree,
                _ => WorkspaceCreateStrategy::JjWorkspace,
            };

            let create_request = CreateWorkspaceRequest {
                repo_id: base_repo_id,
                name: requested_workspace_name.clone(),
                parent_workspace_id: workspace_id,
                strategy,
            };

            let info = manager
                .create_workspace(create_request)
                .await
                .map_err(|e| {
                    BuiltinToolError::execution(DispatchAgentError::Workspace(
                        workspace_manager_op_error(e),
                    ))
                })?;

            workspace = manager
                .open_workspace(info.workspace_id)
                .await
                .map_err(|e| {
                    BuiltinToolError::execution(DispatchAgentError::Workspace(
                        workspace_manager_op_error(e),
                    ))
                })?;

            workspace_id = Some(info.workspace_id);
            created_workspace_id = Some(info.workspace_id);
            workspace_name.clone_from(&info.name);
            workspace_ref = Some(WorkspaceRef {
                environment_id: info.environment_id,
                workspace_id: info.workspace_id,
                repo_id: info.repo_id,
            });

            if let Some(repo_manager) = ctx.services.repo_manager()
                && let Ok(info) = repo_manager
                    .resolve_repo(info.environment_id, workspace.working_directory())
                    .await
            {
                repo_ref = Some(RepoRef {
                    environment_id: info.environment_id,
                    repo_id: info.repo_id,
                    root_path: info.root_path,
                    vcs_kind: info.vcs_kind,
                });
            }
        }

        let env_info = workspace.environment().await.map_err(|e| {
            BuiltinToolError::execution(DispatchAgentError::Workspace(workspace_op_error(e)))
        })?;

        let system_prompt = format!(
            r#"You are an agent for a CLI-based coding tool. Given the user's prompt, you should use the tools available to you to answer the user's question.

Notes:
1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
2. When relevant, share file names and code snippets relevant to the query
3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.

{}
"#,
            env_info.as_context()
        );

        let agent_id = agent
            .as_deref()
            .filter(|value| !value.trim().is_empty())
            .map_or_else(|| default_agent_spec_id().to_string(), str::to_string);

        let agent_spec = agent_spec(&agent_id).ok_or_else(|| {
            let available = agent_specs()
                .into_iter()
                .map(|spec| spec.id)
                .collect::<Vec<_>>()
                .join(", ");
            BuiltinToolError::invalid_params(format!(
                "Unknown agent spec '{agent_id}'. Available: {available}"
            ))
        })?;

        let parent_session_config = match ctx.services.event_store.load_events(ctx.session_id).await
        {
            Ok(events) => events.into_iter().find_map(|(_, event)| match event {
                SessionEvent::SessionCreated { config, .. } => Some(*config),
                _ => None,
            }),
            Err(err) => {
                warn!(
                    session_id = %ctx.session_id,
                    "Failed to load parent session config for MCP servers: {err}"
                );
                None
            }
        };

        let parent_mcp_backends = parent_session_config
            .as_ref()
            .map(|config| config.tool_config.backends.clone())
            .unwrap_or_default();

        let parent_model = parent_session_config
            .as_ref()
            .map_or_else(default_model, |config| config.default_model.clone());

        let allow_mcp_tools = agent_spec.mcp_access.allow_mcp_tools();
        let mcp_backends = match &agent_spec.mcp_access {
            McpAccessPolicy::None => Vec::new(),
            McpAccessPolicy::All => parent_mcp_backends,
            McpAccessPolicy::Allowlist(servers) => parent_mcp_backends
                .into_iter()
                .filter(|backend| match backend {
                    BackendConfig::Mcp { server_name, .. } => {
                        servers.iter().any(|allowed| allowed == server_name)
                    }
                })
                .collect(),
        };

        let config = SubAgentConfig {
            parent_session_id: ctx.session_id,
            prompt,
            allowed_tools: agent_spec.tools.clone(),
            model: agent_spec.model.clone().unwrap_or(parent_model),
            system_context: Some(crate::app::SystemContext::new(system_prompt)),
            workspace: Some(workspace),
            workspace_ref,
            workspace_id,
            repo_ref,
            workspace_name,
            mcp_backends,
            allow_mcp_tools,
        };

        let spawn_result = spawner.spawn(config, ctx.cancellation_token.clone()).await;

        let mut workspace_info = None;

        if let (Some(manager), Some(workspace_id)) = (status_manager, created_workspace_id) {
            let revision = match manager.get_workspace_status(workspace_id).await {
                Ok(status) => match status.vcs {
                    Some(vcs) => match vcs.status {
                        VcsStatus::Jj(jj_status) => {
                            jj_status.working_copy.map(|wc| AgentWorkspaceRevision {
                                vcs_kind: "jj".to_string(),
                                revision_id: wc.commit_id,
                                summary: wc.description,
                                change_id: Some(wc.change_id),
                            })
                        }
                        VcsStatus::Git(_) => None,
                    },
                    None => None,
                },
                Err(err) => {
                    warn!(
                        workspace_id = %workspace_id.as_uuid(),
                        "Failed to get workspace status for sub-agent: {err}"
                    );
                    None
                }
            };

            workspace_info = Some(AgentWorkspaceInfo {
                workspace_id: Some(workspace_id.as_uuid().to_string()),
                revision,
            });
        }

        let result = spawn_result.map_err(|e| match e {
            SubAgentError::Cancelled => BuiltinToolError::Cancelled,
            other => BuiltinToolError::execution(DispatchAgentError::SpawnFailed {
                message: other.to_string(),
            }),
        })?;

        Ok(AgentResult {
            content: result.final_message.extract_text(),
            session_id: Some(result.session_id.to_string()),
            workspace: workspace_info,
        })
    }
}

fn build_runtime_tool_executor(
    workspace: Arc<dyn Workspace>,
    visibility: &crate::session::state::ToolVisibility,
    parent_services: &Arc<ToolServices>,
) -> Arc<ToolExecutor> {
    let mut services = ToolServices::new(
        workspace.clone(),
        parent_services.event_store.clone(),
        parent_services.api_client.clone(),
    );

    if let Some(spawner) = parent_services.agent_spawner() {
        services = services.with_agent_spawner(spawner.clone());
    }
    if let Some(caller) = parent_services.model_caller() {
        services = services.with_model_caller(caller.clone());
    }
    if let Some(manager) = parent_services.workspace_manager() {
        services = services.with_workspace_manager(manager.clone());
    }
    if let Some(manager) = parent_services.repo_manager() {
        services = services.with_repo_manager(manager.clone());
    }
    if parent_services
        .capabilities()
        .contains(Capabilities::NETWORK)
    {
        services = services.with_network();
    }

    let mut registry = ToolRegistry::new();
    register_builtin_tools_for_visibility(&mut registry, visibility);

    Arc::new(
        ToolExecutor::with_components(
            Arc::new(BackendRegistry::new()),
            Arc::new(ValidatorRegistry::new()),
        )
        .with_builtin_tools(Arc::new(registry), Arc::new(services)),
    )
}

async fn resume_agent_session(
    session_id: SessionId,
    prompt: String,
    ctx: &BuiltinToolContext,
) -> Result<AgentResult, BuiltinToolError<DispatchAgentError>> {
    let events = ctx
        .services
        .event_store
        .load_events(session_id)
        .await
        .map_err(|e| {
            BuiltinToolError::execution(DispatchAgentError::SessionLoadFailed {
                session_id: session_id.to_string(),
                message: e.to_string(),
            })
        })?;

    let session_config = events
        .into_iter()
        .find_map(|(_, event)| match event {
            SessionEvent::SessionCreated { config, .. } => Some(*config),
            _ => None,
        })
        .ok_or_else(|| {
            BuiltinToolError::execution(DispatchAgentError::MissingSessionCreatedEvent {
                session_id: session_id.to_string(),
            })
        })?;

    if session_config.parent_session_id != Some(ctx.session_id) {
        return Err(BuiltinToolError::execution(
            DispatchAgentError::InvalidParentSession {
                session_id: session_id.to_string(),
                parent_session_id: ctx.session_id.to_string(),
            },
        ));
    }

    let workspace = create_workspace_from_session_config(&session_config.workspace)
        .await
        .map_err(|e| {
            BuiltinToolError::execution(DispatchAgentError::WorkspaceOpenFailed {
                session_id: session_id.to_string(),
                message: e.to_string(),
            })
        })?;

    let tool_executor = build_runtime_tool_executor(
        workspace,
        &session_config.tool_config.visibility,
        &ctx.services,
    );
    let runtime = RuntimeService::spawn(
        ctx.services.event_store.clone(),
        ctx.services.api_client.clone(),
        tool_executor,
    );

    let run_result = OneShotRunner::run_in_session_with_cancel(
        &runtime.handle,
        session_id,
        prompt,
        session_config.default_model.clone(),
        ctx.cancellation_token.clone(),
    )
    .await;

    runtime.shutdown().await;

    let run_result = run_result.map_err(|e| match e {
        crate::error::Error::Cancelled => BuiltinToolError::Cancelled,
        other => BuiltinToolError::execution(DispatchAgentError::SpawnFailed {
            message: other.to_string(),
        }),
    })?;

    Ok(AgentResult {
        content: run_result.final_message.extract_text(),
        session_id: Some(run_result.session_id.to_string()),
        workspace: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agents::{AgentSpec, AgentSpecError, McpAccessPolicy, register_agent_spec};
    use crate::api::Client as ApiClient;
    use crate::api::{ApiError, CompletionResponse, Provider};
    use crate::app::conversation::{AssistantContent, Message, MessageData};
    use crate::app::domain::session::{EventStore, EventStoreError, InMemoryEventStore};
    use crate::app::domain::types::ToolCallId;
    use crate::config::model::builtin;
    use crate::model_registry::ModelRegistry;
    use crate::session::state::{
        ApprovalRulesOverrides, SessionConfig, SessionPolicyOverrides, ToolApprovalPolicyOverrides,
        ToolFilter, ToolVisibility,
    };
    use crate::tools::McpTransport;
    use crate::tools::builtin_tools::ALL_BUILTIN_TOOL_NAMES;
    use crate::tools::services::{AgentSpawner, SubAgentError, SubAgentResult, ToolServices};
    use async_trait::async_trait;
    use std::collections::{HashMap, HashSet};
    use std::sync::Mutex as StdMutex;
    use tokio::time::{Duration, sleep};
    use tokio_util::sync::CancellationToken;
    use uuid::Uuid;

    #[derive(Default)]
    struct FailingLoadEventStore;

    #[async_trait]
    impl EventStore for FailingLoadEventStore {
        async fn append(
            &self,
            _session_id: SessionId,
            _event: &SessionEvent,
        ) -> Result<u64, EventStoreError> {
            Ok(0)
        }

        async fn load_events(
            &self,
            _session_id: SessionId,
        ) -> Result<Vec<(u64, SessionEvent)>, EventStoreError> {
            Err(EventStoreError::Database {
                message: "boom".to_string(),
            })
        }

        async fn load_events_after(
            &self,
            _session_id: SessionId,
            _after_seq: u64,
        ) -> Result<Vec<(u64, SessionEvent)>, EventStoreError> {
            Ok(Vec::new())
        }

        async fn latest_sequence(
            &self,
            _session_id: SessionId,
        ) -> Result<Option<u64>, EventStoreError> {
            Ok(None)
        }

        async fn session_exists(&self, _session_id: SessionId) -> Result<bool, EventStoreError> {
            Ok(false)
        }

        async fn create_session(&self, _session_id: SessionId) -> Result<(), EventStoreError> {
            Ok(())
        }

        async fn delete_session(&self, _session_id: SessionId) -> Result<(), EventStoreError> {
            Ok(())
        }

        async fn list_session_ids(&self) -> Result<Vec<SessionId>, EventStoreError> {
            Ok(Vec::new())
        }

        async fn load_todos(
            &self,
            _session_id: SessionId,
        ) -> Result<Option<Vec<steer_tools::tools::todo::TodoItem>>, EventStoreError> {
            Ok(None)
        }

        async fn save_todos(
            &self,
            _session_id: SessionId,
            _todos: &[steer_tools::tools::todo::TodoItem],
        ) -> Result<(), EventStoreError> {
            Ok(())
        }
    }

    #[derive(Clone)]
    struct StubProvider {
        response: String,
    }

    impl StubProvider {
        fn new(response: impl Into<String>) -> Self {
            Self {
                response: response.into(),
            }
        }
    }

    #[derive(Clone)]
    struct CancelAwareProvider;

    #[async_trait]
    impl Provider for CancelAwareProvider {
        fn name(&self) -> &'static str {
            "cancel-aware"
        }

        async fn complete(
            &self,
            _model_id: &crate::config::model::ModelId,
            _messages: Vec<Message>,
            _system: Option<crate::app::SystemContext>,
            _tools: Option<Vec<steer_tools::ToolSchema>>,
            _call_options: Option<crate::config::model::ModelParameters>,
            token: CancellationToken,
        ) -> Result<CompletionResponse, ApiError> {
            token.cancelled().await;
            Err(ApiError::Cancelled {
                provider: self.name().to_string(),
            })
        }
    }

    #[async_trait]
    impl Provider for StubProvider {
        fn name(&self) -> &'static str {
            "stub"
        }

        async fn complete(
            &self,
            _model_id: &crate::config::model::ModelId,
            _messages: Vec<Message>,
            _system: Option<crate::app::SystemContext>,
            _tools: Option<Vec<steer_tools::ToolSchema>>,
            _call_options: Option<crate::config::model::ModelParameters>,
            _token: CancellationToken,
        ) -> Result<CompletionResponse, ApiError> {
            Ok(CompletionResponse {
                content: vec![AssistantContent::Text {
                    text: self.response.clone(),
                }],
                usage: None,
            })
        }
    }

    #[derive(Clone)]
    struct StubAgentSpawner {
        session_id: SessionId,
        response: String,
    }

    #[async_trait]
    impl AgentSpawner for StubAgentSpawner {
        async fn spawn(
            &self,
            _config: crate::tools::services::SubAgentConfig,
            _cancel_token: CancellationToken,
        ) -> Result<SubAgentResult, SubAgentError> {
            let timestamp = Message::current_timestamp();
            let message = Message {
                timestamp,
                id: Message::generate_id("assistant", timestamp),
                parent_message_id: None,
                data: MessageData::Assistant {
                    content: vec![AssistantContent::Text {
                        text: self.response.clone(),
                    }],
                },
            };

            Ok(SubAgentResult {
                session_id: self.session_id,
                final_message: message,
            })
        }
    }

    #[derive(Clone)]
    struct CapturingAgentSpawner {
        session_id: SessionId,
        response: String,
        captured: Arc<tokio::sync::Mutex<Option<crate::tools::services::SubAgentConfig>>>,
    }

    #[async_trait]
    impl AgentSpawner for CapturingAgentSpawner {
        async fn spawn(
            &self,
            config: crate::tools::services::SubAgentConfig,
            _cancel_token: CancellationToken,
        ) -> Result<SubAgentResult, SubAgentError> {
            let mut guard = self.captured.lock().await;
            *guard = Some(config);

            let timestamp = Message::current_timestamp();
            let message = Message {
                timestamp,
                id: Message::generate_id("assistant", timestamp),
                parent_message_id: None,
                data: MessageData::Assistant {
                    content: vec![AssistantContent::Text {
                        text: self.response.clone(),
                    }],
                },
            };

            Ok(SubAgentResult {
                session_id: self.session_id,
                final_message: message,
            })
        }
    }

    #[derive(Clone)]
    struct ToolCallThenTextProvider {
        tool_call: steer_tools::ToolCall,
        final_text: String,
        call_count: Arc<StdMutex<usize>>,
    }

    impl ToolCallThenTextProvider {
        fn new(tool_call: steer_tools::ToolCall, final_text: impl Into<String>) -> Self {
            Self {
                tool_call,
                final_text: final_text.into(),
                call_count: Arc::new(StdMutex::new(0)),
            }
        }
    }

    #[async_trait]
    impl Provider for ToolCallThenTextProvider {
        fn name(&self) -> &'static str {
            "stub-tool-call"
        }

        async fn complete(
            &self,
            _model_id: &crate::config::model::ModelId,
            _messages: Vec<Message>,
            _system: Option<crate::app::SystemContext>,
            _tools: Option<Vec<steer_tools::ToolSchema>>,
            _call_options: Option<crate::config::model::ModelParameters>,
            _token: CancellationToken,
        ) -> Result<CompletionResponse, ApiError> {
            let mut count = self
                .call_count
                .lock()
                .expect("tool call counter lock poisoned");
            let response = if *count == 0 {
                CompletionResponse {
                    content: vec![AssistantContent::ToolCall {
                        tool_call: self.tool_call.clone(),
                        thought_signature: None,
                    }],
                    usage: None,
                }
            } else {
                CompletionResponse {
                    content: vec![AssistantContent::Text {
                        text: self.final_text.clone(),
                    }],
                    usage: None,
                }
            };
            *count += 1;
            Ok(response)
        }
    }

    #[tokio::test]
    async fn runtime_tool_executor_registers_all_builtin_tools() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(
            workspace.clone(),
            event_store,
            api_client,
        ));

        let executor = build_runtime_tool_executor(
            workspace,
            &crate::session::state::ToolVisibility::All,
            &services,
        );
        let mut supported = executor.supported_tools().await;
        supported.sort_unstable();

        let mut expected = ALL_BUILTIN_TOOL_NAMES
            .iter()
            .map(|name| (*name).to_string())
            .collect::<Vec<_>>();
        expected.sort_unstable();

        assert_eq!(supported, expected);
    }

    #[tokio::test]
    async fn runtime_tool_executor_honors_whitelist_visibility() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(
            workspace.clone(),
            event_store,
            api_client,
        ));

        let visibility = ToolVisibility::Whitelist(HashSet::from([
            steer_tools::tools::VIEW_TOOL_NAME.to_string(),
            steer_tools::tools::TODO_READ_TOOL_NAME.to_string(),
        ]));

        let executor = build_runtime_tool_executor(workspace, &visibility, &services);
        let mut supported = executor.supported_tools().await;
        supported.sort_unstable();

        let mut expected = vec![
            steer_tools::tools::TODO_READ_TOOL_NAME.to_string(),
            steer_tools::tools::VIEW_TOOL_NAME.to_string(),
        ];
        expected.sort_unstable();

        assert_eq!(supported, expected);
    }

    #[tokio::test]
    async fn resume_session_rejects_non_child() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let session_id = SessionId::new();
        let mut session_config = SessionConfig::read_only(builtin::claude_sonnet_4_5());
        session_config.parent_session_id = Some(parent_session_id);

        event_store.create_session(session_id).await.unwrap();
        event_store
            .append(
                session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: std::collections::HashMap::new(),
                    parent_session_id: Some(parent_session_id),
                },
            )
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: SessionId::new(),
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;

        assert!(matches!(
            result,
            Err(BuiltinToolError::Execution(
                DispatchAgentError::InvalidParentSession {
                    session_id: actual,
                    parent_session_id
                }
            )) if actual == session_id.to_string() && parent_session_id == ctx.session_id.to_string()
        ));
    }

    #[tokio::test]
    async fn resume_session_returns_missing_session_created_event_error() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let session_id = SessionId::new();
        event_store.create_session(session_id).await.unwrap();

        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: SessionId::new(),
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;

        assert!(matches!(
            result,
            Err(BuiltinToolError::Execution(
                DispatchAgentError::MissingSessionCreatedEvent { session_id: actual }
            )) if actual == session_id.to_string()
        ));
    }

    #[tokio::test]
    async fn resume_session_returns_session_load_failed_on_event_store_error() {
        let event_store = Arc::new(FailingLoadEventStore);
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let session_id = SessionId::new();
        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: SessionId::new(),
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;

        assert!(matches!(
            result,
            Err(BuiltinToolError::Execution(
                DispatchAgentError::SessionLoadFailed {
                    session_id: actual,
                    ..
                }
            )) if actual == session_id.to_string()
        ));
    }

    #[tokio::test]
    async fn resume_session_returns_workspace_open_failed_for_invalid_remote_workspace() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let session_id = SessionId::new();
        let mut session_config = SessionConfig::read_only(builtin::claude_sonnet_4_5());
        session_config.parent_session_id = Some(parent_session_id);
        session_config.workspace = crate::session::state::WorkspaceConfig::Remote {
            agent_address: "invalid-address".to_string(),
            auth: None,
        };

        event_store.create_session(session_id).await.unwrap();
        event_store
            .append(
                session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: HashMap::new(),
                    parent_session_id: Some(parent_session_id),
                },
            )
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;

        assert!(matches!(
            result,
            Err(BuiltinToolError::Execution(
                DispatchAgentError::WorkspaceOpenFailed {
                    session_id: actual,
                    ..
                }
            )) if actual == session_id.to_string()
        ));
    }

    #[tokio::test]
    async fn resume_session_accepts_child_and_returns_message() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let model = builtin::claude_sonnet_4_5();
        api_client.insert_test_provider(
            model.provider.clone(),
            Arc::new(StubProvider::new("stub-response")),
        );
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let session_id = SessionId::new();
        let mut session_config = SessionConfig::read_only(model.clone());
        session_config.parent_session_id = Some(parent_session_id);

        event_store.create_session(session_id).await.unwrap();
        event_store
            .append(
                session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: std::collections::HashMap::new(),
                    parent_session_id: Some(parent_session_id),
                },
            )
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx)
            .await
            .unwrap();

        assert!(result.content.contains("stub-response"));
        assert_eq!(
            result.session_id.as_deref(),
            Some(session_id.to_string().as_str())
        );
    }

    #[tokio::test]
    async fn resume_session_honors_cancellation() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let model = builtin::claude_sonnet_4_5();
        api_client.insert_test_provider(model.provider.clone(), Arc::new(CancelAwareProvider));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let session_id = SessionId::new();
        let mut session_config = SessionConfig::read_only(model);
        session_config.parent_session_id = Some(parent_session_id);

        event_store.create_session(session_id).await.unwrap();
        event_store
            .append(
                session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: std::collections::HashMap::new(),
                    parent_session_id: Some(parent_session_id),
                },
            )
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(workspace, event_store, api_client));

        let cancel_token = CancellationToken::new();
        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: cancel_token.clone(),
            services,
        };

        let cancel_task = tokio::spawn(async move {
            sleep(Duration::from_millis(10)).await;
            cancel_token.cancel();
        });

        let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;
        let _ = cancel_task.await;

        assert!(matches!(result, Err(BuiltinToolError::Cancelled)));
    }

    #[tokio::test]
    async fn dispatch_agent_returns_session_id() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let session_id = SessionId::new();
        let spawner = StubAgentSpawner {
            session_id,
            response: "done".to_string(),
        };

        let services = Arc::new(
            ToolServices::new(workspace, event_store, api_client)
                .with_agent_spawner(Arc::new(spawner)),
        );

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: SessionId::new(),
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let params = DispatchAgentParams {
            prompt: "hello".to_string(),
            target: DispatchAgentTarget::New {
                workspace: WorkspaceTarget::Current,
                agent: None,
            },
        };

        let result = DispatchAgentTool.execute(params, &ctx).await.unwrap();
        assert_eq!(
            result.session_id.as_deref(),
            Some(session_id.to_string().as_str())
        );
    }

    #[tokio::test]
    async fn dispatch_agent_filters_mcp_backends_by_allowlist() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let mut session_config = SessionConfig::read_only(builtin::claude_sonnet_4_5());
        session_config
            .tool_config
            .backends
            .push(BackendConfig::Mcp {
                server_name: "allowed-server".to_string(),
                transport: McpTransport::Tcp {
                    host: "127.0.0.1".to_string(),
                    port: 1111,
                },
                tool_filter: ToolFilter::All,
            });
        session_config
            .tool_config
            .backends
            .push(BackendConfig::Mcp {
                server_name: "blocked-server".to_string(),
                transport: McpTransport::Tcp {
                    host: "127.0.0.1".to_string(),
                    port: 2222,
                },
                tool_filter: ToolFilter::All,
            });

        event_store.create_session(parent_session_id).await.unwrap();
        event_store
            .append(
                parent_session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: HashMap::new(),
                    parent_session_id: None,
                },
            )
            .await
            .unwrap();

        let agent_id = format!("allowlist_{}", Uuid::new_v4());
        let spec = AgentSpec {
            id: agent_id.clone(),
            name: "allowlist test".to_string(),
            description: "allowlist test".to_string(),
            tools: vec![VIEW_TOOL_NAME.to_string()],
            mcp_access: McpAccessPolicy::Allowlist(vec!["allowed-server".to_string()]),
            model: None,
        };
        match register_agent_spec(spec) {
            Ok(()) => {}
            Err(AgentSpecError::AlreadyRegistered(_)) => {}
            Err(AgentSpecError::RegistryPoisoned) => {}
        }

        let captured = Arc::new(tokio::sync::Mutex::new(None));
        let spawner = CapturingAgentSpawner {
            session_id: SessionId::new(),
            response: "ok".to_string(),
            captured: captured.clone(),
        };

        let services = Arc::new(
            ToolServices::new(workspace, event_store, api_client)
                .with_agent_spawner(Arc::new(spawner)),
        );

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let params = DispatchAgentParams {
            prompt: "test".to_string(),
            target: DispatchAgentTarget::New {
                workspace: WorkspaceTarget::Current,
                agent: Some(agent_id),
            },
        };

        let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
        let captured = captured.lock().await.clone().expect("no config captured");

        let backend_names: Vec<String> = captured
            .mcp_backends
            .iter()
            .map(|backend| match backend {
                BackendConfig::Mcp { server_name, .. } => server_name.clone(),
            })
            .collect();

        assert_eq!(backend_names, vec!["allowed-server".to_string()]);
        assert!(captured.allow_mcp_tools);
    }

    #[tokio::test]
    async fn dispatch_agent_uses_parent_model_when_spec_missing_model() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let parent_model = builtin::claude_sonnet_4_5();
        let session_config = SessionConfig::read_only(parent_model.clone());

        event_store.create_session(parent_session_id).await.unwrap();
        event_store
            .append(
                parent_session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: HashMap::new(),
                    parent_session_id: None,
                },
            )
            .await
            .unwrap();

        let agent_id = format!("inherit_model_{}", Uuid::new_v4());
        let spec = AgentSpec {
            id: agent_id.clone(),
            name: "inherit model test".to_string(),
            description: "inherit model test".to_string(),
            tools: vec![VIEW_TOOL_NAME.to_string()],
            mcp_access: McpAccessPolicy::None,
            model: None,
        };
        match register_agent_spec(spec) {
            Ok(()) => {}
            Err(AgentSpecError::AlreadyRegistered(_)) => {}
            Err(AgentSpecError::RegistryPoisoned) => {}
        }

        let captured = Arc::new(tokio::sync::Mutex::new(None));
        let spawner = CapturingAgentSpawner {
            session_id: SessionId::new(),
            response: "ok".to_string(),
            captured: captured.clone(),
        };

        let services = Arc::new(
            ToolServices::new(workspace, event_store, api_client)
                .with_agent_spawner(Arc::new(spawner)),
        );

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let params = DispatchAgentParams {
            prompt: "test".to_string(),
            target: DispatchAgentTarget::New {
                workspace: WorkspaceTarget::Current,
                agent: Some(agent_id),
            },
        };

        let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
        let captured = captured.lock().await.clone().expect("no config captured");

        assert_eq!(captured.model, parent_model);
    }

    #[tokio::test]
    async fn dispatch_agent_uses_spec_model_when_set() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let parent_model = builtin::claude_sonnet_4_5();
        let session_config = SessionConfig::read_only(parent_model);

        event_store.create_session(parent_session_id).await.unwrap();
        event_store
            .append(
                parent_session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: HashMap::new(),
                    parent_session_id: None,
                },
            )
            .await
            .unwrap();

        let spec_model = builtin::claude_haiku_4_5();
        let agent_id = format!("spec_model_{}", Uuid::new_v4());
        let spec = AgentSpec {
            id: agent_id.clone(),
            name: "spec model test".to_string(),
            description: "spec model test".to_string(),
            tools: vec![VIEW_TOOL_NAME.to_string()],
            mcp_access: McpAccessPolicy::None,
            model: Some(spec_model.clone()),
        };
        match register_agent_spec(spec) {
            Ok(()) => {}
            Err(AgentSpecError::AlreadyRegistered(_)) => {}
            Err(AgentSpecError::RegistryPoisoned) => {}
        }

        let captured = Arc::new(tokio::sync::Mutex::new(None));
        let spawner = CapturingAgentSpawner {
            session_id: SessionId::new(),
            response: "ok".to_string(),
            captured: captured.clone(),
        };

        let services = Arc::new(
            ToolServices::new(workspace, event_store, api_client)
                .with_agent_spawner(Arc::new(spawner)),
        );

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let params = DispatchAgentParams {
            prompt: "test".to_string(),
            target: DispatchAgentTarget::New {
                workspace: WorkspaceTarget::Current,
                agent: Some(agent_id),
            },
        };

        let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
        let captured = captured.lock().await.clone().expect("no config captured");

        assert_eq!(captured.model, spec_model);
    }

    #[tokio::test]
    async fn resume_session_rejects_invisible_tools_as_unknown() {
        let event_store = Arc::new(InMemoryEventStore::new());
        let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
        let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
        let api_client = Arc::new(ApiClient::new_with_deps(
            crate::test_utils::test_llm_config_provider().unwrap(),
            provider_registry,
            model_registry,
        ));
        let workspace =
            crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
                path: std::env::current_dir().unwrap(),
            })
            .await
            .unwrap();

        let parent_session_id = SessionId::new();
        let session_id = SessionId::new();
        let model = builtin::claude_sonnet_4_5();

        let tool_call = steer_tools::ToolCall {
            name: "bash".to_string(),
            parameters: serde_json::json!({ "command": "echo denied" }),
            id: "tool_denied".to_string(),
        };
        api_client.insert_test_provider(
            model.provider.clone(),
            Arc::new(ToolCallThenTextProvider::new(tool_call, "done")),
        );

        let mut session_config = SessionConfig::read_only(model);
        session_config.parent_session_id = Some(parent_session_id);
        session_config.policy_overrides = SessionPolicyOverrides {
            default_model: None,
            tool_visibility: Some(ToolVisibility::Whitelist(HashSet::from([
                VIEW_TOOL_NAME.to_string()
            ]))),
            approval_policy: ToolApprovalPolicyOverrides {
                preapproved: ApprovalRulesOverrides {
                    tools: HashSet::from([VIEW_TOOL_NAME.to_string()]),
                    per_tool: HashMap::new(),
                },
            },
        };

        event_store.create_session(session_id).await.unwrap();
        event_store
            .append(
                session_id,
                &SessionEvent::SessionCreated {
                    config: Box::new(session_config),
                    metadata: HashMap::new(),
                    parent_session_id: Some(parent_session_id),
                },
            )
            .await
            .unwrap();

        let services = Arc::new(ToolServices::new(
            workspace,
            event_store.clone(),
            api_client,
        ));

        let ctx = BuiltinToolContext {
            tool_call_id: ToolCallId::new(),
            session_id: parent_session_id,
            invoking_model: None,
            cancellation_token: CancellationToken::new(),
            services,
        };

        let _ = resume_agent_session(session_id, "trigger".to_string(), &ctx)
            .await
            .unwrap();

        let events = event_store.load_events(session_id).await.unwrap();
        let unknown = events.iter().any(|(_, event)| match event {
            SessionEvent::ToolCallFailed { name, error, .. } => {
                name == "bash" && error.contains("Unknown tool")
            }
            _ => false,
        });

        assert!(
            unknown,
            "expected unknown-tool ToolCallFailed event for invisible bash"
        );
    }
}
