//! Integration tests for routine execution via Provider.

use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use anyhow::Result;
use uuid::Uuid;

use nenjo::manifest::{
    AgentManifest, CouncilDelegationStrategy, CouncilManifest, CouncilMemberManifest, Manifest,
    ModelManifest, ProjectManifest, PromptConfig, PromptTemplates, RoutineEdgeCondition,
    RoutineEdgeManifest, RoutineManifest, RoutineMetadata, RoutineStepManifest, RoutineStepType,
    model_manifest_slug,
};
use nenjo::provider::{ModelProviderFactory, NoopToolFactory, Provider};
use nenjo::routines::RoutineEvent;
use nenjo::routines::gate::PassVerdictTool;
use nenjo::{CronInput, ProjectLocation, RoutineRun, Slug, TaskInput};
use nenjo_models::traits::{
    ChatMessage, ChatRequest, ChatResponse, ModelProvider, TokenUsage, ToolCall,
};

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

struct MockLlm {
    response: String,
}

impl MockLlm {
    fn new(text: &str) -> Self {
        Self {
            response: text.to_string(),
        }
    }
}

#[async_trait::async_trait]
impl ModelProvider for MockLlm {
    async fn chat(
        &self,
        _request: ChatRequest<'_>,
        _model: &str,
        _temperature: f64,
    ) -> Result<ChatResponse> {
        Ok(ChatResponse {
            text: Some(self.response.clone()),
            tool_calls: vec![],
            usage: TokenUsage {
                input_tokens: 10,
                output_tokens: 5,
            },
        })
    }

    fn context_window(&self, _model: &str) -> Option<usize> {
        Some(128_000)
    }

    fn supports_native_tools(&self) -> bool {
        true
    }

    fn supports_developer_role(&self, _model: &str) -> bool {
        true
    }
}

struct MockFactory {
    response: String,
}

impl MockFactory {
    fn new(text: &str) -> Self {
        Self {
            response: text.to_string(),
        }
    }
}

impl ModelProviderFactory for MockFactory {
    fn create(&self, _provider_name: &str) -> Result<Arc<dyn ModelProvider>> {
        Ok(Arc::new(MockLlm::new(&self.response)))
    }
}

/// Mock that returns full ChatResponse objects in sequence, allowing tool call
/// responses. Shared call index so all instances advance the same counter.
struct SequentialResponseMockLlm {
    responses: Arc<Vec<ChatResponse>>,
    call_index: Arc<AtomicUsize>,
    seen_messages: Option<Arc<Mutex<Vec<Vec<ChatMessage>>>>>,
}

#[async_trait::async_trait]
impl ModelProvider for SequentialResponseMockLlm {
    async fn chat(
        &self,
        request: ChatRequest<'_>,
        _model: &str,
        _temperature: f64,
    ) -> Result<ChatResponse> {
        if let Some(seen_messages) = &self.seen_messages {
            seen_messages
                .lock()
                .unwrap()
                .push(request.messages.to_vec());
        }
        let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
        let resp = self
            .responses
            .get(idx)
            .unwrap_or(self.responses.last().unwrap())
            .clone();
        Ok(resp)
    }

    fn context_window(&self, _model: &str) -> Option<usize> {
        Some(128_000)
    }

    fn supports_native_tools(&self) -> bool {
        true
    }

    fn supports_developer_role(&self, _model: &str) -> bool {
        true
    }
}

struct SequentialResponseMockFactory {
    responses: Arc<Vec<ChatResponse>>,
    call_index: Arc<AtomicUsize>,
    seen_messages: Option<Arc<Mutex<Vec<Vec<ChatMessage>>>>>,
}

impl SequentialResponseMockFactory {
    fn new(responses: Vec<ChatResponse>) -> Self {
        Self {
            responses: Arc::new(responses),
            call_index: Arc::new(AtomicUsize::new(0)),
            seen_messages: None,
        }
    }

    fn with_message_recording(responses: Vec<ChatResponse>) -> Self {
        Self {
            responses: Arc::new(responses),
            call_index: Arc::new(AtomicUsize::new(0)),
            seen_messages: Some(Arc::new(Mutex::new(Vec::new()))),
        }
    }

    fn seen_messages(&self) -> Option<Arc<Mutex<Vec<Vec<ChatMessage>>>>> {
        self.seen_messages.clone()
    }
}

impl ModelProviderFactory for SequentialResponseMockFactory {
    fn create(&self, _provider_name: &str) -> Result<Arc<dyn ModelProvider>> {
        Ok(Arc::new(SequentialResponseMockLlm {
            responses: self.responses.clone(),
            call_index: self.call_index.clone(),
            seen_messages: self.seen_messages.clone(),
        }))
    }
}

struct RecordingToolsMockLlm {
    response: ChatResponse,
    seen_tools: Arc<Mutex<Vec<Vec<String>>>>,
}

#[async_trait::async_trait]
impl ModelProvider for RecordingToolsMockLlm {
    async fn chat(
        &self,
        request: ChatRequest<'_>,
        _model: &str,
        _temperature: f64,
    ) -> Result<ChatResponse> {
        self.seen_tools
            .lock()
            .unwrap()
            .push(request.tools.map(tool_names).unwrap_or_default());
        Ok(self.response.clone())
    }

    fn context_window(&self, _model: &str) -> Option<usize> {
        Some(128_000)
    }

    fn supports_native_tools(&self) -> bool {
        true
    }

    fn supports_developer_role(&self, _model: &str) -> bool {
        true
    }
}

struct RecordingToolsMockFactory {
    response: ChatResponse,
    seen_tools: Arc<Mutex<Vec<Vec<String>>>>,
}

impl RecordingToolsMockFactory {
    fn new(response: ChatResponse) -> Self {
        Self {
            response,
            seen_tools: Arc::new(Mutex::new(Vec::new())),
        }
    }

    fn seen_tools(&self) -> Arc<Mutex<Vec<Vec<String>>>> {
        self.seen_tools.clone()
    }
}

impl ModelProviderFactory for RecordingToolsMockFactory {
    fn create(&self, _provider_name: &str) -> Result<Arc<dyn ModelProvider>> {
        Ok(Arc::new(RecordingToolsMockLlm {
            response: self.response.clone(),
            seen_tools: self.seen_tools.clone(),
        }))
    }
}

struct RecordingMessagesMockLlm {
    response: ChatResponse,
    seen_messages: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
}

#[async_trait::async_trait]
impl ModelProvider for RecordingMessagesMockLlm {
    async fn chat(
        &self,
        request: ChatRequest<'_>,
        _model: &str,
        _temperature: f64,
    ) -> Result<ChatResponse> {
        self.seen_messages
            .lock()
            .unwrap()
            .push(request.messages.to_vec());
        Ok(self.response.clone())
    }

    fn context_window(&self, _model: &str) -> Option<usize> {
        Some(128_000)
    }

    fn supports_native_tools(&self) -> bool {
        true
    }

    fn supports_developer_role(&self, _model: &str) -> bool {
        true
    }
}

struct RecordingMessagesMockFactory {
    response: ChatResponse,
    seen_messages: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
}

impl RecordingMessagesMockFactory {
    fn new(response: ChatResponse) -> Self {
        Self {
            response,
            seen_messages: Arc::new(Mutex::new(Vec::new())),
        }
    }

    fn seen_messages(&self) -> Arc<Mutex<Vec<Vec<ChatMessage>>>> {
        self.seen_messages.clone()
    }
}

impl ModelProviderFactory for RecordingMessagesMockFactory {
    fn create(&self, _provider_name: &str) -> Result<Arc<dyn ModelProvider>> {
        Ok(Arc::new(RecordingMessagesMockLlm {
            response: self.response.clone(),
            seen_messages: self.seen_messages.clone(),
        }))
    }
}

fn tool_names(tools: &[nenjo::ToolSpec]) -> Vec<String> {
    tools.iter().map(|tool| tool.name.clone()).collect()
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn test_task(_project_id: Uuid, title: &str, desc: &str) -> TaskInput {
    TaskInput::new(title, desc)
        .with_project("project")
        .with_task_id(Uuid::nil())
        .source("test")
}

fn model(id: Uuid) -> ModelManifest {
    ModelManifest {
        id,
        name: "test-model".into(),
        description: None,
        model: "mock-v1".into(),
        model_provider: "mock".into(),
        temperature: Some(0.5),
        base_url: None,
    }
}

fn agent(id: Uuid, name: &str, _model_id: Uuid) -> AgentManifest {
    AgentManifest {
        id,
        name: name.into(),
        slug: None,
        description: Some(format!("{name} agent")),
        prompt_config: PromptConfig {
            system_prompt: format!("You are the {name} agent."),
            templates: PromptTemplates {
                task_execution: "Execute: {{ task.title }}\n{{ task.description }}".into(),
                chat_task: "{{ chat.message }}".into(),
                gate_eval:
                    "Evaluate:\n{{ routine.step.instructions }}\n\nPrevious output:\n{{ gate.previous_output }}"
                        .into(),
                heartbeat_task: String::new(),
            },
            ..Default::default()
        },
        color: None,
        model: Some(model_manifest_slug("mock", "mock-v1")),
        domains: vec![],
        platform_scopes: vec![],
        mcp_servers: vec![],
        script_tools: vec![],
        abilities: vec![],
        prompt_locked: false,
        heartbeat: None,
    }
}

fn messages_contain(messages: &[Vec<ChatMessage>], needle: &str) -> bool {
    messages
        .iter()
        .flatten()
        .any(|message| message.content.contains(needle))
}

fn project() -> ProjectManifest {
    ProjectManifest {
        id: Uuid::new_v4(),
        name: "test-project".into(),
        slug: Slug::derive("test-project"),
        description: Some("A test project".into()),
        settings: serde_json::Value::Null,
    }
}

fn verdict_response(text: &str, verdict: &str, reasoning: &str) -> ChatResponse {
    ChatResponse {
        text: Some(text.into()),
        tool_calls: vec![ToolCall {
            id: format!("call_{verdict}"),
            name: "pass_verdict".into(),
            arguments: format!(r#"{{"verdict":"{}","reasoning":"{}"}}"#, verdict, reasoning),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    }
}

// ===========================================================================
// Tests
// ===========================================================================

/// Single agent step → terminal. The simplest possible routine.
#[tokio::test]
async fn single_agent_step() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "simple-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("simple-routine"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![verdict_response(
            "Implementation complete.",
            "pass",
            "Implementation is complete",
        )]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Add auth", "Implement JWT authentication");
    let result = provider
        .routine("simple-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(result.passed);
    assert_eq!(result.output, "Implementation complete.");
    assert_eq!(result.input_tokens, 10);
    assert_eq!(result.output_tokens, 5);
}

#[tokio::test]
async fn routine_agent_request_includes_pass_verdict_tool() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();
    let project_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "tool-check-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("tool-check-routine"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![ProjectManifest {
            id: project_id,
            ..project()
        }],
        routines: vec![routine],
        ..Default::default()
    };
    let factory = RecordingToolsMockFactory::new(verdict_response(
        "Implementation complete.",
        "pass",
        "Implementation is complete",
    ));
    let seen_tools = factory.seen_tools();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let work_dir = tempfile::tempdir().unwrap();
    let task = RoutineRun::task(test_task(
        project_id,
        "Add auth",
        "Implement JWT authentication",
    ))
    .project_location(ProjectLocation::from_git(nenjo::types::GitContext {
        branch: "agent/test".into(),
        target_branch: "main".into(),
        work_dir: work_dir.path().to_string_lossy().to_string(),
        repo_url: "https://github.com/nenjo-ai/dashboard.git".into(),
    }));

    provider
        .routine("tool-check-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    let seen_tools = seen_tools.lock().unwrap();
    assert!(
        seen_tools
            .first()
            .is_some_and(|tools| tools.iter().any(|name| name == "pass_verdict")),
        "pass_verdict should be sent in the routine model request. Tool requests: {:?}",
        *seen_tools
    );
}

#[tokio::test]
async fn routine_agent_step_renders_step_instructions_context_var() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();
    let instructions = "Use the migration checklist before editing files.";

    let mut coder = agent(agent_id, "coder", model_id);
    coder.prompt_config.templates.task_execution =
        "Step instructions:\n{{ routine.step.instructions }}\n\nTask:\n{{ task.description }}"
            .into();

    let routine = RoutineManifest {
        id: routine_id,
        name: "agent-instructions".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("agent-instructions"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({
                "instructions": instructions,
            }),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![coder],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };
    let factory = RecordingMessagesMockFactory::new(verdict_response(
        "Implementation complete.",
        "pass",
        "Implementation is complete",
    ));
    let seen_messages = factory.seen_messages();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    provider
        .routine("agent-instructions")
        .unwrap()
        .run(test_task(
            Uuid::new_v4(),
            "Add auth",
            "Implement JWT authentication",
        ))
        .await
        .unwrap();

    let seen_messages = seen_messages.lock().unwrap();
    assert!(
        messages_contain(&seen_messages, instructions),
        "agent step instructions should render through routine.step.instructions. Messages: {seen_messages:#?}"
    );
}

#[tokio::test]
async fn cron_triggered_agent_step_uses_task_execution_template() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let mut coder = agent(agent_id, "coder", model_id);
    coder.prompt_config.templates.task_execution = "TASK TEMPLATE: {{ task.description }}".into();

    let routine = RoutineManifest {
        id: routine_id,
        name: "cron-agent-template".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Cron,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("cron-agent-template"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![coder],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };
    let factory = RecordingMessagesMockFactory::new(verdict_response(
        "Implementation complete.",
        "pass",
        "Implementation is complete",
    ));
    let seen_messages = factory.seen_messages();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let run = RoutineRun::cron(CronInput {
        task: Some(test_task(
            Uuid::new_v4(),
            "Add auth",
            "Implement JWT authentication",
        )),
        project: Some(Slug::derive("project")),
        schedule: nenjo::routines::types::CronSchedule::Interval(Duration::from_millis(50)),
        start_at: None,
        timeout: Duration::from_secs(5),
    });

    provider
        .routine("cron-agent-template")
        .unwrap()
        .run(run)
        .await
        .unwrap();

    let seen_messages = seen_messages.lock().unwrap();
    assert!(
        messages_contain(&seen_messages, "TASK TEMPLATE"),
        "cron-triggered agent steps should use task_execution. Messages: {seen_messages:#?}"
    );
}

#[tokio::test]
async fn cron_triggered_agent_step_without_project_uses_task_execution_template() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let mut coder = agent(agent_id, "coder", model_id);
    coder.prompt_config.templates.task_execution = "TASK TEMPLATE: {{ task.description }}".into();

    let routine = RoutineManifest {
        id: routine_id,
        name: "cron-agent-no-project".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Cron,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("cron-agent-no-project"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![coder],
        models: vec![model(model_id)],
        projects: vec![],
        routines: vec![routine],
        ..Default::default()
    };
    let factory = RecordingMessagesMockFactory::new(verdict_response(
        "Implementation complete.",
        "pass",
        "Implementation is complete",
    ));
    let seen_messages = factory.seen_messages();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let run = RoutineRun::cron(CronInput {
        task: None,
        project: None,
        schedule: nenjo::routines::types::CronSchedule::Interval(Duration::from_millis(50)),
        start_at: None,
        timeout: Duration::from_secs(5),
    });

    provider
        .routine("cron-agent-no-project")
        .unwrap()
        .run(run)
        .await
        .unwrap();

    let seen_messages = seen_messages.lock().unwrap();
    assert!(
        messages_contain(&seen_messages, "TASK TEMPLATE"),
        "cron-triggered agent steps without a project should use task_execution. Messages: {seen_messages:#?}"
    );
}

#[tokio::test]
async fn routine_gate_step_renders_step_instructions_context_var() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();
    let instructions = "Reject unless the output cites the acceptance criteria.";

    let mut reviewer = agent(agent_id, "reviewer", model_id);
    reviewer.prompt_config.templates.gate_eval =
        "Gate instructions:\n{{ routine.step.instructions }}\n\nPrevious output:\n{{ gate.previous_output }}"
            .into();

    let routine = RoutineManifest {
        id: routine_id,
        name: "gate-instructions".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("gate-instructions"),
            name: "review".into(),
            step_type: RoutineStepType::Gate,
            council: None,
            agent: Some(Slug::derive("reviewer")),
            config: serde_json::json!({
                "instructions": instructions,
            }),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![reviewer],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };
    let factory = RecordingMessagesMockFactory::new(verdict_response(
        "Gate passed.",
        "pass",
        "Criteria are satisfied",
    ));
    let seen_messages = factory.seen_messages();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    provider
        .routine("gate-instructions")
        .unwrap()
        .run(test_task(
            Uuid::new_v4(),
            "Review auth",
            "Confirm JWT implementation is acceptable",
        ))
        .await
        .unwrap();

    let seen_messages = seen_messages.lock().unwrap();
    assert!(
        messages_contain(&seen_messages, instructions),
        "gate step instructions should render through routine.step.instructions. Messages: {seen_messages:#?}"
    );
}

/// If an agent omits pass_verdict, the runtime should route back with an
/// explicit corrective instruction and accept the follow-up tool call.
#[tokio::test]
async fn single_agent_step_retries_until_pass_verdict() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "retry-for-pass-verdict".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("retry-for-pass-verdict"),
            name: "implement".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("coder")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let mut coder = agent(agent_id, "coder", model_id);
    coder.prompt_config.templates.chat_task = "CHAT TEMPLATE: {{ chat.message }}".into();

    let manifest = Manifest {
        agents: vec![coder],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let factory = SequentialResponseMockFactory::with_message_recording(vec![
        ChatResponse {
            text: Some("Implementation complete.".into()),
            tool_calls: vec![],
            usage: TokenUsage {
                input_tokens: 10,
                output_tokens: 5,
            },
        },
        verdict_response(
            "Implementation complete.",
            "pass",
            "Implementation is complete",
        ),
    ]);
    let seen_messages = factory.seen_messages().unwrap();

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(factory)
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Add auth", "Implement JWT authentication");
    let result = provider
        .routine("retry-for-pass-verdict")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(result.passed);
    assert_eq!(result.output, "Implementation complete.");
    assert_eq!(result.input_tokens, 20);
    assert_eq!(result.output_tokens, 10);

    let seen_messages = seen_messages.lock().unwrap();
    assert_eq!(seen_messages.len(), 2);
    let retry_messages = &seen_messages[1];
    assert!(
        messages_contain(std::slice::from_ref(retry_messages), "call `pass_verdict`"),
        "retry turn should instruct the agent to call pass_verdict. Messages: {retry_messages:#?}"
    );
    assert!(
        !messages_contain(std::slice::from_ref(retry_messages), "CHAT TEMPLATE"),
        "retry turn should not render the chat template. Messages: {retry_messages:#?}"
    );
}

/// Stream events from a single-step routine.
#[tokio::test]
async fn stream_events_single_step() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "stream-test".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("stream-test"),
            name: "work".into(),
            step_type: RoutineStepType::Agent,
            council: None,
            agent: Some(Slug::derive("worker")),
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "worker", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![verdict_response(
            "Streamed output.",
            "pass",
            "Work completed successfully",
        )]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Do work");
    let mut handle = provider
        .routine("stream-test")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut saw_step_started = false;
    let mut saw_step_completed = false;
    let mut saw_done = false;

    while let Some(event) = handle.recv().await {
        match event {
            RoutineEvent::StepStarted {
                step_name,
                step_type,
                ..
            } => {
                assert_eq!(step_name, "work");
                assert_eq!(step_type, "agent");
                saw_step_started = true;
            }
            RoutineEvent::StepCompleted { result, .. } => {
                assert!(result.passed);
                saw_step_completed = true;
            }
            RoutineEvent::Done { task_id, result } => {
                assert_eq!(task_id, Some(Uuid::nil()));
                assert_eq!(result.output, "Streamed output.");
                saw_done = true;
            }
            _ => {}
        }
    }

    assert!(saw_step_started, "should have received StepStarted");
    assert!(saw_step_completed, "should have received StepCompleted");
    assert!(saw_done, "should have received Done");
}

/// Two agent steps connected by an edge: implement → review (terminal).
#[tokio::test]
async fn two_step_chain() {
    let model_id = Uuid::new_v4();
    let coder_id = Uuid::new_v4();
    let reviewer_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let step2_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "code-review".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("code-review"),
                name: "implement".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: step2_id,
                slug: Slug::derive(step2_id.to_string()),
                routine: Slug::derive("code-review"),
                name: "review".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("reviewer")),
                config: serde_json::json!({}),
                order_index: 1,
            },
        ],
        edges: vec![RoutineEdgeManifest {
            id: Uuid::new_v4(),
            routine: Slug::derive("code-review"),
            source_step: Slug::derive(step1_id.to_string()),
            target_step: Slug::derive(step2_id.to_string()),
            condition: RoutineEdgeCondition::Always,
            metadata: serde_json::json!({}),
        }],
    };

    let manifest = Manifest {
        agents: vec![
            agent(coder_id, "coder", model_id),
            agent(reviewer_id, "reviewer", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response("Step done.", "pass", "Implementation step passed"),
            verdict_response("Step done.", "pass", "Review step passed"),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Feature", "Add login");

    let mut handle = provider
        .routine("code-review")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut step_names = Vec::new();
    while let Some(event) = handle.recv().await {
        if let RoutineEvent::StepStarted { step_name, .. } = event {
            step_names.push(step_name);
        }
    }

    assert_eq!(step_names, vec!["implement", "review"]);

    let result = handle.output().await.unwrap();
    assert!(result.passed);
}

/// A fail pass_verdict from an agent step terminates the routine and does not
/// continue along outgoing edges.
#[tokio::test]
async fn agent_step_fail_verdict_terminates_routine() {
    let model_id = Uuid::new_v4();
    let first_agent_id = Uuid::new_v4();
    let second_agent_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let step2_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "agent-fail-stops-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("agent-fail-stops-routine"),
                name: "first-agent".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("first")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: step2_id,
                slug: Slug::derive(step2_id.to_string()),
                routine: Slug::derive("agent-fail-stops-routine"),
                name: "second-agent".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("second")),
                config: serde_json::json!({}),
                order_index: 1,
            },
        ],
        edges: vec![RoutineEdgeManifest {
            id: Uuid::new_v4(),
            routine: Slug::derive("agent-fail-stops-routine"),
            source_step: Slug::derive(step1_id.to_string()),
            target_step: Slug::derive(step2_id.to_string()),
            condition: RoutineEdgeCondition::Always,
            metadata: serde_json::json!({}),
        }],
    };

    let manifest = Manifest {
        agents: vec![
            agent(first_agent_id, "first", model_id),
            agent(second_agent_id, "second", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let fail_verdict_response = ChatResponse {
        text: Some("The implementation is not acceptable.".into()),
        tool_calls: vec![ToolCall {
            id: "call_fail_verdict".into(),
            name: "pass_verdict".into(),
            arguments:
                r#"{"verdict":"fail","reasoning":"Critical acceptance criteria were missed"}"#
                    .into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            fail_verdict_response,
            ChatResponse {
                text: Some("This should never run.".into()),
                tool_calls: vec![],
                usage: TokenUsage {
                    input_tokens: 10,
                    output_tokens: 5,
                },
            },
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Do work");
    let mut handle = provider
        .routine("agent-fail-stops-routine")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut step_names = Vec::new();
    while let Some(event) = handle.recv().await {
        if let RoutineEvent::StepStarted { step_name, .. } = event {
            step_names.push(step_name);
        }
    }

    assert_eq!(step_names, vec!["first-agent"]);

    let result = handle.output().await.unwrap();
    assert!(!result.passed);
    assert_eq!(result.output, "The implementation is not acceptable.");
    assert_eq!(
        result.data.get("verdict").and_then(|v| v.as_str()),
        Some("fail")
    );
    assert_eq!(
        result.data.get("reasoning").and_then(|v| v.as_str()),
        Some("Critical acceptance criteria were missed")
    );
}

/// Gate step: agent → gate (pass) → terminal.
#[tokio::test]
async fn gate_step_pass() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let gate_id = Uuid::new_v4();
    let terminal_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "gated-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("gated-routine"),
                name: "implement".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: gate_id,
                slug: Slug::derive(gate_id.to_string()),
                routine: Slug::derive("gated-routine"),
                name: "quality-check".into(),
                step_type: RoutineStepType::Gate,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({ "instructions": "Code must compile and have tests." }),
                order_index: 1,
            },
            RoutineStepManifest {
                id: terminal_id,
                slug: Slug::derive(terminal_id.to_string()),
                routine: Slug::derive("gated-routine"),
                name: "done".into(),
                step_type: RoutineStepType::Terminal,
                council: None,
                agent: None,
                config: serde_json::json!({}),
                order_index: 2,
            },
        ],
        edges: vec![
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("gated-routine"),
                source_step: Slug::derive(step1_id.to_string()),
                target_step: Slug::derive(gate_id.to_string()),
                condition: RoutineEdgeCondition::Always,
                metadata: serde_json::json!({}),
            },
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("gated-routine"),
                source_step: Slug::derive(gate_id.to_string()),
                target_step: Slug::derive(terminal_id.to_string()),
                condition: RoutineEdgeCondition::OnPass,
                metadata: serde_json::json!({}),
            },
        ],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Implementation complete.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response("Code looks good.", "pass", "Criteria were satisfied"),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Build feature");
    let result = provider
        .routine("gated-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    // Terminal step returns the last result
    assert!(result.passed);
}

#[tokio::test]
async fn gate_always_edge_is_invalid() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let gate_id = Uuid::new_v4();
    let terminal_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "invalid-gate-routing".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("invalid-gate-routing"),
                name: "analyze_and_develop".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: gate_id,
                slug: Slug::derive(gate_id.to_string()),
                routine: Slug::derive("invalid-gate-routing"),
                name: "verify".into(),
                step_type: RoutineStepType::Gate,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({ "instructions": "Acceptance criteria must pass." }),
                order_index: 1,
            },
            RoutineStepManifest {
                id: terminal_id,
                slug: Slug::derive(terminal_id.to_string()),
                routine: Slug::derive("invalid-gate-routing"),
                name: "complete".into(),
                step_type: RoutineStepType::Terminal,
                council: None,
                agent: None,
                config: serde_json::json!({}),
                order_index: 2,
            },
        ],
        edges: vec![
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("invalid-gate-routing"),
                source_step: Slug::derive(step1_id.to_string()),
                target_step: Slug::derive(gate_id.to_string()),
                condition: RoutineEdgeCondition::OnPass,
                metadata: serde_json::json!({}),
            },
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("invalid-gate-routing"),
                source_step: Slug::derive(gate_id.to_string()),
                target_step: Slug::derive(terminal_id.to_string()),
                condition: RoutineEdgeCondition::Always,
                metadata: serde_json::json!({}),
            },
        ],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Implementation complete.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response("Needs changes.", "fail", "Criteria were not satisfied"),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Build feature");
    let err = provider
        .routine("invalid-gate-routing")
        .unwrap()
        .run(task)
        .await
        .unwrap_err();

    assert!(err.to_string().contains("must route with on_pass/on_fail"));
}

#[tokio::test]
async fn gate_on_fail_routes_back_before_completion() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let gate_id = Uuid::new_v4();
    let terminal_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "retry-gated-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("retry-gated-routine"),
                name: "analyze_and_develop".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: gate_id,
                slug: Slug::derive(gate_id.to_string()),
                routine: Slug::derive("retry-gated-routine"),
                name: "verify".into(),
                step_type: RoutineStepType::Gate,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({ "instructions": "Acceptance criteria must pass." }),
                order_index: 1,
            },
            RoutineStepManifest {
                id: terminal_id,
                slug: Slug::derive(terminal_id.to_string()),
                routine: Slug::derive("retry-gated-routine"),
                name: "complete".into(),
                step_type: RoutineStepType::Terminal,
                council: None,
                agent: None,
                config: serde_json::json!({}),
                order_index: 2,
            },
        ],
        edges: vec![
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("retry-gated-routine"),
                source_step: Slug::derive(step1_id.to_string()),
                target_step: Slug::derive(gate_id.to_string()),
                condition: RoutineEdgeCondition::OnPass,
                metadata: serde_json::json!({}),
            },
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("retry-gated-routine"),
                source_step: Slug::derive(gate_id.to_string()),
                target_step: Slug::derive(step1_id.to_string()),
                condition: RoutineEdgeCondition::OnFail,
                metadata: serde_json::json!({}),
            },
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("retry-gated-routine"),
                source_step: Slug::derive(gate_id.to_string()),
                target_step: Slug::derive(terminal_id.to_string()),
                condition: RoutineEdgeCondition::OnPass,
                metadata: serde_json::json!({}),
            },
        ],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Implementation complete.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response("Needs changes.", "fail", "Criteria were not satisfied"),
            verdict_response(
                "Implementation revised.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response("Looks good.", "pass", "Criteria were satisfied"),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Build feature");
    let mut handle = provider
        .routine("retry-gated-routine")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut analyze_starts = 0;
    let mut verify_starts = 0;
    while let Some(event) = handle.recv().await {
        if let RoutineEvent::StepStarted { step_name, .. } = event {
            match step_name.as_str() {
                "analyze_and_develop" => analyze_starts += 1,
                "verify" => verify_starts += 1,
                _ => {}
            }
        }
    }

    let result = handle.output().await.unwrap();

    assert!(result.passed);
    assert_eq!(analyze_starts, 2);
    assert_eq!(verify_starts, 2);
}

#[tokio::test]
async fn gate_on_fail_edge_exhausts_after_max_attempts() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step1_id = Uuid::new_v4();
    let gate_id = Uuid::new_v4();
    let failed_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();
    let retry_edge_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "retry-exhaustion-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: step1_id,
                slug: Slug::derive(step1_id.to_string()),
                routine: Slug::derive("retry-exhaustion-routine"),
                name: "analyze_and_develop".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({}),
                order_index: 0,
            },
            RoutineStepManifest {
                id: gate_id,
                slug: Slug::derive(gate_id.to_string()),
                routine: Slug::derive("retry-exhaustion-routine"),
                name: "verify".into(),
                step_type: RoutineStepType::Gate,
                council: None,
                agent: Some(Slug::derive("coder")),
                config: serde_json::json!({ "instructions": "Acceptance criteria must pass." }),
                order_index: 1,
            },
            RoutineStepManifest {
                id: failed_id,
                slug: Slug::derive(failed_id.to_string()),
                routine: Slug::derive("retry-exhaustion-routine"),
                name: "failed".into(),
                step_type: RoutineStepType::TerminalFail,
                council: None,
                agent: None,
                config: serde_json::json!({ "reason": "Retry limit exhausted." }),
                order_index: 2,
            },
        ],
        edges: vec![
            RoutineEdgeManifest {
                id: Uuid::new_v4(),
                routine: Slug::derive("retry-exhaustion-routine"),
                source_step: Slug::derive(step1_id.to_string()),
                target_step: Slug::derive(gate_id.to_string()),
                condition: RoutineEdgeCondition::OnPass,
                metadata: serde_json::json!({}),
            },
            RoutineEdgeManifest {
                id: retry_edge_id,
                routine: Slug::derive("retry-exhaustion-routine"),
                source_step: Slug::derive(gate_id.to_string()),
                target_step: Slug::derive(step1_id.to_string()),
                condition: RoutineEdgeCondition::OnFail,
                metadata: serde_json::json!({
                    "max_attempts": 1,
                    "on_exhausted_step_id": failed_id,
                }),
            },
        ],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "coder", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Implementation complete.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response("Needs changes.", "fail", "Criteria were not satisfied"),
            verdict_response(
                "Implementation revised.",
                "pass",
                "Implementation succeeded",
            ),
            verdict_response(
                "Still needs changes.",
                "fail",
                "Criteria were not satisfied",
            ),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let result = provider
        .routine("retry-exhaustion-routine")
        .unwrap()
        .run(test_task(Uuid::new_v4(), "Task", "Build feature"))
        .await
        .unwrap();

    assert!(!result.passed);
    assert_eq!(result.step_id, failed_id);
    assert_eq!(result.output, "Retry limit exhausted.");
}

/// Routine not found → error.
#[tokio::test]
async fn routine_not_found() {
    let provider = Provider::builder()
        .with_manifest(Manifest::default())
        .with_model_factory(MockFactory::new("irrelevant"))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let err = provider.routine(Uuid::new_v4().to_string());

    assert!(err.is_err());
    assert!(err.unwrap_err().to_string().contains("not found"));
}

/// Terminal fail step produces a failed result.
#[tokio::test]
async fn terminal_fail_step() {
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "fail-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("fail-routine"),
            name: "abort".into(),
            step_type: RoutineStepType::TerminalFail,
            council: None,
            agent: None,
            config: serde_json::json!({ "reason": "Blocked by policy." }),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(MockFactory::new("irrelevant"))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Task", "Desc");
    let result = provider
        .routine("fail-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(!result.passed);
    assert_eq!(result.output, "Blocked by policy.");
}

/// Council step with decompose strategy.
#[tokio::test]
async fn council_decompose() {
    let model_id = Uuid::new_v4();
    let leader_id = Uuid::new_v4();
    let member_id = Uuid::new_v4();
    let council_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "council-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("council-routine"),
            name: "council-step".into(),
            step_type: RoutineStepType::Council,
            council: Some(Slug::derive("test-council")),
            agent: None,
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![
            agent(leader_id, "leader", model_id),
            agent(member_id, "member", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        councils: vec![CouncilManifest {
            id: council_id,
            name: "test-council".into(),
            delegation_strategy: CouncilDelegationStrategy::Decompose,
            leader_agent: Slug::derive("leader"),
            members: vec![CouncilMemberManifest {
                agent: Slug::derive("member"),
                priority: 1,
            }],
        }],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response("1. Do the thing", "pass", "Decomposition is complete"),
            verdict_response("Did the thing", "pass", "Assignment is complete"),
            verdict_response("Council synthesis complete.", "pass", "Council agrees"),
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Council task", "Build the feature");
    let result = provider
        .routine("council-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    // Council always returns a result (even with mock LLM)
    assert!(!result.output.is_empty());
}

#[tokio::test]
async fn council_broadcast() {
    let model_id = Uuid::new_v4();
    let leader_id = Uuid::new_v4();
    let member_a_id = Uuid::new_v4();
    let member_b_id = Uuid::new_v4();
    let council_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "broadcast-council-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("broadcast-council-routine"),
            name: "broadcast-council-step".into(),
            step_type: RoutineStepType::Council,
            council: Some(Slug::derive("broadcast-council")),
            agent: None,
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![
            agent(leader_id, "leader", model_id),
            agent(member_a_id, "member-a", model_id),
            agent(member_b_id, "member-b", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        councils: vec![CouncilManifest {
            id: council_id,
            name: "broadcast-council".into(),
            delegation_strategy: CouncilDelegationStrategy::Broadcast,
            leader_agent: Slug::derive("leader"),
            members: vec![
                CouncilMemberManifest {
                    agent: Slug::derive("member-a"),
                    priority: 1,
                },
                CouncilMemberManifest {
                    agent: Slug::derive("member-b"),
                    priority: 2,
                },
            ],
        }],
        ..Default::default()
    };

    let leader_response = ChatResponse {
        text: Some("Broadcast consensus complete.".into()),
        tool_calls: vec![ToolCall {
            id: "call_broadcast".into(),
            name: "pass_verdict".into(),
            arguments: r#"{"verdict":"pass","reasoning":"Broadcast consensus reached"}"#.into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Member A independent assessment",
                "pass",
                "Assessment complete",
            ),
            verdict_response(
                "Member B independent assessment",
                "pass",
                "Assessment complete",
            ),
            leader_response,
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Council task", "Build the feature");
    let result = provider
        .routine("broadcast-council-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(result.passed);
    assert_eq!(result.output, "Broadcast consensus complete.");
}

#[tokio::test]
async fn council_round_robin() {
    let model_id = Uuid::new_v4();
    let leader_id = Uuid::new_v4();
    let member_a_id = Uuid::new_v4();
    let member_b_id = Uuid::new_v4();
    let council_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "round-robin-council-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("round-robin-council-routine"),
            name: "round-robin-council-step".into(),
            step_type: RoutineStepType::Council,
            council: Some(Slug::derive("round-robin-council")),
            agent: None,
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![
            agent(leader_id, "leader", model_id),
            agent(member_a_id, "member-a", model_id),
            agent(member_b_id, "member-b", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        councils: vec![CouncilManifest {
            id: council_id,
            name: "round-robin-council".into(),
            delegation_strategy: CouncilDelegationStrategy::RoundRobin,
            leader_agent: Slug::derive("leader"),
            members: vec![
                CouncilMemberManifest {
                    agent: Slug::derive("member-a"),
                    priority: 1,
                },
                CouncilMemberManifest {
                    agent: Slug::derive("member-b"),
                    priority: 2,
                },
            ],
        }],
        ..Default::default()
    };

    let leader_response = ChatResponse {
        text: Some("Round robin synthesis complete.".into()),
        tool_calls: vec![ToolCall {
            id: "call_round_robin".into(),
            name: "pass_verdict".into(),
            arguments: r#"{"verdict":"pass","reasoning":"Round robin sequence converged"}"#.into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response("Contribution one", "pass", "First contribution complete"),
            verdict_response(
                "Contribution two building on prior context",
                "pass",
                "Second contribution complete",
            ),
            leader_response,
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Council task", "Build the feature");
    let result = provider
        .routine("round-robin-council-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(result.passed);
    assert_eq!(result.output, "Round robin synthesis complete.");
}

#[tokio::test]
async fn council_vote() {
    let model_id = Uuid::new_v4();
    let leader_id = Uuid::new_v4();
    let member_a_id = Uuid::new_v4();
    let member_b_id = Uuid::new_v4();
    let council_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "vote-council-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Task,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("vote-council-routine"),
            name: "vote-council-step".into(),
            step_type: RoutineStepType::Council,
            council: Some(Slug::derive("vote-council")),
            agent: None,
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![
            agent(leader_id, "leader", model_id),
            agent(member_a_id, "member-a", model_id),
            agent(member_b_id, "member-b", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        councils: vec![CouncilManifest {
            id: council_id,
            name: "vote-council".into(),
            delegation_strategy: CouncilDelegationStrategy::Vote,
            leader_agent: Slug::derive("leader"),
            members: vec![
                CouncilMemberManifest {
                    agent: Slug::derive("member-a"),
                    priority: 1,
                },
                CouncilMemberManifest {
                    agent: Slug::derive("member-b"),
                    priority: 2,
                },
            ],
        }],
        ..Default::default()
    };

    let leader_response = ChatResponse {
        text: Some("Vote tallied and accepted.".into()),
        tool_calls: vec![ToolCall {
            id: "call_vote".into(),
            name: "pass_verdict".into(),
            arguments: r#"{"verdict":"pass","reasoning":"Majority voted to proceed"}"#.into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![
            verdict_response(
                "Vote: pass. Reason: solution is sound.",
                "pass",
                "Vote cast in favor",
            ),
            verdict_response(
                "Vote: pass. Reason: acceptable tradeoffs.",
                "pass",
                "Vote cast in favor",
            ),
            leader_response,
        ]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = test_task(Uuid::new_v4(), "Council task", "Build the feature");
    let result = provider
        .routine("vote-council-routine")
        .unwrap()
        .run(task)
        .await
        .unwrap();

    assert!(result.passed);
    assert_eq!(result.output, "Vote tallied and accepted.");
}

/// Cron execution: runs one scheduled routine firing.
#[tokio::test]
async fn scheduled_cron_routine_execution() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "cron-routine".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Cron,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("cron-routine"),
            name: "check".into(),
            step_type: RoutineStepType::Gate,
            council: None,
            agent: Some(Slug::derive("monitor")),
            config: serde_json::json!({ "instructions": "Check system health" }),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "monitor", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    // Mock returns a pass_verdict tool call for the gate step. The cron
    // wrapper should complete this scheduled firing after the DAG run.
    let verdict_response = ChatResponse {
        text: Some("Evaluation complete.".into()),
        tool_calls: vec![ToolCall {
            id: "call_1".into(),
            name: "pass_verdict".into(),
            arguments: r#"{"verdict": "pass", "reasoning": "All checks passed"}"#.into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };
    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![verdict_response]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = RoutineRun::cron(CronInput {
        task: None,
        project: Some(nenjo::Slug::derive("project")),
        schedule: nenjo::routines::types::CronSchedule::Interval(Duration::from_millis(50)),
        start_at: None,
        timeout: Duration::from_secs(5),
    });

    let mut handle = provider
        .routine("cron-routine")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut cycles_started = 0u32;
    let mut cycles_completed = 0u32;

    while let Some(event) = handle.recv().await {
        match event {
            RoutineEvent::CronCycleStarted { .. } => cycles_started += 1,
            RoutineEvent::CronCycleCompleted { .. } => cycles_completed += 1,
            _ => {}
        }
    }

    assert_eq!(cycles_started, 1, "should have started 1 cron cycle");
    assert_eq!(cycles_completed, 1, "should have completed 1 cron cycle");

    let result = handle.output().await.unwrap();
    assert!(result.passed, "cron routine should pass with pass_verdict");
    assert_eq!(
        result.data.get("verdict").and_then(|v| v.as_str()),
        Some("pass"),
        "should have structured verdict data"
    );
}

/// Cron execution: agent pass_verdict routes to following terminal step.
#[tokio::test]
async fn cron_agent_pass_verdict_continues_to_terminal_step() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let agent_step_id = Uuid::new_v4();
    let terminal_step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();

    let routine = RoutineManifest {
        id: routine_id,
        name: "cron-agent-terminal".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Cron,
        metadata: RoutineMetadata::default(),
        steps: vec![
            RoutineStepManifest {
                id: agent_step_id,
                slug: Slug::derive(agent_step_id.to_string()),
                routine: Slug::derive("cron-agent-terminal"),
                name: "inspect".into(),
                step_type: RoutineStepType::Agent,
                council: None,
                agent: Some(Slug::derive("monitor")),
                config: serde_json::json!({ "description": "Inspect workspace files" }),
                order_index: 0,
            },
            RoutineStepManifest {
                id: terminal_step_id,
                slug: Slug::derive(terminal_step_id.to_string()),
                routine: Slug::derive("cron-agent-terminal"),
                name: "done".into(),
                step_type: RoutineStepType::Terminal,
                council: None,
                agent: None,
                config: serde_json::json!({}),
                order_index: 1,
            },
        ],
        edges: vec![RoutineEdgeManifest {
            id: Uuid::new_v4(),
            routine: Slug::derive("cron-agent-terminal"),
            source_step: Slug::derive(agent_step_id.to_string()),
            target_step: Slug::derive(terminal_step_id.to_string()),
            condition: RoutineEdgeCondition::OnPass,
            metadata: serde_json::json!({}),
        }],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "monitor", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let verdict_response = ChatResponse {
        text: Some("Inspection complete.".into()),
        tool_calls: vec![ToolCall {
            id: "call_pass_verdict".into(),
            name: "pass_verdict".into(),
            arguments:
                r#"{"verdict":"pass","reasoning":"Workspace inspected","output":"Found files"}"#
                    .into(),
        }],
        usage: TokenUsage {
            input_tokens: 10,
            output_tokens: 5,
        },
    };
    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(SequentialResponseMockFactory::new(vec![verdict_response]))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = RoutineRun::cron(CronInput {
        task: None,
        project: None,
        schedule: nenjo::routines::types::CronSchedule::Interval(Duration::from_millis(50)),
        start_at: None,
        timeout: Duration::from_secs(5),
    });

    let mut handle = provider
        .routine("cron-agent-terminal")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    let mut cycles_started = 0u32;
    let mut cycles_completed = 0u32;
    let mut step_names = Vec::new();

    while let Some(event) = handle.recv().await {
        match event {
            RoutineEvent::CronCycleStarted { .. } => cycles_started += 1,
            RoutineEvent::CronCycleCompleted { .. } => cycles_completed += 1,
            RoutineEvent::StepStarted { step_name, .. } => step_names.push(step_name),
            _ => {}
        }
    }

    assert_eq!(cycles_started, 1, "should run one scheduled firing");
    assert_eq!(cycles_completed, 1, "should complete one cron cycle");
    assert_eq!(
        step_names,
        vec!["inspect", "done"],
        "pass verdict should allow the next routine step to run"
    );

    let result = handle.output().await.unwrap();
    assert!(result.passed);
    assert_eq!(result.output, "Found files");
}

/// Cron cancellation: cancel the handle mid-execution and verify it stops.
#[tokio::test]
async fn cron_cancellation() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();
    let step_id = Uuid::new_v4();
    let routine_id = Uuid::new_v4();
    let routine = RoutineManifest {
        id: routine_id,
        name: "cancel-cron".into(),
        description: None,
        trigger: nenjo::manifest::RoutineTrigger::Cron,
        metadata: RoutineMetadata::default(),
        steps: vec![RoutineStepManifest {
            id: step_id,
            slug: Slug::derive(step_id.to_string()),
            routine: Slug::derive("cancel-cron"),
            name: "poll".into(),
            step_type: RoutineStepType::Terminal,
            council: None,
            agent: None,
            config: serde_json::json!({}),
            order_index: 0,
        }],
        edges: vec![],
    };

    let manifest = Manifest {
        agents: vec![agent(agent_id, "poller", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        routines: vec![routine],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(MockFactory::new("wait"))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let task = RoutineRun::cron(CronInput {
        task: None,
        project: Some(nenjo::Slug::derive("project")),
        schedule: nenjo::routines::types::CronSchedule::Interval(Duration::from_millis(50)),
        start_at: None,
        timeout: Duration::from_secs(30),
    });

    let mut handle = provider
        .routine("cancel-cron")
        .unwrap()
        .run_stream(task)
        .await
        .unwrap();

    // Wait for the scheduled firing to complete, then cancel. The firing may
    // already be complete, because cron execution runs one DAG cycle.
    let mut saw_cycle = false;
    while let Some(event) = handle.recv().await {
        if let RoutineEvent::CronCycleCompleted { .. } = event {
            saw_cycle = true;
            handle.cancel();
            break;
        }
    }

    assert!(
        saw_cycle,
        "should have seen at least one cron cycle before cancel"
    );

    let result = handle.output().await.unwrap();
    assert!(result.passed);
}

// ===========================================================================
// Sub-agent tool injection tests
// ===========================================================================

/// The legacy delegate_to tool is no longer injected at build time.
#[tokio::test]
async fn delegation_basic() {
    let model_id = Uuid::new_v4();
    let coder_id = Uuid::new_v4();
    let reviewer_id = Uuid::new_v4();

    let manifest = Manifest {
        agents: vec![
            agent(coder_id, "coder", model_id),
            agent(reviewer_id, "reviewer", model_id),
        ],
        models: vec![model(model_id)],
        projects: vec![project()],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(MockFactory::new("Delegation result from reviewer."))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    // Parent sub-agent tools are injected into the per-run clone, not the
    // stored runner instance. The legacy delegate_to tool should never appear.
    let runner = provider
        .agent("coder")
        .await
        .unwrap()
        .build()
        .await
        .unwrap();

    let specs = runner.instance().tool_specs();
    let tool_names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();

    assert!(
        !tool_names.contains(&"delegate_to"),
        "delegate_to should not be injected. Tools: {:?}",
        tool_names
    );
}

/// Single agent should not get legacy delegate_to.
#[tokio::test]
async fn delegation_not_injected_for_single_agent() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();

    let manifest = Manifest {
        agents: vec![agent(agent_id, "solo", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(MockFactory::new("irrelevant"))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let runner = provider.agent("solo").await.unwrap().build().await.unwrap();

    let specs = runner.instance().tool_specs();
    let tool_names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();

    assert!(
        !tool_names.contains(&"delegate_to"),
        "delegate_to should NOT be injected for a single agent"
    );
}

#[tokio::test]
async fn worktree_scoped_agent_keeps_extra_runtime_tools() {
    let model_id = Uuid::new_v4();
    let agent_id = Uuid::new_v4();

    let manifest = Manifest {
        agents: vec![agent(agent_id, "worker", model_id)],
        models: vec![model(model_id)],
        projects: vec![project()],
        ..Default::default()
    };

    let provider = Provider::builder()
        .with_manifest(manifest)
        .with_model_factory(MockFactory::new("irrelevant"))
        .with_tool_factory(NoopToolFactory)
        .build()
        .await
        .unwrap();

    let work_dir = tempfile::tempdir().unwrap();
    let runner = provider
        .agent("worker")
        .await
        .unwrap()
        .with_tool(PassVerdictTool::new())
        .with_work_dir(work_dir.path())
        .build()
        .await
        .unwrap();

    let specs = runner.instance().tool_specs();
    let tool_names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();

    assert!(
        tool_names.contains(&"pass_verdict"),
        "pass_verdict should survive worktree tool rebuild. Tools: {:?}",
        tool_names
    );
}
