#[cfg(test)]
mod tests {
    use crate::agents::runner::*;
    use crate::models::AgentType;
    use serial_test::serial;

    // ─── parse_claude_stream_line ─────────────────────────────────────────────

    #[test]
    fn parse_stream_empty_line() {
        assert!(matches!(parse_claude_stream_line(""), StreamJsonEvent::Skip));
        assert!(matches!(parse_claude_stream_line("  "), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_text_delta() {
        let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::Text(t) => assert_eq!(t, "Hello"),
            _ => panic!("Expected Text event"),
        }
    }

    // ─── strip_thinking_leaks ─────────────────────────────────────────────────

    #[test]
    fn strip_thinking_leaks_removes_closing_tag() {
        // Hot-path case: Claude Opus leaks one closing tag into a text delta.
        assert_eq!(strip_thinking_leaks("</thinking>"), "");
        assert_eq!(strip_thinking_leaks("</thinking>\n"), "\n");
    }

    #[test]
    fn strip_thinking_leaks_removes_runaway_repeats() {
        // EW-7189 reproducer: the partial_response had 6349× `</thinking>\n`.
        // After stripping, the delta collapses to just the newlines.
        let input = "</thinking>\n".repeat(200);
        let out = strip_thinking_leaks(&input);
        assert_eq!(out, "\n".repeat(200));
        assert!(!out.contains("thinking"));
    }

    #[test]
    fn strip_thinking_leaks_is_case_insensitive() {
        // Model quirks: `<Thinking>`, `<THINKING>` seen in the wild.
        assert_eq!(strip_thinking_leaks("<Thinking>x</Thinking>"), "x");
        assert_eq!(strip_thinking_leaks("<THINKING>y</THINKING>"), "y");
    }

    #[test]
    fn strip_thinking_leaks_preserves_legitimate_content() {
        // The word "thinking" in plain text — MUST NOT be stripped.
        assert_eq!(strip_thinking_leaks("Thinking about it."), "Thinking about it.");
        // Unrelated HTML-like content — MUST NOT be stripped.
        assert_eq!(strip_thinking_leaks("Use <em>this</em> tag."), "Use <em>this</em> tag.");
        // A genuine code sample referencing the tag as string — rare, but we
        // err on the side of the stream-pollution fix here: if someone really
        // needs to paste `</thinking>` into a message, they can escape it.
        // Documented trade-off in strip_thinking_leaks's doc comment.
    }

    #[test]
    fn parse_stream_text_delta_with_thinking_leak_is_skipped() {
        // End-to-end: a text_delta whose entire content is the leak should
        // NOT reach `full_response` as an empty chunk (which would still
        // count toward the downstream loop detector).
        let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"</thinking>"}}}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_text_delta_with_partial_thinking_leak_preserves_rest() {
        // Mixed chunk: legitimate text with a stray tag — keep the text.
        let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello</thinking> world"}}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::Text(t) => assert_eq!(t, "Hello world"),
            other => panic!("Expected Text event, got {:?}", other),
        }
    }

    #[test]
    fn parse_stream_usage_from_message_delta() {
        let line = r#"{"type":"stream_event","event":{"type":"message_delta","usage":{"input_tokens":100,"output_tokens":50}}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::Usage { input_tokens, output_tokens, cost_usd } => {
                assert_eq!(input_tokens, 100);
                assert_eq!(output_tokens, 50);
                assert!(cost_usd.is_none());
            }
            _ => panic!("Expected Usage event"),
        }
    }

    #[test]
    fn parse_stream_result_with_usage() {
        let line = r#"{"type":"result","subtype":"success","cost_usd":0.01,"usage":{"input_tokens":200,"output_tokens":100}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::Usage { input_tokens, output_tokens, cost_usd } => {
                assert_eq!(input_tokens, 200);
                assert_eq!(output_tokens, 100);
                assert!((cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
            }
            _ => panic!("Expected Usage event from result"),
        }
    }

    #[test]
    fn parse_stream_result_without_usage() {
        let line = r#"{"type":"result","subtype":"success"}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_assistant_skipped() {
        let line = r#"{"type":"assistant","message":"full text so far"}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_not_json() {
        // Non-JSON lines should be passed through as text
        match parse_claude_stream_line("This is plain text output") {
            StreamJsonEvent::Text(t) => assert_eq!(t, "This is plain text output"),
            _ => panic!("Expected Text passthrough"),
        }
    }

    #[test]
    fn parse_stream_unknown_type() {
        let line = r#"{"type":"init","session_id":"abc"}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_event_without_delta() {
        let line = r#"{"type":"stream_event","event":{"type":"message_start"}}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    #[test]
    fn parse_stream_zero_usage_skipped() {
        let line = r#"{"type":"stream_event","event":{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":0}}}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::Skip));
    }

    // ─── parse_token_usage ────────────────────────────────────────────────────

    #[test]
    fn codex_tokens_from_stderr() {
        let stderr = vec!["some info".into(), "tokens used".into(), "1,234".into()];
        let (response, tokens) = parse_token_usage(&AgentType::Codex, "response text", &stderr);
        assert_eq!(tokens, 1234);
        assert_eq!(response, "response text"); // response not modified
    }

    #[test]
    fn codex_tokens_from_stdout_fallback() {
        let response = "Hello world\ntokens used\n5,678";
        let (cleaned, tokens) = parse_token_usage(&AgentType::Codex, response, &[]);
        assert_eq!(tokens, 5678);
        assert_eq!(cleaned, "Hello world"); // token lines stripped
    }

    #[test]
    fn codex_no_tokens() {
        let (response, tokens) = parse_token_usage(&AgentType::Codex, "just a response", &[]);
        assert_eq!(tokens, 0);
        assert_eq!(response, "just a response");
    }

    #[test]
    fn claude_tokens_always_zero_from_this_fn() {
        // Claude Code tokens are parsed inline via parse_claude_stream_line
        let (_, tokens) = parse_token_usage(&AgentType::ClaudeCode, "response", &[]);
        assert_eq!(tokens, 0);
    }

    #[test]
    fn gemini_strips_mcp_issues_prefix() {
        // Pin user-reported bug 2026-05-10: when one or more MCPs in the
        // project's `.mcp.json` fail handshake (auth gone stale, missing
        // binaries, network blocks), Gemini CLI 0.32 prepends a noisy
        // header to its reply that confuses the user (they assume Gemini
        // failed when it didn't). parse_token_usage must strip it.
        let raw = "MCP issues detected. Run /mcp list for status.\nVoici la réponse réelle de Gemini.";
        let (cleaned, tokens) = parse_token_usage(&AgentType::GeminiCli, raw, &[]);
        assert_eq!(tokens, 0);
        assert_eq!(cleaned, "Voici la réponse réelle de Gemini.");
    }

    #[test]
    fn gemini_strips_mcp_issues_when_inline() {
        // The same marker sometimes lands without a leading newline (Gemini
        // streams it as a continuation of the previous line when the discovery
        // error fires mid-output).
        let raw = "MCP issues detected. Run /mcp list for status.Réponse: ok";
        let (cleaned, _) = parse_token_usage(&AgentType::GeminiCli, raw, &[]);
        assert_eq!(cleaned, "Réponse: ok");
    }

    #[test]
    fn gemini_drops_mcp_debug_lines() {
        // Gemini debug output (`Server '…' supports tool updates...` and
        // `[MCP error] …`) leaks into stdout on some MCP server configs.
        // Strip it so the saved transcript only carries the agent's own
        // reply.
        let raw = "\
Server 'GitLab' supports tool updates. Listening for changes...
[MCP error] Error during discovery for MCP server 'context7': MCP error -32000
Réponse de Gemini.
Suite de la réponse.";
        let (cleaned, _) = parse_token_usage(&AgentType::GeminiCli, raw, &[]);
        assert_eq!(cleaned, "Réponse de Gemini.\nSuite de la réponse.");
    }

    #[test]
    fn gemini_keeps_clean_response_unchanged() {
        let raw = "Réponse propre sans préambule MCP.";
        let (cleaned, _) = parse_token_usage(&AgentType::GeminiCli, raw, &[]);
        assert_eq!(cleaned, raw);
    }

    #[test]
    fn vibe_tokens_always_zero() {
        let (_, tokens) = parse_token_usage(&AgentType::Vibe, "response", &[]);
        assert_eq!(tokens, 0);
    }

    #[test]
    fn gemini_tokens_always_zero() {
        let (_, tokens) = parse_token_usage(&AgentType::GeminiCli, "response", &[]);
        assert_eq!(tokens, 0);
    }

    // ─── fix_file_ownership ──────────────────────────────────────────────────
    // super::super:: because: runner.rs > runner_test (mod) > tests (mod)

    #[test]
    #[serial]
    fn fix_file_ownership_no_env_vars_does_not_panic() {
        // When KRONN_HOST_UID / KRONN_HOST_GID are not set, fix_file_ownership
        // should return early without error.
        std::env::remove_var("KRONN_HOST_UID");
        std::env::remove_var("KRONN_HOST_GID");
        super::super::fix_file_ownership(std::path::Path::new("/tmp"));
    }

    #[test]
    #[serial]
    fn fix_file_ownership_with_nonexistent_dir_does_not_panic() {
        // Even with UID/GID set, chown on a nonexistent path should not panic.
        std::env::set_var("KRONN_HOST_UID", "1000");
        std::env::set_var("KRONN_HOST_GID", "1000");
        super::super::fix_file_ownership(std::path::Path::new("/nonexistent/path/for/test"));
        // Clean up
        std::env::remove_var("KRONN_HOST_UID");
        std::env::remove_var("KRONN_HOST_GID");
    }

    // ─── agent_command: full_access flags ──────────────────────────────────────

    #[test]
    fn claude_code_full_access_adds_skip_permissions() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "test prompt", true, "", None,
        );
        assert!(args.contains(&"--dangerously-skip-permissions".to_string()),
            "Claude Code with full_access should include --dangerously-skip-permissions");
    }

    #[test]
    fn claude_code_no_full_access_omits_skip_permissions() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "test prompt", false, "", None,
        );
        assert!(!args.contains(&"--dangerously-skip-permissions".to_string()),
            "Claude Code without full_access should NOT include --dangerously-skip-permissions");
    }

    #[test]
    fn codex_full_access_uses_explicit_sandbox_only() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "test prompt", true, "", None,
        );
        assert!(!args.contains(&"--full-auto".to_string()),
            "Codex should not include --full-auto (it overrides explicit sandbox)");
    }

    #[test]
    fn codex_no_full_access_omits_full_auto() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "test prompt", false, "", None,
        );
        assert!(!args.contains(&"--full-auto".to_string()),
            "Codex without full_access should NOT include --full-auto");
    }

    #[test]
    fn gemini_full_access_adds_yolo() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::GeminiCli, "test prompt", true, "", None,
        );
        assert!(args.contains(&"--yolo".to_string()),
            "Gemini CLI with full_access should include --yolo");
        // --yolo must come BEFORE -p (Gemini requires -p <prompt> as last args)
        let yolo_idx = args.iter().position(|a| a == "--yolo").unwrap();
        let p_idx = args.iter().position(|a| a == "-p").unwrap();
        assert!(yolo_idx < p_idx,
            "--yolo ({}) must come before -p ({}) to avoid arg parsing issues", yolo_idx, p_idx);
    }

    // ─── agent_command: MCP/skills context injection ───────────────────────────

    #[test]
    fn claude_code_injects_context_via_append_system_prompt() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "prompt", false, "MCP context here", None,
        );
        let idx = args.iter().position(|a| a == "--append-system-prompt");
        assert!(idx.is_some(), "Should have --append-system-prompt flag");
        assert_eq!(args[idx.unwrap() + 1], "MCP context here");
    }

    #[test]
    fn codex_prepends_context_to_prompt() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "user prompt", false, "MCP context", None,
        );
        let last = args.last().unwrap();
        assert!(last.starts_with("MCP context"), "Context should be prepended to prompt");
        assert!(last.contains("user prompt"), "Original prompt should be in the combined prompt");
    }

    #[test]
    fn agent_command_no_context_when_empty() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "prompt", false, "", None,
        );
        assert!(!args.contains(&"--append-system-prompt".to_string()),
            "Should not add --append-system-prompt when context is empty");
    }

    // ─── Kiro output cleaning ──────────────────────────────────────────────────

    #[test]
    fn kiro_credits_parsing() {
        let stderr = vec!["▸ Credits: 0.05 • Time: 3s".into()];
        let (_, tokens) = parse_token_usage(&AgentType::Kiro, "response", &stderr);
        assert_eq!(tokens, 500); // 0.05 × 10000
    }

    #[test]
    fn kiro_credits_parsing_no_bullet() {
        let stderr = vec!["Credits: 1.23 • Time: 10s".into()];
        let (_, tokens) = parse_token_usage(&AgentType::Kiro, "response", &stderr);
        assert_eq!(tokens, 12300); // 1.23 × 10000
    }

    // ─── clean_kiro_line: structural pattern filtering ───────────────────────

    #[test]
    fn kiro_filters_tool_use_lines() {
        // Lines with "(using tool: X)" should be filtered regardless of language
        assert!(clean_kiro_line("Reading file: /some/path (using tool: read)").is_none());
        assert!(clean_kiro_line("Recherche de symboles (using tool: code)").is_none());
        assert!(clean_kiro_line("Buscando archivos (using tool: grep)").is_none());
        assert!(clean_kiro_line("ファイルを書いています (using tool: write)").is_none());
    }

    #[test]
    fn kiro_filters_mcp_tool_calls() {
        assert!(clean_kiro_line("Running tool jira_get_issue with params (from mcp server: atlassian)").is_none());
        assert!(clean_kiro_line("Appel de l'outil get_repos (from mcp server: github)").is_none());
    }

    #[test]
    fn kiro_filters_unicode_markers() {
        assert!(clean_kiro_line("✓ Successfully read 7951 bytes").is_none());
        assert!(clean_kiro_line("↱ Operation 1: Reading file").is_none());
        assert!(clean_kiro_line("⋮").is_none());
        assert!(clean_kiro_line("❗ No matches found for pattern: X").is_none());
    }

    #[test]
    fn kiro_filters_structured_results() {
        assert!(clean_kiro_line("- Completed in 0.39s").is_none());
        assert!(clean_kiro_line("- Summary: 2 operations processed").is_none());
        assert!(clean_kiro_line("Batch fs_read operation with 2 operations").is_none());
    }

    #[test]
    fn kiro_filters_credits_and_empty() {
        assert!(clean_kiro_line("Credits: 0.05 • Time: 3s").is_none());
        assert!(clean_kiro_line("▸ Credits: 1.23").is_none());
        assert!(clean_kiro_line("").is_none());
        assert!(clean_kiro_line("   ").is_none());
    }

    #[test]
    fn kiro_filters_shell_commands_and_symbol_lookups() {
        // Real examples from Kiro output — "I will run..." contains "(using tool: shell)"
        assert!(clean_kiro_line("I will run the following command: find /some/path -name '*.yaml' (using tool: shell)").is_none());
        assert!(clean_kiro_line("Getting symbols from: /some/file.php [top_level=true] (using tool: code)").is_none());
        // French variant
        assert!(clean_kiro_line("Je vais exécuter la commande suivante: ls -la (using tool: shell)").is_none());
    }

    #[test]
    fn kiro_keeps_real_content() {
        // Actual response text should NOT be filtered
        assert_eq!(clean_kiro_line("Voici l'analyse du problème :"), Some("Voici l'analyse du problème :".into()));
        assert_eq!(clean_kiro_line("## Architecture des redirections"), Some("## Architecture des redirections".into()));
        assert_eq!(clean_kiro_line("Layer 1 — YAML"), Some("Layer 1 — YAML".into()));
        assert_eq!(clean_kiro_line("The fix needed: preserve query params"), Some("The fix needed: preserve query params".into()));
    }

    #[test]
    fn kiro_strips_ansi_and_prefix() {
        // ANSI codes should be stripped
        assert_eq!(clean_kiro_line("\x1b[32mSome text\x1b[0m"), Some("Some text".into()));
        // "> " prefix should be stripped
        assert_eq!(clean_kiro_line("> Response text"), Some("Response text".into()));
    }

    // ─── agent_command: complete args structure per agent ────────────────────────
    //
    // These tests verify the full command structure for each agent type.
    // They catch regressions like missing flags, wrong binary names,
    // wrong env key, or broken npx fallback packages.

    #[test]
    fn claude_code_command_structure() {
        let (binary, npx, args, env_key, _, output_mode) = super::super::agent_command(
            &AgentType::ClaudeCode, "do something", false, "", None,
        );
        assert_eq!(binary, "claude");
        assert_eq!(npx, Some("@anthropic-ai/claude-code"));
        assert_eq!(env_key, "ANTHROPIC_API_KEY");
        assert!(matches!(output_mode, OutputMode::StreamJson));
        assert!(args.contains(&"--print".to_string()), "Missing --print flag");
        assert!(args.contains(&"--output-format".to_string()), "Missing --output-format flag");
        assert!(args.contains(&"stream-json".to_string()), "Missing stream-json value");
        assert!(args.contains(&"--verbose".to_string()), "Missing --verbose flag");
        assert!(args.contains(&"--include-partial-messages".to_string()), "Missing --include-partial-messages");
        // Prompt should be last arg
        assert_eq!(args.last().unwrap(), "do something");
    }

    #[test]
    fn codex_command_structure() {
        let (binary, npx, args, env_key, _, output_mode) = super::super::agent_command(
            &AgentType::Codex, "fix the bug", false, "", None,
        );
        assert_eq!(binary, "codex");
        assert_eq!(npx, Some("@openai/codex"));
        assert_eq!(env_key, "OPENAI_API_KEY");
        assert!(matches!(output_mode, OutputMode::Text));
        assert_eq!(args[0], "exec", "First arg must be 'exec' subcommand");
        assert!(args.contains(&"--skip-git-repo-check".to_string()), "Missing --skip-git-repo-check");
        assert_eq!(args.last().unwrap(), "fix the bug");
    }

    #[test]
    fn vibe_command_structure() {
        let (binary, npx, args, env_key, _, output_mode) = super::super::agent_command(
            &AgentType::Vibe, "analyse this", false, "", None,
        );
        assert_eq!(binary, "python3", "Vibe must use python3 with vibe-runner.py");
        assert_eq!(npx, None, "Vibe has no npx fallback");
        assert_eq!(env_key, "MISTRAL_API_KEY");
        assert!(matches!(output_mode, OutputMode::Text));
        // First arg should be the runner script path
        assert!(args[0].ends_with("vibe-runner.py"), "First arg must be vibe-runner.py, got: {}", args[0]);
        // Prompt should be the last arg
        assert_eq!(args.last().unwrap(), "analyse this");
    }

    #[test]
    fn gemini_command_structure() {
        let (binary, npx, args, env_key, _, output_mode) = super::super::agent_command(
            &AgentType::GeminiCli, "explain this", false, "", None,
        );
        assert_eq!(binary, "gemini");
        assert_eq!(npx, Some("@google/gemini-cli"));
        assert_eq!(env_key, "GEMINI_API_KEY");
        assert!(matches!(output_mode, OutputMode::Text));
        // -p must be just before the prompt (last two args), not first
        let p_idx = args.iter().position(|a| a == "-p").expect("-p flag must exist");
        assert_eq!(p_idx, args.len() - 2, "-p must be second-to-last arg (before prompt)");
        assert_eq!(args.last().unwrap(), "explain this");
    }

    #[test]
    fn kiro_command_structure() {
        let (binary, npx, args, env_key, _, output_mode) = super::super::agent_command(
            &AgentType::Kiro, "review code", false, "", None,
        );
        assert_eq!(binary, "kiro-cli");
        assert_eq!(npx, None, "Kiro has no npx fallback");
        assert_eq!(env_key, "AWS_BUILDER_ID");
        assert!(matches!(output_mode, OutputMode::Text));
        assert_eq!(args[0], "chat", "First arg must be 'chat' subcommand");
        assert!(args.contains(&"--no-interactive".to_string()), "Missing --no-interactive (required for headless)");
        assert!(args.contains(&"--trust-all-tools".to_string()), "Missing --trust-all-tools (required with --no-interactive)");
        assert!(args.contains(&"--wrap".to_string()), "Missing --wrap flag");
        assert!(args.contains(&"never".to_string()), "Missing 'never' wrap value");
        assert_eq!(args.last().unwrap(), "review code");
    }

    // ─── agent_command: model flag injection ────────────────────────────────────

    #[test]
    fn claude_code_model_flag_injected() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "prompt", false, "", Some("haiku"),
        );
        let idx = args.iter().position(|a| a == "--model").expect("Missing --model flag");
        assert_eq!(args[idx + 1], "haiku");
    }

    #[test]
    fn codex_model_flag_injected() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "prompt", false, "", Some("gpt-5-codex-mini"),
        );
        let idx = args.iter().position(|a| a == "--model").expect("Missing --model flag");
        assert_eq!(args[idx + 1], "gpt-5-codex-mini");
    }

    #[test]
    fn gemini_model_flag_injected() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::GeminiCli, "prompt", false, "", Some("gemini-2.5-flash"),
        );
        let idx = args.iter().position(|a| a == "--model").expect("Missing --model flag");
        assert_eq!(args[idx + 1], "gemini-2.5-flash");
    }

    #[test]
    fn vibe_model_flag_injected() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Vibe, "prompt", false, "", Some("devstral-small-latest"),
        );
        let idx = args.iter().position(|a| a == "--model").expect("Vibe should support --model via runner");
        assert_eq!(args[idx + 1], "devstral-small-latest");
    }

    #[test]
    fn kiro_no_model_flag_support() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Kiro, "prompt", false, "", Some("some-model"),
        );
        assert!(!args.contains(&"--model".to_string()),
            "Kiro should not have --model flag (not supported)");
    }

    // ─── Vibe runner path resolution ────────────────────────────────────────────

    #[test]
    fn vibe_runner_path_resolves_to_existing_file() {
        let path = super::super::vibe_runner_path();
        assert!(path.ends_with("vibe-runner.py"), "Path should end with vibe-runner.py, got: {}", path);
        assert!(std::path::Path::new(&path).exists(), "vibe-runner.py must exist at: {}", path);
    }

    // ─── get_api_key: Mistral provider support ──────────────────────────────────

    fn empty_tokens() -> crate::models::TokensConfig {
        crate::models::TokensConfig {
            anthropic: None, openai: None, google: None,
            keys: vec![], disabled_overrides: vec![],
        }
    }

    #[test]
    fn get_api_key_all_providers_no_panic() {
        let tokens = empty_tokens();
        // None of these should panic, all should return None with empty config
        for env_key in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "MISTRAL_API_KEY", "UNKNOWN_KEY"] {
            let _ = super::super::get_api_key(env_key, &tokens);
        }
    }

    #[test]
    fn get_api_key_unknown_provider_returns_none() {
        let tokens = empty_tokens();
        assert_eq!(super::super::get_api_key("UNKNOWN_KEY", &tokens), None);
    }

    #[test]
    fn get_api_key_returns_active_key_per_provider() {
        use crate::models::ApiKey;

        let cases = [
            ("ANTHROPIC_API_KEY", "anthropic", "sk-ant-test-123"),
            ("OPENAI_API_KEY",    "openai",    "sk-openai-test-456"),
            ("GEMINI_API_KEY",    "google",    "AIza-gemini-test-789"),
            ("MISTRAL_API_KEY",   "mistral",   "mist-test-abc"),
        ];

        for (env_key, provider, value) in cases {
            let mut tokens = empty_tokens();
            tokens.keys.push(ApiKey {
                id: format!("k-{}", provider),
                name: "test".into(),
                provider: provider.into(),
                value: value.into(),
                active: true,
            });
            let key = super::super::get_api_key(env_key, &tokens);
            assert_eq!(key, Some(value.to_string()),
                "get_api_key({}) should return the active {} key", env_key, provider);
        }
    }

    #[test]
    fn get_api_key_inactive_key_not_returned() {
        use crate::models::ApiKey;
        let mut tokens = empty_tokens();
        tokens.keys.push(ApiKey {
            id: "k1".into(), name: "old".into(),
            provider: "anthropic".into(), value: "sk-inactive".into(),
            active: false,
        });
        // No active key → should fall back to env var (which is unset in tests)
        let key = super::super::get_api_key("ANTHROPIC_API_KEY", &tokens);
        assert_ne!(key, Some("sk-inactive".to_string()),
            "Inactive key should NOT be returned");
    }

    #[test]
    fn get_api_key_disabled_override_skips_config() {
        use crate::models::ApiKey;
        let mut tokens = empty_tokens();
        tokens.keys.push(ApiKey {
            id: "k1".into(), name: "test".into(),
            provider: "openai".into(), value: "sk-from-config".into(),
            active: true,
        });
        tokens.disabled_overrides.push("openai".into());
        // Override disabled → should NOT use config key, falls back to env
        let key = super::super::get_api_key("OPENAI_API_KEY", &tokens);
        assert_ne!(key, Some("sk-from-config".to_string()),
            "Disabled override should skip config key");
    }

    #[test]
    fn get_api_key_picks_active_among_multiple() {
        use crate::models::ApiKey;
        let mut tokens = empty_tokens();
        tokens.keys.push(ApiKey {
            id: "k1".into(), name: "personal".into(),
            provider: "google".into(), value: "AIza-personal".into(),
            active: false,
        });
        tokens.keys.push(ApiKey {
            id: "k2".into(), name: "work".into(),
            provider: "google".into(), value: "AIza-work".into(),
            active: true,
        });
        let key = super::super::get_api_key("GEMINI_API_KEY", &tokens);
        assert_eq!(key, Some("AIza-work".to_string()),
            "Should pick the active key among multiple for same provider");
    }

    // ─── agent_command: context injection per agent ─────────────────────────────

    #[test]
    fn vibe_prepends_mcp_context_to_prompt() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Vibe, "user prompt", false, "MCP context here", None,
        );
        // vibe-runner.py uses the real Vibe SDK which supports tools,
        // so MCP context is prepended to the prompt (same as Codex/Gemini/Kiro)
        let prompt = args.last().unwrap();
        assert!(prompt.contains("MCP context here"), "MCP context should be prepended");
        assert!(prompt.contains("user prompt"), "User prompt should be present");
    }

    #[test]
    fn gemini_prepends_context_to_prompt() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::GeminiCli, "user prompt", false, "MCP context", None,
        );
        let prompt = args.last().unwrap();
        assert!(prompt.starts_with("MCP context"), "Context should be prepended");
        assert!(prompt.contains("user prompt"), "Original prompt should be present");
    }

    #[test]
    fn kiro_prepends_context_to_prompt() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Kiro, "user prompt", false, "MCP context", None,
        );
        let prompt = args.last().unwrap();
        assert!(prompt.starts_with("MCP context"), "Context should be prepended");
        assert!(prompt.contains("user prompt"), "Original prompt should be present");
    }

    // ─── resolve_model_flag: tier mapping per agent ─────────────────────────────

    #[test]
    fn resolve_model_flag_claude_code_tiers() {
        use crate::models::ModelTier;
        assert_eq!(resolve_model_flag(&AgentType::ClaudeCode, ModelTier::Economy, None), Some("haiku".into()));
        assert_eq!(resolve_model_flag(&AgentType::ClaudeCode, ModelTier::Default, None), Some("sonnet".into()));
        assert_eq!(resolve_model_flag(&AgentType::ClaudeCode, ModelTier::Reasoning, None), Some("opus".into()));
    }

    #[test]
    fn resolve_model_flag_codex_tiers() {
        use crate::models::ModelTier;
        assert_eq!(resolve_model_flag(&AgentType::Codex, ModelTier::Economy, None), Some("gpt-5-codex-mini".into()));
        assert_eq!(resolve_model_flag(&AgentType::Codex, ModelTier::Default, None), None);
        assert_eq!(resolve_model_flag(&AgentType::Codex, ModelTier::Reasoning, None), Some("gpt-5.4".into()));
    }

    #[test]
    fn resolve_model_flag_gemini_tiers() {
        use crate::models::ModelTier;
        assert_eq!(resolve_model_flag(&AgentType::GeminiCli, ModelTier::Economy, None), Some("gemini-2.5-flash".into()));
        assert_eq!(resolve_model_flag(&AgentType::GeminiCli, ModelTier::Default, None), None);
        assert_eq!(resolve_model_flag(&AgentType::GeminiCli, ModelTier::Reasoning, None), Some("gemini-3.1-pro-preview".into()));
    }

    #[test]
    fn resolve_model_flag_kiro_vibe_always_none() {
        use crate::models::ModelTier;
        for tier in [ModelTier::Economy, ModelTier::Default, ModelTier::Reasoning] {
            assert_eq!(resolve_model_flag(&AgentType::Kiro, tier, None), None,
                "Kiro should return None for all tiers (no --model support)");
            assert_eq!(resolve_model_flag(&AgentType::Vibe, tier, None), None,
                "Vibe should return None for all tiers (no --model support)");
        }
    }

    #[test]
    fn resolve_model_flag_user_override_takes_precedence() {
        use crate::models::{ModelTier, ModelTiersConfig, ModelTierConfig};
        let overrides = ModelTiersConfig {
            claude_code: ModelTierConfig {
                economy: Some("custom-haiku-3".into()),
                default: None,
                reasoning: None,
            },
            ..Default::default()
        };
        assert_eq!(
            resolve_model_flag(&AgentType::ClaudeCode, ModelTier::Economy, Some(&overrides)),
            Some("custom-haiku-3".into()),
            "User override should take precedence over built-in"
        );
        // Reasoning has no override → falls back to built-in
        assert_eq!(
            resolve_model_flag(&AgentType::ClaudeCode, ModelTier::Reasoning, Some(&overrides)),
            Some("opus".into()),
        );
    }

    #[test]
    fn resolve_model_flag_default_tier_honors_user_override() {
        // New 2026-05-11: the Default tier now reads `agent_cfg.default`
        // before falling through to the built-in match. Primary use case
        // = Ollama user picks `gemma3:27b` from the OllamaCard, that
        // value overrides the hardcoded `llama3.2` fallback.
        use crate::models::{ModelTier, ModelTiersConfig, ModelTierConfig};
        let overrides = ModelTiersConfig {
            ollama: ModelTierConfig {
                economy: None,
                default: Some("gemma3:27b".into()),
                reasoning: None,
            },
            ..Default::default()
        };
        assert_eq!(
            resolve_model_flag(&AgentType::Ollama, ModelTier::Default, Some(&overrides)),
            Some("gemma3:27b".into()),
            "Default-tier user override must win over built-in `llama3.2`",
        );
        // Without an override, the legacy built-in is still served.
        let no_override = ModelTiersConfig::default();
        assert_eq!(
            resolve_model_flag(&AgentType::Ollama, ModelTier::Default, Some(&no_override)),
            Some("llama3.2".into()),
            "No override → legacy built-in default applies",
        );
    }

    // ─── Claude Code: prompt is always last arg (required for --mcp-config injection)

    #[test]
    fn claude_code_prompt_is_last_arg() {
        // start_agent_with_config inserts --mcp-config before the prompt (last arg).
        // This test ensures prompt remains the last arg so that insertion works.
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "my prompt", true, "context", Some("sonnet"),
        );
        assert_eq!(args.last().unwrap(), "my prompt",
            "Prompt must be the last arg for --mcp-config injection to work");
    }

    #[test]
    fn claude_code_prompt_is_last_even_with_context() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "do stuff", false, "MCP servers info", None,
        );
        assert_eq!(args.last().unwrap(), "do stuff");
        // --append-system-prompt should be before the prompt
        let sys_idx = args.iter().position(|a| a == "--append-system-prompt").unwrap();
        assert!(sys_idx < args.len() - 1, "--append-system-prompt must come before prompt");
    }

    // ─── --mcp-config insertion order ─────────────────────────────────────────

    #[test]
    fn mcp_config_inserted_before_append_system_prompt() {
        // Simulates what start_agent_with_config does: insert --mcp-config
        // before --append-system-prompt and its value.
        let (_, _, mut args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "the prompt", false, "MCP context", None,
        );

        // Simulate the --mcp-config insertion logic from start_agent_with_config
        let prompt_arg = args.pop();
        let sys_prompt_val = if args.last().map(|a| !a.starts_with("--")).unwrap_or(false) {
            let val = args.pop();
            let flag = args.pop();
            Some((flag, val))
        } else {
            None
        };
        args.push("--mcp-config".into());
        args.push("/path/to/.mcp.json".into());
        if let Some((flag, val)) = sys_prompt_val {
            if let Some(f) = flag { args.push(f); }
            if let Some(v) = val { args.push(v); }
        }
        if let Some(p) = prompt_arg { args.push(p); }

        // Verify order: --mcp-config must come BEFORE --append-system-prompt
        let mcp_idx = args.iter().position(|a| a == "--mcp-config").unwrap();
        let sys_idx = args.iter().position(|a| a == "--append-system-prompt").unwrap();
        assert!(mcp_idx < sys_idx,
            "--mcp-config ({}) must come before --append-system-prompt ({}) to avoid arg parsing issues. Args: {:?}",
            mcp_idx, sys_idx, args);

        // Prompt must still be last
        assert_eq!(args.last().unwrap(), "the prompt");
    }

    #[test]
    fn mcp_config_works_without_append_system_prompt() {
        // When there's no MCP context, --append-system-prompt is absent
        let (_, _, mut args, _, _, _) = super::super::agent_command(
            &AgentType::ClaudeCode, "the prompt", false, "", None,
        );

        // Simulate --mcp-config insertion
        let prompt_arg = args.pop();
        let sys_prompt_val = if args.last().map(|a| !a.starts_with("--")).unwrap_or(false) {
            let val = args.pop();
            let flag = args.pop();
            Some((flag, val))
        } else {
            None
        };
        args.push("--mcp-config".into());
        args.push("/path/to/.mcp.json".into());
        if let Some((flag, val)) = sys_prompt_val {
            if let Some(f) = flag { args.push(f); }
            if let Some(v) = val { args.push(v); }
        }
        if let Some(p) = prompt_arg { args.push(p); }

        assert!(args.contains(&"--mcp-config".to_string()));
        assert_eq!(args.last().unwrap(), "the prompt");
        // No --append-system-prompt should be present
        assert!(!args.contains(&"--append-system-prompt".to_string()));
    }

    // ─── ensure_kiro_cli_available ─────────────────────────────────────────────

    #[tokio::test]
    async fn ensure_kiro_cli_skips_install_when_present() {
        // kiro-cli is available on this machine (mounted from host)
        // ensure_kiro_cli_available should return Ok immediately
        let result = super::super::ensure_kiro_cli_available().await;
        // On CI/dev where kiro-cli may not exist, this is allowed to fail
        // The important thing is it doesn't panic
        let _ = result;
    }

    // ─── agent_command: Codex sandbox behavior ──────────────────────────────────

    #[test]
    #[serial]
    fn codex_docker_sandbox_workspace_write() {
        std::env::set_var("KRONN_HOST_HOME", "/home/testuser");
        std::env::set_var("KRONN_HOST_OS", "Linux");
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "prompt", false, "", None,
        );
        assert!(args.contains(&"--sandbox=workspace-write".to_string()),
            "Linux Docker + no full_access should use workspace-write sandbox");
        std::env::remove_var("KRONN_HOST_HOME");
        std::env::remove_var("KRONN_HOST_OS");
    }

    #[test]
    #[serial]
    fn codex_macos_docker_forces_full_access_sandbox() {
        std::env::set_var("KRONN_HOST_HOME", "/Users/testuser");
        std::env::set_var("KRONN_HOST_OS", "macOS");
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "prompt", false, "", None,
        );
        assert!(args.contains(&"--sandbox=danger-full-access".to_string()),
            "macOS Docker should force danger-full-access sandbox regardless of full_access flag");
        std::env::remove_var("KRONN_HOST_HOME");
        std::env::remove_var("KRONN_HOST_OS");
    }

    #[test]
    #[serial]
    fn codex_full_access_uses_danger_sandbox() {
        std::env::set_var("KRONN_HOST_HOME", "/home/testuser");
        std::env::set_var("KRONN_HOST_OS", "Linux");
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::Codex, "prompt", true, "", None,
        );
        assert!(args.contains(&"--sandbox=danger-full-access".to_string()),
            "full_access=true should use danger-full-access sandbox");
        std::env::remove_var("KRONN_HOST_HOME");
        std::env::remove_var("KRONN_HOST_OS");
    }

    // ─── StreamJsonEvent: ToolStart / ToolInputDelta / ToolEnd ──────────────

    #[test]
    fn parse_stream_tool_start() {
        let line = r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01","name":"Read","input":{}}}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::ToolStart(name) => assert_eq!(name, "Read"),
            other => panic!("Expected ToolStart, got {:?}", other),
        }
    }

    #[test]
    fn parse_stream_tool_input_delta() {
        let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\":\"/src"}}}"#;
        match parse_claude_stream_line(line) {
            StreamJsonEvent::ToolInputDelta(partial) => {
                assert!(partial.contains("file_path"), "Should contain partial JSON, got: {}", partial);
            }
            other => panic!("Expected ToolInputDelta, got {:?}", other),
        }
    }

    #[test]
    fn parse_stream_tool_end() {
        let line = r#"{"type":"stream_event","event":{"type":"content_block_stop","index":1}}"#;
        assert!(matches!(parse_claude_stream_line(line), StreamJsonEvent::ToolEnd));
    }

    // ─── CopilotCli agent_command ─────────────────────────────────────────────

    #[test]
    fn copilot_agent_command_basic() {
        let (bin, npx, args, env_key, _, _) = super::super::agent_command(
            &AgentType::CopilotCli, "hello", false, "", None,
        );
        assert_eq!(bin, "copilot");
        assert_eq!(npx, Some("@github/copilot"));
        assert_eq!(env_key, "GH_TOKEN");
        assert!(args.contains(&"-p".to_string()), "Should have -p flag");
        assert!(args.contains(&"hello".to_string()), "Should contain prompt");
        assert!(!args.contains(&"--allow-all-tools".to_string()), "Should not have full_access flag");
    }

    #[test]
    fn copilot_agent_command_full_access() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::CopilotCli, "hello", true, "", None,
        );
        assert!(args.contains(&"--allow-all-tools".to_string()), "Full access should add --allow-all-tools");
    }

    #[test]
    fn copilot_agent_command_with_model() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::CopilotCli, "hello", false, "", Some("gpt-4o-mini"),
        );
        assert!(args.contains(&"--model".to_string()));
        assert!(args.contains(&"gpt-4o-mini".to_string()));
    }

    #[test]
    fn copilot_agent_command_with_mcp_context() {
        let (_, _, args, _, _, _) = super::super::agent_command(
            &AgentType::CopilotCli, "hello", false, "MCP context here", None,
        );
        // MCP context should be prepended to prompt (no --append-system-prompt for Copilot)
        let prompt = args.last().unwrap();
        assert!(prompt.contains("MCP context here"), "Prompt should contain MCP context");
        assert!(prompt.contains("hello"), "Prompt should contain user prompt");
    }

    // ─── Cross-platform: model tier resolution ─────────────────────────────

    #[test]
    fn copilot_model_tiers() {
        let economy = super::super::resolve_model_flag(&AgentType::CopilotCli, ModelTier::Economy, None);
        assert_eq!(economy, Some("gpt-4o-mini".into()));
        let default = super::super::resolve_model_flag(&AgentType::CopilotCli, ModelTier::Default, None);
        assert_eq!(default, None); // Use Copilot's default
        let reasoning = super::super::resolve_model_flag(&AgentType::CopilotCli, ModelTier::Reasoning, None);
        assert_eq!(reasoning, Some("o4-mini".into()));
    }

    // ─── Cross-platform: is_wsl detection ───────────────────────────────────

    #[test]
    fn is_wsl_returns_bool() {
        // Just a smoke test — actual result depends on platform
        let _ = super::super::is_wsl();
    }

    // ─── HOME override skip policy (TD-20260507-home-override) ─────────────

    #[test]
    fn skip_home_override_for_known_cli_agent_binaries() {
        // Every Kronn-managed CLI agent has its config at /home/kronn/<agent>
        // (via docker-compose mounts) — overriding HOME to KRONN_HOST_HOME
        // would route them to /home/<host-user>/<agent> which doesn't exist
        // in the container. Each agent must be in the skip list.
        for binary in ["claude", "codex", "vibe", "gemini", "kiro-cli", "copilot"] {
            assert!(
                should_skip_home_override(binary, None),
                "binary {} must skip the HOME override",
                binary
            );
        }
    }

    #[test]
    fn skip_home_override_for_npx_packaged_agents() {
        // When agents are launched via `npx <pkg>`, the binary name is
        // "npx" — we identify them by the npx_package field instead.
        for pkg in [
            "@anthropic-ai/claude-code",
            "@openai/codex",
            "@google/gemini-cli",
            "@github/copilot",
        ] {
            assert!(
                should_skip_home_override("npx", Some(pkg)),
                "npx package {} must skip the HOME override",
                pkg
            );
        }
    }

    #[test]
    fn skip_home_override_keeps_override_for_unknown_binaries() {
        // Arbitrary tools the operator runs through Kronn need a host-rooted
        // HOME (e.g. config files in $HOME the operator expects to be the
        // host's). Don't strip the override for them.
        assert!(!should_skip_home_override("python", None));
        assert!(!should_skip_home_override("rg", None));
        assert!(!should_skip_home_override("custom-tool", None));
        // Unknown npx package: also keep override.
        assert!(!should_skip_home_override("npx", Some("@some/random-pkg")));
    }

    #[test]
    fn skip_home_override_ollama_keeps_override() {
        // Ollama is a local HTTP server, not a CLI agent reading $HOME/.ollama
        // for auth. It's not in the skip list — let the override stand to
        // match the historical behaviour.
        assert!(!should_skip_home_override("ollama", None));
    }
}
