# -*- coding: utf-8 -*-
"""
Tests for agent-mode pipeline integration.

Covers:
- Config: agent_mode, agent_max_steps, agent_skills fields
- _analyze_with_agent method
- _agent_result_to_analysis_result conversion
- YAML strategy loading (load_builtin_strategies)
"""

import json
import importlib
import types
import unittest
import sys
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch, PropertyMock
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any

from tests.litellm_stub import ensure_litellm_stub

ensure_litellm_stub()

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))


def _builtin_strategy_names() -> set[str]:
    strategies_dir = Path(__file__).resolve().parent.parent / "strategies"
    return {path.stem for path in strategies_dir.glob("*.yaml")}


# ============================================================
# Config tests
# ============================================================

class TestAgentConfig(unittest.TestCase):
    """Test agent-related configuration fields load correctly."""

    @patch.dict(os.environ, {}, clear=True)
    @patch('src.config.load_dotenv')
    def test_default_agent_config(self, _mock_dotenv):
        """Agent mode should be disabled by default."""
        from src.config import AGENT_MAX_STEPS_DEFAULT, Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertEqual(config.agent_litellm_model, "")
        self.assertFalse(config.agent_mode)
        self.assertEqual(config.agent_max_steps, AGENT_MAX_STEPS_DEFAULT)
        self.assertEqual(config.agent_skills, [])

    @patch.dict(os.environ, {
        'AGENT_MODE': 'true',
        'AGENT_MAX_STEPS': '15',
        'AGENT_SKILLS': 'dragon_head,shrink_pullback,volume_breakout',
    }, clear=True)
    def test_agent_config_from_env(self):
        """Agent config should be loaded from environment."""
        from src.config import Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertTrue(config.agent_mode)
        self.assertEqual(config.agent_max_steps, 15)
        self.assertEqual(config.agent_skills, ['dragon_head', 'shrink_pullback', 'volume_breakout'])

    @patch.dict(os.environ, {'AGENT_MODE': 'false'}, clear=True)
    def test_agent_mode_disabled(self):
        """Explicitly disabled agent mode."""
        from src.config import Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertFalse(config.agent_mode)

    @patch.dict(os.environ, {'AGENT_SKILLS': ''}, clear=True)
    def test_empty_skills_list(self):
        """Empty AGENT_SKILLS should produce empty list."""
        from src.config import Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertEqual(config.agent_skills, [])

    @patch.dict(os.environ, {'AGENT_SKILLS': '  dragon_head , shrink_pullback  '}, clear=True)
    def test_skills_whitespace_handling(self):
        """Skills should have whitespace trimmed."""
        from src.config import Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertEqual(config.agent_skills, ['dragon_head', 'shrink_pullback'])

    @patch.dict(os.environ, {'AGENT_LITELLM_MODEL': 'gpt-4o-mini'}, clear=True)
    def test_agent_is_available_when_agent_primary_model_is_configured(self):
        """Agent availability auto-detection should use effective Agent primary model."""
        from src.config import Config
        Config._instance = None
        config = Config._load_from_env()
        self.assertEqual(config.agent_litellm_model, 'openai/gpt-4o-mini')
        self.assertTrue(config.is_agent_available())

    def test_agent_models_to_try_inherit_legacy_provider_models(self):
        """Legacy provider key/model envs should still produce a non-empty Agent model try list."""
        from src.config import Config, get_effective_agent_models_to_try

        test_cases = [
            (
                {
                    "GEMINI_API_KEY": "gemini-test-key",
                    "GEMINI_MODEL": "gemini-2.5-flash",
                    "AGENT_LITELLM_MODEL": "",
                },
                ["gemini/gemini-2.5-flash", "gemini/gemini-3-flash-preview"],
            ),
            (
                {
                    "OPENAI_API_KEY": "sk-test-value",
                    "OPENAI_MODEL": "gpt-4o-mini",
                    "AGENT_LITELLM_MODEL": "",
                },
                ["openai/gpt-4o-mini"],
            ),
            (
                {
                    "ANTHROPIC_API_KEY": "anthropic-test-key",
                    "ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022",
                    "AGENT_LITELLM_MODEL": "",
                },
                ["anthropic/claude-3-5-sonnet-20241022"],
            ),
        ]

        with patch("src.config.setup_env"), patch.object(Config, "_parse_litellm_yaml", return_value=[]):
            for env, expected_models in test_cases:
                with self.subTest(expected_models=expected_models), patch.dict(os.environ, env, clear=True):
                    Config._instance = None
                    config = Config._load_from_env()
                    self.assertEqual(get_effective_agent_models_to_try(config), expected_models)

        Config._instance = None

    def test_build_agent_executor_does_not_mutate_llm_route_config(self) -> None:
        """Agent factory should not rewrite model/base_url/runtime routing fields."""
        provided_config = SimpleNamespace(
            agent_arch="single",
            agent_skills=["bull_trend"],
            agent_max_steps="10",
            agent_orchestrator_timeout_s="120",
            litellm_model="openai/gpt-5",
            agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
            openai_base_url="https://api.openai.com/v1",
        )
        captured: Dict[str, Any] = {}

        def _mock_llm_adapter(cfg):
            captured["cfg"] = cfg
            return MagicMock()

        fake_llm_module = types.ModuleType("src.agent.llm_adapter")
        fake_llm_module.LLMToolAdapter = _mock_llm_adapter

        fake_executor_module = types.ModuleType("src.agent.executor")
        fake_executor_cls = MagicMock(return_value=MagicMock())
        fake_executor_module.AgentExecutor = fake_executor_cls

        skill_manager = MagicMock()
        skill_manager.list_skills.return_value = [
            SimpleNamespace(
                name="bull_trend",
                display_name="bull_trend",
                description="bull_trend desc",
                instructions="测试指令",
                default_active=True,
                default_router=True,
                default_priority=100,
                user_invocable=True,
                source="builtin",
            )
        ]
        skill_manager.get_skill_instructions.return_value = "测试指令"

        with patch.dict(sys.modules, {
            "litellm": MagicMock(),
            "src.agent.llm_adapter": fake_llm_module,
            "src.agent.executor": fake_executor_module,
        }):
            factory_module = importlib.import_module("src.agent.factory")
            with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
                 patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
                factory_module.build_agent_executor(provided_config)

        adapter_cfg = captured.get("cfg")
        self.assertIs(adapter_cfg, provided_config)
        self.assertEqual(provided_config.agent_max_steps, "10")
        self.assertEqual(provided_config.agent_orchestrator_timeout_s, "120")
        self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
        self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
        self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")
        fake_executor_cls.assert_called_once()
        kwargs = fake_executor_cls.call_args.kwargs
        self.assertEqual(kwargs["max_steps"], 10)
        self.assertEqual(kwargs["timeout_seconds"], 120)

    def test_build_agent_executor_multi_arch_does_not_mutate_llm_route_config(self) -> None:
        """Multi-arch path should keep provider/base_url/runtime fields unchanged."""
        provided_config = SimpleNamespace(
            agent_arch="multi",
            agent_skills=["bull_trend"],
            agent_max_steps="10",
            agent_orchestrator_timeout_s="120",
            litellm_model="openai/gpt-5",
            agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
            openai_base_url="https://api.openai.com/v1",
            agent_orchestrator_mode="standard",
        )
        captured: Dict[str, Any] = {}

        def _mock_llm_adapter(cfg):
            captured["cfg"] = cfg
            return MagicMock()

        fake_llm_module = types.ModuleType("src.agent.llm_adapter")
        fake_llm_module.LLMToolAdapter = _mock_llm_adapter

        fake_orchestrator_module = types.ModuleType("src.agent.orchestrator")
        fake_orchestrator_cls = MagicMock(return_value=MagicMock())
        fake_orchestrator_module.AgentOrchestrator = fake_orchestrator_cls

        skill_manager = MagicMock()
        skill_manager.list_skills.return_value = [
            SimpleNamespace(
                name="bull_trend",
                display_name="bull_trend",
                description="bull_trend desc",
                instructions="测试指令",
                default_active=True,
                default_router=True,
                default_priority=100,
                user_invocable=True,
                source="builtin",
            )
        ]
        skill_manager.get_skill_instructions.return_value = "测试指令"

        with patch.dict(sys.modules, {
            "litellm": MagicMock(),
            "src.agent.llm_adapter": fake_llm_module,
            "src.agent.orchestrator": fake_orchestrator_module,
            "src.agent.executor": MagicMock(),
        }):
            factory_module = importlib.import_module("src.agent.factory")
            with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
                 patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
                factory_module.build_agent_executor(provided_config)

        adapter_cfg = captured.get("cfg")
        self.assertIs(adapter_cfg, provided_config)
        self.assertEqual(provided_config.agent_max_steps, "10")
        self.assertEqual(provided_config.agent_orchestrator_timeout_s, "120")
        self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
        self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
        self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")
        fake_orchestrator_cls.assert_called_once()
        kwargs = fake_orchestrator_cls.call_args.kwargs
        self.assertEqual(kwargs["max_steps"], 10)
        self.assertIs(kwargs["config"], provided_config)

    def test_invalid_numeric_config_values_fallback_to_defaults_with_warning(self) -> None:
        """Invalid agent_max_steps / agent_orchestrator_timeout_s should fallback and emit warning."""
        provided_config = SimpleNamespace(
            agent_arch="single",
            agent_skills=["bull_trend"],
            agent_max_steps="invalid-steps",
            agent_orchestrator_timeout_s="invalid-timeout",
            litellm_model="openai/gpt-5",
            agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
            openai_base_url="https://api.openai.com/v1",
        )
        captured: Dict[str, Any] = {}

        def _mock_llm_adapter(cfg):
            captured["cfg"] = cfg
            return MagicMock()

        fake_llm_module = types.ModuleType("src.agent.llm_adapter")
        fake_llm_module.LLMToolAdapter = _mock_llm_adapter

        fake_executor_module = types.ModuleType("src.agent.executor")
        fake_executor_cls = MagicMock(return_value=MagicMock())
        fake_executor_module.AgentExecutor = fake_executor_cls

        skill_manager = MagicMock()
        skill_manager.list_skills.return_value = [
            SimpleNamespace(
                name="bull_trend",
                display_name="bull_trend",
                description="bull_trend desc",
                instructions="测试指令",
                default_active=True,
                default_router=True,
                default_priority=100,
                user_invocable=True,
                source="builtin",
            )
        ]
        skill_manager.get_skill_instructions.return_value = "测试指令"

        with self.assertLogs("src.agent.factory", level="WARNING") as logs:
            with patch.dict(sys.modules, {
                "litellm": MagicMock(),
                "src.agent.llm_adapter": fake_llm_module,
                "src.agent.executor": fake_executor_module,
            }):
                factory_module = importlib.import_module("src.agent.factory")
                with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
                     patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
                    factory_module.build_agent_executor(provided_config)

        adapter_cfg = captured.get("cfg")
        self.assertIs(adapter_cfg, provided_config)
        self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
        self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
        self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")

        log_output = "\n".join(logs.output)
        self.assertIn("[AgentFactory] Invalid value for agent_max_steps", log_output)
        self.assertIn("[AgentFactory] Invalid value for agent_orchestrator_timeout_s", log_output)

        kwargs = fake_executor_cls.call_args.kwargs
        from src.config import AGENT_MAX_STEPS_DEFAULT
        self.assertEqual(kwargs["max_steps"], AGENT_MAX_STEPS_DEFAULT)
        self.assertEqual(kwargs["timeout_seconds"], 0)


class TestAgentFactorySkillBaseline(unittest.TestCase):
    """Ensure explicit skill selection does not silently re-apply the default bull-trend baseline."""

    @staticmethod
    def _make_skill(
        name: str,
        *,
        default_active: bool = False,
        default_priority: int = 100,
        source: str = "builtin",
    ):
        return SimpleNamespace(
            name=name,
            display_name=name,
            description=f"{name} desc",
            instructions=f"{name} instructions",
            default_active=default_active,
            default_router=default_active,
            default_priority=default_priority,
            user_invocable=True,
            source=source,
        )

    def _run_factory_case(self, config, *, request_skills, skill_catalog, instructions):
        skill_manager = MagicMock()
        skill_manager.list_skills.return_value = skill_catalog
        skill_manager.get_skill_instructions.return_value = instructions

        fake_llm_module = types.ModuleType("src.agent.llm_adapter")
        fake_llm_module.LLMToolAdapter = MagicMock(return_value=MagicMock())
        fake_executor_module = types.ModuleType("src.agent.executor")
        fake_executor_cls = MagicMock(return_value=MagicMock())
        fake_executor_module.AgentExecutor = fake_executor_cls

        with patch.dict(sys.modules, {
            "litellm": MagicMock(),
            "src.agent.llm_adapter": fake_llm_module,
            "src.agent.executor": fake_executor_module,
        }):
            factory_module = importlib.import_module("src.agent.factory")

            with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
                 patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
                factory_module.build_agent_executor(config, skills=request_skills)

        return fake_executor_cls.call_args.kwargs, skill_manager

    def test_explicit_request_disables_default_skill_policy(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=["chan_theory"],
            skill_catalog=[
                self._make_skill("bull_trend", default_active=True, default_priority=10),
                self._make_skill("chan_theory", default_priority=20),
            ],
            instructions="chan_theory instructions",
        )

        self.assertEqual(kwargs["default_skill_policy"], "")
        self.assertFalse(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["chan_theory"])

    def test_configured_skills_disable_default_skill_policy(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=["wave_theory"],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=None,
            skill_catalog=[
                self._make_skill("bull_trend", default_active=True, default_priority=10),
                self._make_skill("wave_theory", default_priority=20),
            ],
            instructions="wave_theory instructions",
        )

        self.assertEqual(kwargs["default_skill_policy"], "")
        self.assertFalse(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["wave_theory"])

    def test_implicit_default_run_keeps_default_skill_policy(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=None,
            skill_catalog=[self._make_skill("bull_trend", default_active=True, default_priority=10)],
            instructions="bull_trend instructions",
        )

        self.assertIn("严进策略", kwargs["default_skill_policy"])
        self.assertTrue(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["bull_trend"])

    def test_explicit_empty_request_falls_back_to_primary_default_skill(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=[],
            skill_catalog=[
                self._make_skill("bull_trend", default_active=True, default_priority=10),
                self._make_skill("chan_theory", default_priority=20),
            ],
            instructions="bull_trend instructions",
        )

        self.assertIn("严进策略", kwargs["default_skill_policy"])
        self.assertTrue(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["bull_trend"])

    def test_explicit_primary_default_skill_uses_skill_aware_prompt_mode(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=["bull_trend"],
            skill_catalog=[
                self._make_skill("bull_trend", default_active=True, default_priority=10),
                self._make_skill("chan_theory", default_priority=20),
            ],
            instructions="bull_trend instructions",
        )

        self.assertEqual(kwargs["default_skill_policy"], "")
        self.assertFalse(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["bull_trend"])

    def test_invalid_configured_skills_fall_back_to_primary_default_skill(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=["missing_skill"],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=None,
            skill_catalog=[
                self._make_skill("bull_trend", default_active=True, default_priority=10),
                self._make_skill("chan_theory", default_priority=20),
            ],
            instructions="bull_trend instructions",
        )

        self.assertIn("严进策略", kwargs["default_skill_policy"])
        self.assertTrue(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["bull_trend"])

    def test_custom_default_skill_does_not_use_legacy_bull_prompt(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=None,
            skill_catalog=[
                self._make_skill("custom_default", default_active=True, default_priority=10),
                self._make_skill("bull_trend", default_priority=20),
            ],
            instructions="custom_default instructions",
        )

        self.assertEqual(kwargs["default_skill_policy"], "")
        self.assertFalse(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["custom_default"])

    def test_custom_bull_trend_override_does_not_use_legacy_prompt(self):
        config = SimpleNamespace(
            agent_arch="single",
            agent_skills=[],
            agent_max_steps=10,
            agent_orchestrator_timeout_s=600,
        )
        kwargs, skill_manager = self._run_factory_case(
            config,
            request_skills=None,
            skill_catalog=[
                self._make_skill(
                    "bull_trend",
                    default_active=True,
                    default_priority=10,
                    source="/tmp/custom-skills/bull_trend.yaml",
                ),
            ],
            instructions="custom bull_trend instructions",
        )

        self.assertEqual(kwargs["default_skill_policy"], "")
        self.assertFalse(kwargs["use_legacy_default_prompt"])
        skill_manager.activate.assert_called_once_with(["bull_trend"])


# ============================================================
# AgentResult to AnalysisResult conversion
# ============================================================

class TestAgentResultConversion(unittest.TestCase):
    """Test _agent_result_to_analysis_result without spinning up the full pipeline."""

    def _make_pipeline(self):
        """Create a minimal StockAnalysisPipeline with mocked dependencies."""
        # We need to import and mock carefully to avoid touching real services
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'):

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_orchestrator_timeout_s = 0
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            pipeline = StockAnalysisPipeline(config=mock_cfg)
            return pipeline

    def test_convert_success_dashboard(self):
        """Successful AgentResult should produce a valid AnalysisResult."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        dashboard = {
            "stock_name": "贵州茅台",
            "sentiment_score": 80,
            "trend_prediction": "看多",
            "operation_advice": "持有",
            "decision_type": "hold",
            "confidence_level": "高",
            "dashboard": {"core_conclusion": {"one_sentence": "看好"}},
            "analysis_summary": "Testing",
            "key_points": "Strong",
            "risk_warning": "High valuation",
            "buy_reason": "Leader",
            "trend_analysis": "Upward",
            "technical_analysis": "Bullish MACD",
            "ma_analysis": "Golden cross",
            "volume_analysis": "Healthy volume",
            "pattern_analysis": "Cup and handle",
            "fundamental_analysis": "Strong revenue",
            "sector_position": "Liquor leader",
            "company_highlights": "Brand value",
            "news_summary": "Recent news",
            "market_sentiment": "Optimistic",
            "hot_topics": "Baijiu",
            "short_term_outlook": "Bullish",
            "medium_term_outlook": "Stable",
        }

        agent_result = AgentResult(
            success=True,
            content=json.dumps(dashboard),
            dashboard=dashboard,
            tool_calls_log=[{"step": 1, "tool": "echo", "success": True}],
            total_steps=3,
            total_tokens=500,
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q123"
        )

        self.assertIsNotNone(result)
        self.assertTrue(result.success)
        self.assertEqual(result.code, "600519")
        self.assertEqual(result.name, "贵州茅台")
        self.assertEqual(result.sentiment_score, 80)
        self.assertEqual(result.trend_prediction, "看多")
        self.assertEqual(result.decision_type, "hold")
        self.assertIn("agent:gemini", result.data_sources)
        self.assertIsNotNone(result.dashboard)

    def test_convert_failed_dashboard(self):
        """Failed AgentResult should produce a minimal AnalysisResult."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=False,
            content="",
            dashboard=None,
            error="Max steps exceeded",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q123"
        )

        self.assertIsNotNone(result)
        self.assertFalse(result.success)
        self.assertEqual(result.sentiment_score, 50)
        self.assertEqual(result.operation_advice, "观望")
        self.assertIn("Max steps exceeded", result.error_message)

    def test_convert_invalid_dashboard_preserves_local_trend_result(self):
        """Invalid Agent dashboard should not erase already-computed trend data."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="LLM returned text but no dashboard JSON",
            dashboard=None,
            provider="ollama",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=64,
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-trend-fallback",
            trend_result=trend_result,
        )

        self.assertIsNotNone(result)
        self.assertTrue(result.success)
        self.assertEqual(result.sentiment_score, 64)
        self.assertEqual(result.trend_prediction, "多头排列")
        self.assertEqual(result.operation_advice, "买入")
        self.assertEqual(result.decision_type, "buy")
        self.assertIn("trend:fallback", result.data_sources)

    def test_convert_empty_dashboard_backfills_local_trend_dashboard(self):
        """Empty Agent dashboard should still produce an integrity-ready local fallback dashboard."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.analyzer import check_content_integrity
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={},
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=68,
            support_levels=[112.3],
            risk_factors=["跌破 MA20 需止损"],
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-empty-dashboard",
            trend_result=trend_result,
        )

        ok, missing = check_content_integrity(result)
        self.assertTrue(ok, missing)
        self.assertEqual(result.sentiment_score, 68)
        self.assertEqual(result.analysis_summary, "趋势结论：多头排列；操作建议：买入。")
        self.assertEqual(result.dashboard["sentiment_score"], 68)
        self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], result.analysis_summary)
        self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["跌破 MA20 需止损"])
        self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], 112.3)

    def test_convert_dict_operation_advice_missing_decision_type_preserves_buy_signal(self):
        """When operation_advice is dict without decision_type, preserve dict-derived buy/sell hint."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "operation_advice": {
                    "has_position": "买入",
                    "no_position": "观望",
                },
                "trend_prediction": "看多",
                "sentiment_score": 74,
            },
            provider="ollama",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-dict-advice",
        )

        self.assertEqual(result.operation_advice, "买入")
        self.assertEqual(result.decision_type, "buy")

    def test_convert_missing_decision_type_preserves_conditional_hold_advice(self):
        """Condition-hold wording should remain hold when decision_type is not provided."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "operation_advice": "不跌破支撑位继续持有",
                "sentiment_score": 72,
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.STRONG_BUY,
            signal_score=78,
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-conditional-hold-advice",
            trend_result=trend_result,
        )

        self.assertEqual(result.operation_advice, "不跌破支撑位继续持有")
        self.assertEqual(result.decision_type, "hold")

    def test_convert_empty_top_level_advice_uses_nested_dashboard_advice(self):
        """Empty top-level advice dict should not block nested dashboard fallback."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "operation_advice": {},
                "dashboard": {
                    "operation_advice": "减仓",
                    "trend_prediction": "看空",
                    "sentiment_score": 42,
                },
            },
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-nested-advice",
        )

        self.assertEqual(result.operation_advice, "减仓")
        self.assertEqual(result.decision_type, "sell")
        self.assertEqual(result.dashboard["operation_advice"], "减仓")

    def test_convert_placeholder_top_level_advice_uses_nested_dashboard_advice(self):
        """Placeholder advice dict should not block nested dashboard fallback."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "operation_advice": {
                    "has_position": "待补充",
                    "no_position": "TBD",
                },
                "dashboard": {
                    "operation_advice": "减仓",
                    "trend_prediction": "看空",
                    "sentiment_score": 42,
                },
            },
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-placeholder-advice",
        )

        self.assertEqual(result.operation_advice, "减仓")
        self.assertEqual(result.decision_type, "sell")
        self.assertEqual(result.dashboard["operation_advice"], "减仓")

    def test_convert_malformed_top_level_summary_uses_nested_dashboard_summary(self):
        """Malformed top-level analysis_summary should not block nested dashboard fallback."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "analysis_summary": [],
                "dashboard": {
                    "analysis_summary": "AI 已给出的摘要",
                    "trend_prediction": "看多",
                    "operation_advice": "持有",
                    "sentiment_score": 73,
                },
            },
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-nested-summary",
        )

        self.assertEqual(result.analysis_summary, "AI 已给出的摘要")
        self.assertEqual(result.dashboard["analysis_summary"], "AI 已给出的摘要")

    def test_convert_non_string_summary_falls_back_to_nested_or_local_summary(self):
        """Non-string analysis_summary should trigger fallback to nested summary or local fallback."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        for raw_summary in (0, False):
            agent_result = AgentResult(
                success=True,
                content="{}",
                dashboard={
                    "analysis_summary": raw_summary,
                    "trend_prediction": "看多",
                    "dashboard": {
                        "analysis_summary": "AI 已给出的摘要",
                    },
                    "operation_advice": "持有",
                    "sentiment_score": 73,
                },
                provider="gemini",
            )

            result = pipeline._agent_result_to_analysis_result(
                agent_result,
                "600519",
                "贵州茅台",
                ReportType.SIMPLE,
                f"q-summary-non-string-{raw_summary}",
            )

            self.assertEqual(result.analysis_summary, "AI 已给出的摘要")
            self.assertEqual(result.dashboard["analysis_summary"], "AI 已给出的摘要")

    def test_convert_malformed_scalar_fields_fallback_to_trend_result(self):
        """Malformed non-scalar scalar fields should not be treated as valid values."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "sentiment_score": {"value": ""},
                "trend_prediction": [],
                "operation_advice": [],
                "decision_type": {},
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=66,
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-malformed-scalars",
            trend_result=trend_result,
        )

        self.assertEqual(result.sentiment_score, 66)
        self.assertEqual(result.trend_prediction, "多头排列")
        self.assertEqual(result.operation_advice, "买入")
        self.assertEqual(result.decision_type, "buy")
        self.assertEqual(result.dashboard["sentiment_score"], 66)
        self.assertEqual(result.dashboard["trend_prediction"], "多头排列")
        self.assertEqual(result.dashboard["operation_advice"], "买入")
    def test_convert_empty_dashboard_backfills_localized_trend_fallback_for_en(self):
        """English reports should keep trend/advice fallback values localized."""
        pipeline = self._make_pipeline()
        pipeline.config.report_language = "en"

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={},
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=70,
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-en-fallback",
            trend_result=trend_result,
        )

        self.assertEqual(result.report_language, "en")
        self.assertEqual(result.trend_prediction, "Bullish")
        self.assertEqual(result.operation_advice, "Buy")
        self.assertEqual(
            result.analysis_summary,
            "Trend view: Bullish; action advice: Buy.",
        )
        self.assertEqual(result.dashboard["trend_prediction"], "Bullish")
        self.assertEqual(result.dashboard["operation_advice"], "Buy")
        self.assertEqual(
            result.dashboard["core_conclusion"]["one_sentence"],
            "Trend view: Bullish; action advice: Buy.",
        )

    def test_convert_non_dict_advice_conflict_keeps_advice_decision(self):
        """Conflict between trend fallback and explicit non-dict advice should keep advice decision."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "sentiment_score": 65,
                "trend_prediction": "看空",
                "operation_advice": "减仓",
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=70,
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-advice-vs-trend",
            trend_result=trend_result,
        )

        self.assertEqual(result.operation_advice, "减仓")
        self.assertEqual(result.decision_type, "sell")

    def test_convert_partial_dashboard_uses_trend_fallback_for_missing_scalars(self):
        """Partial Agent dashboards should keep AI fields while filling missing scalars locally."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "stock_name": "贵州茅台",
                "dashboard": {
                    "core_conclusion": {"one_sentence": "AI 已给出的核心结论"},
                    "intelligence": {"risk_alerts": ["AI 风险"]},
                    "battle_plan": {"sniper_points": {"take_profit": "120元"}},
                },
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=66,
            support_levels=[108.5],
            risk_factors=["跌破 MA20"],
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-partial-dashboard",
            trend_result=trend_result,
        )

        self.assertEqual(result.sentiment_score, 66)
        self.assertEqual(result.trend_prediction, "多头排列")
        self.assertEqual(result.operation_advice, "买入")
        self.assertEqual(result.decision_type, "buy")
        self.assertEqual(result.dashboard["sentiment_score"], 66)
        self.assertEqual(result.dashboard["operation_advice"], "买入")
        self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], "AI 已给出的核心结论")
        self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["AI 风险"])
        self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], 108.5)
        self.assertIn("trend:fallback", result.data_sources)

    def test_convert_risk_alerts_string_placeholder_uses_local_risk_factors(self):
        """String-like placeholder risk alerts should be replaced with local trend risk factors."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.analyzer import check_content_integrity
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "dashboard": {
                    "core_conclusion": {"one_sentence": "AI 已给出的核心结论"},
                    "intelligence": {"risk_alerts": "待补充"},
                },
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=66,
            support_levels=[108.5],
            risk_factors=["涨幅过快", "回撤放大"],
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-risk-alerts-string-placeholder",
            trend_result=trend_result,
        )

        ok, missing = check_content_integrity(result)
        self.assertTrue(ok, missing)
        self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["涨幅过快", "回撤放大"])

    def test_convert_placeholder_dashboard_is_completed_from_local_context(self):
        """Placeholder dashboard blocks should be completed without falling back to neutral defaults."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.analyzer import check_content_integrity
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "stock_name": "贵州茅台",
                "dashboard": {
                    "core_conclusion": {"one_sentence": "待补充"},
                    "intelligence": {},
                    "battle_plan": {"sniper_points": {"stop_loss": ""}},
                },
            },
            provider="gemini",
        )
        trend_result = TrendAnalysisResult(
            code="600519",
            trend_status=TrendStatus.BULL,
            buy_signal=BuySignal.BUY,
            signal_score=62,
            risk_factors=["趋势跌破支撑需减仓"],
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result,
            "600519",
            "贵州茅台",
            ReportType.SIMPLE,
            "q-placeholder-dashboard",
            trend_result=trend_result,
        )

        ok, missing = check_content_integrity(result)
        self.assertTrue(ok, missing)
        self.assertEqual(result.sentiment_score, 62)
        self.assertEqual(result.dashboard["sentiment_score"], 62)
        self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], result.analysis_summary)
        self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["趋势跌破支撑需减仓"])
        self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], "待补充")

    def test_convert_invalid_dashboard_normalizes_strong_trend_decision_type(self):
        """Fallback preserves strong advice text while keeping stable decision_type values."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType
        from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus

        cases = [
            (BuySignal.STRONG_BUY, "buy", "强烈买入"),
            (BuySignal.STRONG_SELL, "sell", "强烈卖出"),
        ]

        for buy_signal, expected_decision, expected_advice in cases:
            with self.subTest(buy_signal=buy_signal):
                agent_result = AgentResult(
                    success=True,
                    content="LLM returned text but no dashboard JSON",
                    dashboard=None,
                    provider="ollama",
                )
                trend_result = TrendAnalysisResult(
                    code="600519",
                    trend_status=TrendStatus.BULL,
                    buy_signal=buy_signal,
                    signal_score=80,
                )

                result = pipeline._agent_result_to_analysis_result(
                    agent_result,
                    "600519",
                    "贵州茅台",
                    ReportType.SIMPLE,
                    "q-trend-fallback",
                    trend_result=trend_result,
                )

                self.assertEqual(result.operation_advice, expected_advice)
                self.assertEqual(result.decision_type, expected_decision)

    def test_convert_uses_dashboard_stock_name_when_input_is_placeholder(self):
        """When input name is placeholder-like, prefer dashboard stock_name."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "stock_name": "科创芯片ETF",
                "sentiment_score": 75,
                "trend_prediction": "震荡偏多",
                "operation_advice": "持有",
                "decision_type": "hold",
            },
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result, "588200", "股票588200", ReportType.SIMPLE, "q-placeholder"
        )
        self.assertEqual(result.name, "科创芯片ETF")

    def test_convert_keeps_input_stock_name_when_valid(self):
        """When input name is already valid, do not overwrite with dashboard value."""
        pipeline = self._make_pipeline()

        from src.agent.executor import AgentResult
        from src.enums import ReportType

        agent_result = AgentResult(
            success=True,
            content="{}",
            dashboard={
                "stock_name": "错误名称",
                "sentiment_score": 70,
                "trend_prediction": "看多",
                "operation_advice": "持有",
                "decision_type": "hold",
            },
            provider="gemini",
        )

        result = pipeline._agent_result_to_analysis_result(
            agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-valid"
        )
        self.assertEqual(result.name, "贵州茅台")


# ============================================================
# Skill registration in pipeline
# ============================================================

class TestPipelineSkillRegistration(unittest.TestCase):
    """Test built-in strategies load from YAML via SkillManager."""

    def test_load_builtin_strategies(self):
        """SkillManager.load_builtin_strategies() should load all YAML strategies."""
        from src.agent.skills.base import SkillManager

        skill_manager = SkillManager()
        expected = _builtin_strategy_names()
        count = skill_manager.load_builtin_strategies()
        self.assertEqual(count, len(expected))

        skills = skill_manager.list_skills()
        self.assertEqual(len(skills), len(expected))

        names = {s.name for s in skills}
        self.assertEqual(names, expected)

        # All should be disabled by default
        active = skill_manager.list_active_skills()
        self.assertEqual(len(active), 0)

        # All should have source='builtin'
        for s in skills:
            self.assertEqual(s.source, "builtin")


# ============================================================
# Pipeline dual-path routing
# ============================================================

class TestPipelineRouting(unittest.TestCase):
    """Test that analyze_stock routes to agent mode when config.agent_mode is True."""

    def test_agent_mode_routes_to_agent(self):
        """When agent_mode=True, analyze_stock should call _analyze_with_agent."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'):

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 5
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)

            # Mock _analyze_with_agent to verify it gets called
            pipeline._analyze_with_agent = MagicMock(return_value=None)

            pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")

            pipeline._analyze_with_agent.assert_called_once()
            call_args = pipeline._analyze_with_agent.call_args
            # Positional args: code, report_type, query_id, stock_name, realtime_quote, chip_data, fundamental_context, trend_result
            self.assertEqual(call_args[0][0], "600519")
            self.assertEqual(call_args[0][1], ReportType.SIMPLE)
            self.assertEqual(call_args[0][2], "q1")
            # trend_result (8th arg) should be present (may be a TrendAnalysisResult or None)
            self.assertEqual(len(call_args[0]), 8)

    def test_legacy_mode_does_not_call_agent(self):
        """When agent_mode=False, analyze_stock should NOT call _analyze_with_agent."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db') as mock_db, \
             patch('src.core.pipeline.DataFetcherManager') as mock_fm, \
             patch('src.core.pipeline.GeminiAnalyzer') as mock_analyzer, \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService') as mock_search:

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = False
            mock_cfg.is_agent_available.return_value = False
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)

            # Mock the fetcher_manager to return None for realtime
            pipeline.fetcher_manager.get_realtime_quote.return_value = None
            pipeline.fetcher_manager.get_chip_distribution.return_value = None
            # Mock search service
            pipeline.search_service.is_available = False
            # Mock DB context
            pipeline.db.get_analysis_context.return_value = None
            # Mock analyzer
            pipeline.analyzer.analyze.return_value = None

            result = pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")

            # _analyze_with_agent should NOT exist as a mock (it's the real method)
            # Instead, verify analyzer.analyze was called (legacy path)
            pipeline.analyzer.analyze.assert_called_once()

    def test_request_skills_auto_enable_agent_mode(self):
        """Request-specific skills should route the stock analysis through Agent mode."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'):

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = False
            mock_cfg.agent_max_steps = 5
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(
                config=mock_cfg,
                analysis_skills=["growth_quality"],
            )
            pipeline._analyze_with_agent = MagicMock(return_value=None)

            pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")

            pipeline._analyze_with_agent.assert_called_once()
            self.assertEqual(pipeline.analysis_skills, ["growth_quality"])


class TestAnalyzeWithAgentStockName(unittest.TestCase):
    """Test stock-name handling in _analyze_with_agent."""

    def test_analyze_with_agent_uses_resolved_name_for_news_persistence(self):
        """Should use resolved stock name from dashboard for search and DB persistence."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'), \
             patch('src.agent.factory.build_agent_executor') as mock_build_executor, \
             patch('src.agent.executor.AgentExecutor.run') as mock_agent_run:

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.agent.executor import AgentResult
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)

            agent_result = AgentResult(
                success=True,
                content="{}",
                dashboard={
                    "stock_name": "科创芯片ETF",
                    "sentiment_score": 78,
                    "trend_prediction": "震荡偏多",
                    "operation_advice": "持有",
                    "decision_type": "hold",
                },
                provider="gemini",
            )
            mock_executor = MagicMock()
            mock_executor.run.return_value = agent_result
            mock_build_executor.return_value = mock_executor
            mock_agent_run.return_value = agent_result

            news_response = MagicMock()
            news_response.success = True
            news_response.results = [{"title": "test"}]
            news_response.query = "test query"
            pipeline.search_service.is_available = True
            pipeline.search_service.search_stock_news.return_value = news_response

            result = pipeline._analyze_with_agent(
                code="588200",
                report_type=ReportType.SIMPLE,
                query_id="q-news",
                stock_name="股票588200",
                realtime_quote=None,
                chip_data=None
            )

            self.assertIsNotNone(result)
            self.assertEqual(result.name, "科创芯片ETF")
            pipeline.search_service.search_stock_news.assert_called_once_with(
                stock_code="588200",
                stock_name="科创芯片ETF",
                max_results=5
            )
            pipeline.db.save_news_intel.assert_called_once()
            saved_kwargs = pipeline.db.save_news_intel.call_args.kwargs
            self.assertEqual(saved_kwargs["name"], "科创芯片ETF")

    def test_analyze_with_agent_keeps_dashboard_top_level_fields_after_stability(self):
        """Decision stability downgrade in agent flow should sync dashboard and top-level decision fields."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'), \
             patch('src.agent.factory.build_agent_executor') as mock_build_executor:

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_cfg.report_language = "zh"
            mock_cfg.agent_orchestrator_timeout_s = 600
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.agent.executor import AgentResult
            from src.enums import ReportType
            from src.stock_analyzer import TrendAnalysisResult, TrendStatus, BuySignal
            pipeline = StockAnalysisPipeline(config=mock_cfg)

            agent_result = AgentResult(
                success=True,
                content="{}",
                dashboard={
                    "sentiment_score": 30,
                    "trend_prediction": "震荡",
                    "operation_advice": "卖出",
                    "decision_type": "sell",
                    "analysis_summary": "原始建议",
                    "dashboard": {
                        "core_conclusion": {"one_sentence": "初始结论"},
                    },
                },
                provider="gemini",
            )
            mock_executor = MagicMock()
            mock_executor.run.return_value = agent_result
            mock_build_executor.return_value = mock_executor

            trend_result = TrendAnalysisResult(
                code="002812",
                trend_status=TrendStatus.BULL,
                buy_signal=BuySignal.SELL,
                signal_score=30,
                support_levels=[30.0],
                resistance_levels=[34.0],
            )
            fundamental_context = {
                "capital_flow": {
                    "status": "ok",
                    "data": {
                        "stock_flow": {
                            "main_net_inflow": 800_000,
                        }
                    },
                }
            }

            result = pipeline._analyze_with_agent(
                code="002812",
                report_type=ReportType.SIMPLE,
                query_id="q-agent-stability",
                stock_name="恩捷股份",
                realtime_quote={"price": 30.4, "change_pct": -2.1},
                chip_data=None,
                fundamental_context=fundamental_context,
                trend_result=trend_result,
            )

            self.assertIsNotNone(result)
            self.assertEqual(result.decision_type, "hold")
            self.assertEqual(result.operation_advice, "洗盘观察")
            self.assertEqual(result.dashboard.get("decision_type"), "hold")
            self.assertEqual(result.dashboard.get("operation_advice"), "洗盘观察")
            self.assertEqual(result.dashboard.get("sentiment_score"), result.sentiment_score)

    def test_analyze_with_agent_preserves_chip_structure_when_prefetch_missing(self):
        """Agent tool chip metrics should not be cleared when prefetch chip_data is unavailable."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'), \
             patch('src.agent.factory.build_agent_executor') as mock_build_executor:

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_cfg.report_language = "zh"
            mock_cfg.report_integrity_enabled = False
            mock_cfg.agent_orchestrator_timeout_s = 600
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.agent.executor import AgentResult
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)
            pipeline.search_service.is_available = False

            agent_result = AgentResult(
                success=True,
                content="{}",
                dashboard={
                    "sentiment_score": 70,
                    "trend_prediction": "震荡",
                    "operation_advice": "持有",
                    "decision_type": "hold",
                    "dashboard": {
                        "data_perspective": {
                            "chip_structure": {
                                "profit_ratio": "52.0%",
                                "avg_cost": 1850.0,
                                "concentration": "0.00%",
                                "chip_health": "健康",
                            }
                        }
                    },
                },
                provider="gemini",
            )
            mock_executor = MagicMock()
            mock_executor.run.return_value = agent_result
            mock_build_executor.return_value = mock_executor

            result = pipeline._analyze_with_agent(
                code="600519",
                report_type=ReportType.SIMPLE,
                query_id="q-agent-chip",
                stock_name="贵州茅台",
                realtime_quote=None,
                chip_data=None,
            )

            self.assertIsNotNone(result)
            dp = result.dashboard["data_perspective"]
            self.assertEqual(dp["chip_structure"]["concentration"], "0.00%")
            self.assertNotIn("chip_unavailable_reason", dp)

    def test_analyze_with_agent_history_context_includes_diagnostic_snapshot(self):
        """Agent 分析入库存档时应保留 diagnostics 快照，避免历史诊断返回 unknown。"""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'), \
             patch('src.core.pipeline.fill_price_position_if_needed'), \
             patch('src.core.pipeline.stabilize_decision_with_structure'), \
             patch('src.core.pipeline.current_diagnostic_snapshot') as mock_diagnostic_snapshot:

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.anspire_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = True
            mock_cfg.report_language = "zh"
            mock_cfg.report_integrity_enabled = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)
            pipeline.search_service.is_available = False
            pipeline._ensure_agent_history = MagicMock()
            pipeline._agent_result_to_analysis_result = MagicMock(
                return_value=SimpleNamespace(
                    success=True,
                    code="588200",
                    name="科创芯片ETF",
                    model_used="agent-model",
                    sentiment_score=70,
                    operation_advice="持有",
                    trend_prediction="震荡",
                    analysis_summary="测试摘要",
                )
            )

            mock_executor = MagicMock()
            mock_executor.run.return_value = SimpleNamespace(
                success=True,
                provider="agent-provider",
                dashboard={"stock_name": "科创芯片ETF"},
            )
            with patch('src.agent.factory.build_agent_executor', return_value=mock_executor):
                mock_diagnostic_snapshot.return_value = {"trace_id": "trace-1391", "query_id": "q-1391"}
                pipeline.db.save_analysis_history = MagicMock(return_value=1)

                result = pipeline._analyze_with_agent(
                    code="588200",
                    report_type=ReportType.SIMPLE,
                    query_id="q-1391",
                    stock_name="科创芯片ETF",
                    realtime_quote=None,
                    chip_data=None,
                )

            self.assertIsNotNone(result)
            call_kwargs = pipeline.db.save_analysis_history.call_args.kwargs
            history_context = call_kwargs["context_snapshot"]
            self.assertIn("diagnostics", history_context)
            self.assertEqual(history_context["diagnostics"]["trace_id"], "trace-1391")
            self.assertEqual(history_context["stock_name"], "科创芯片ETF")


# ============================================================
# Agent construction chain (real objects, mocked LLM)
# ============================================================

class TestAgentConstructionChain(unittest.TestCase):
    """Test that the agent construction chain wires up correctly."""

    def test_llm_adapter_accepts_config(self):
        """LLMToolAdapter should accept an optional config parameter."""
        mock_cfg = MagicMock()
        mock_cfg.gemini_api_key = ""
        mock_cfg.anthropic_api_key = ""
        mock_cfg.openai_api_key = ""
        mock_cfg.openai_base_url = ""
        mock_cfg.openai_model = ""

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        self.assertIsNotNone(adapter)

    def test_llm_adapter_no_args(self):
        """LLMToolAdapter should also work with no arguments (uses get_config)."""
        with patch('src.agent.llm_adapter.get_config') as mock_get_config:
            mock_cfg = MagicMock()
            mock_cfg.gemini_api_key = ""
            mock_cfg.anthropic_api_key = ""
            mock_cfg.openai_api_key = ""
            mock_cfg.openai_base_url = ""
            mock_cfg.openai_model = ""
            mock_get_config.return_value = mock_cfg

            from src.agent.llm_adapter import LLMToolAdapter
            adapter = LLMToolAdapter()
            self.assertIsNotNone(adapter)

    def test_full_construction_chain(self):
        """Test ToolRegistry + SkillManager + LLMToolAdapter + AgentExecutor wiring."""
        from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter
        from src.agent.skills.base import SkillManager, Skill
        from src.agent.llm_adapter import LLMToolAdapter
        from src.agent.executor import AgentExecutor

        # Build registry with a dummy tool
        registry = ToolRegistry()

        def dummy_handler(x: str) -> str:
            return f"echo {x}"

        dummy_tool = ToolDefinition(
            name="dummy_echo",
            description="A test tool for echoing input.",
            category="test",
            parameters=[ToolParameter(name="x", type="string", description="input string", required=True)],
            handler=dummy_handler,
        )
        registry.register(dummy_tool)

        # Build skill manager with a fresh skill instance (avoid module singleton state)
        skill_manager = SkillManager()
        test_skill = Skill(
            name="test_skill",
            display_name="测试策略",
            description="A test skill",
            instructions="Test instructions for analysis.",
            category="trend",
            core_rules=[1, 2],
        )
        skill_manager.register(test_skill)
        skill_manager.activate(["test_skill"])
        instructions = skill_manager.get_skill_instructions()
        self.assertIn("测试策略", instructions)

        # Build LLM adapter with mocked config (no real API keys)
        mock_cfg = MagicMock()
        mock_cfg.gemini_api_key = ""
        mock_cfg.anthropic_api_key = ""
        mock_cfg.openai_api_key = ""
        mock_cfg.openai_base_url = ""
        mock_cfg.openai_model = ""
        adapter = LLMToolAdapter(config=mock_cfg)

        # Build executor
        executor = AgentExecutor(
            tool_registry=registry,
            llm_adapter=adapter,
            skill_instructions=instructions,
            max_steps=3,
        )
        self.assertEqual(executor.max_steps, 3)
        self.assertIsNotNone(executor.tool_registry)
        self.assertIsNotNone(executor.llm_adapter)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_call_completion_uses_effective_agent_models_order(self, _mock_router):
        """call_completion should use Agent effective model chain in order."""
        mock_cfg = MagicMock()
        mock_cfg.agent_litellm_model = "gpt-4o-mini"
        mock_cfg.litellm_model = "gemini/gemini-2.5-flash"
        mock_cfg.litellm_fallback_models = ["openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet-20241022"]
        mock_cfg.llm_model_list = []
        mock_cfg.llm_temperature = 0.7
        mock_cfg.gemini_api_keys = []
        mock_cfg.anthropic_api_keys = []
        mock_cfg.openai_api_keys = []
        mock_cfg.deepseek_api_keys = []
        mock_cfg.openai_base_url = None

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        calls = []

        def fake_call(_messages, _tools, model, **_kwargs):
            calls.append(model)
            if model == "openai/gpt-4o-mini":
                raise RuntimeError("primary failed")
            return MagicMock(content="ok")

        adapter._call_litellm_model = MagicMock(side_effect=fake_call)

        result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])

        self.assertEqual(calls, ["openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet-20241022"])
        self.assertEqual(result.content, "ok")

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_normalizes_kimi_k26_temperature(self, _mock_router):
        """Agent direct LiteLLM calls should not send unsupported temperatures to Kimi K2.6."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/kimi-k2.6",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        adapter._router = None
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )

        with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
            result = adapter._call_litellm_model(
                [{"role": "user", "content": "hi"}],
                [],
                "openai/kimi-k2.6",
                temperature=0.2,
            )

        self.assertEqual(result.content, "agent ok")
        self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_normalizes_kimi_k26_temperature_for_yaml_alias(self, _mock_router):
        """Agent direct LiteLLM calls should normalize through routed YAML aliases."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="kimi_router",
            litellm_fallback_models=[],
            llm_model_list=[
                {
                    "model_name": "kimi_router",
                    "litellm_params": {"model": "openai/kimi-k2.6"},
                }
            ],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        adapter._router = None
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )

        with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
            result = adapter._call_litellm_model(
                [{"role": "user", "content": "hi"}],
                [],
                "kimi_router",
                temperature=0.2,
            )

        self.assertEqual(result.content, "agent ok")
        self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_normalizes_kimi_k26_temperature_for_non_thinking_yaml_alias(self, _mock_router):
        """Agent direct LiteLLM calls should honor non-thinking Kimi YAML overrides."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="kimi_router",
            litellm_fallback_models=[],
            llm_model_list=[
                {
                    "model_name": "kimi_router",
                    "litellm_params": {
                        "model": "openai/kimi-k2.6",
                        "extra_body": {"thinking": {"type": "disabled"}},
                    },
                }
            ],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        adapter._router = None
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )

        with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
            result = adapter._call_litellm_model(
                [{"role": "user", "content": "hi"}],
                [],
                "kimi_router",
                temperature=0.2,
            )

        self.assertEqual(result.content, "agent ok")
        self.assertEqual(mock_completion.call_args.kwargs["temperature"], 0.6)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_omits_temperature_for_gpt5_family(self, _mock_router):
        """Agent direct LiteLLM calls should omit temperature for strict default-temperature models."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/gpt5.5-ferr",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        adapter._router = None
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )

        with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
            result = adapter._call_litellm_model(
                [{"role": "user", "content": "hi"}],
                [],
                "openai/gpt5.5-ferr",
                temperature=0.2,
            )

        self.assertEqual(result.content, "agent ok")
        self.assertNotIn("temperature", mock_completion.call_args.kwargs)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_recovers_from_unsupported_temperature(self, _mock_router):
        """Agent direct LiteLLM calls should retry once with a request-scoped parameter repair."""
        from src.llm.generation_params import clear_litellm_generation_param_recovery_cache

        clear_litellm_generation_param_recovery_cache()
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/custom-temp-locked-agent",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        adapter._router = None
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )

        with patch("src.agent.llm_adapter.litellm.completion") as mock_completion:
            mock_completion.side_effect = [
                RuntimeError("Unsupported parameter: temperature is not supported"),
                response,
            ]
            result = adapter._call_litellm_model(
                [{"role": "user", "content": "hi"}],
                [],
                "openai/custom-temp-locked-agent",
                temperature=0.2,
            )

        self.assertEqual(result.content, "agent ok")
        self.assertEqual(mock_completion.call_args_list[0].kwargs["temperature"], 0.2)
        self.assertNotIn("temperature", mock_completion.call_args_list[1].kwargs)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_legacy_router_recovery_cache_is_scoped_to_endpoint(self, mock_router):
        """Legacy multi-key Router recoveries should not leak across base URLs."""
        from src.llm.generation_params import clear_litellm_generation_param_recovery_cache

        clear_litellm_generation_param_recovery_cache()
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="agent ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )
        strict_router = MagicMock()
        flex_router = MagicMock()
        strict_router.completion.side_effect = [
            RuntimeError("Unsupported parameter: temperature is not supported"),
            response,
        ]
        flex_router.completion.return_value = response
        mock_router.side_effect = [strict_router, flex_router]

        strict_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/shared-model",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=["sk-strict-key-1", "sk-strict-key-2"],
            deepseek_api_keys=[],
            openai_base_url="https://strict.example/v1",
        )
        flex_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/shared-model",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=["sk-flex-key-1", "sk-flex-key-2"],
            deepseek_api_keys=[],
            openai_base_url="https://flex.example/v1",
        )

        from src.agent.llm_adapter import LLMToolAdapter

        strict_adapter = LLMToolAdapter(config=strict_cfg)
        strict_result = strict_adapter._call_litellm_model(
            [{"role": "user", "content": "hi"}],
            [],
            "openai/shared-model",
            temperature=0.2,
        )
        flex_adapter = LLMToolAdapter(config=flex_cfg)
        flex_result = flex_adapter._call_litellm_model(
            [{"role": "user", "content": "hi"}],
            [],
            "openai/shared-model",
            temperature=0.2,
        )

        self.assertEqual(strict_result.content, "agent ok")
        self.assertEqual(flex_result.content, "agent ok")
        self.assertEqual(strict_router.completion.call_args_list[0].kwargs["temperature"], 0.2)
        self.assertNotIn("temperature", strict_router.completion.call_args_list[1].kwargs)
        self.assertEqual(flex_router.completion.call_args.kwargs["temperature"], 0.2)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_fallback_does_not_leak_kimi_fixed_temperature(self, _mock_router):
        """Non-Kimi fallbacks should keep the requested temperature after a Kimi failure."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="openai/kimi-k2.6",
            litellm_fallback_models=["openai/gpt-4o-mini"],
            llm_model_list=[],
            llm_temperature=0.2,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)
        response = SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content="fallback ok",
                        tool_calls=[],
                    )
                )
            ],
            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
        )
        temperatures = []

        def fake_completion(**kwargs):
            temperatures.append((kwargs["model"], kwargs["temperature"]))
            if kwargs["model"] == "openai/kimi-k2.6":
                raise RuntimeError("primary failed")
            return response

        with patch("src.agent.llm_adapter.litellm.completion", side_effect=fake_completion):
            result = adapter.call_completion(
                messages=[{"role": "user", "content": "hi"}],
                tools=[],
                temperature=0.2,
            )

        self.assertEqual(result.content, "fallback ok")
        self.assertEqual(
            temperatures,
            [("openai/kimi-k2.6", 1.0), ("openai/gpt-4o-mini", 0.2)],
        )

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_recomputes_timeout_for_each_fallback_attempt(self, _mock_router):
        """Each fallback model attempt should receive only the remaining timeout budget."""
        mock_cfg = MagicMock()
        mock_cfg.agent_litellm_model = "gpt-4o-mini"
        mock_cfg.litellm_model = None
        mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
        mock_cfg.llm_model_list = []
        mock_cfg.llm_temperature = 0.7
        mock_cfg.gemini_api_keys = []
        mock_cfg.anthropic_api_keys = []
        mock_cfg.openai_api_keys = []
        mock_cfg.deepseek_api_keys = []
        mock_cfg.openai_base_url = None

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        timeouts = []

        def fake_call(_messages, _tools, model, **kwargs):
            timeouts.append((model, kwargs.get("timeout")))
            if model == "openai/gpt-4o-mini":
                raise RuntimeError("primary failed")
            return MagicMock(content="ok")

        adapter._call_litellm_model = MagicMock(side_effect=fake_call)

        with patch("src.agent.llm_adapter.time.time", side_effect=[0.0, 0.0, 7.0, 7.0]):
            result = adapter.call_completion(
                messages=[{"role": "user", "content": "hi"}],
                tools=[],
                timeout=10.0,
            )

        self.assertEqual(result.content, "ok")
        self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
        self.assertEqual(timeouts[1], ("anthropic/claude-3-5-sonnet-20241022", 3.0))

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_rate_limit_backoff_is_bounded_by_remaining_timeout(self, _mock_router):
        """Rate-limit backoff should sleep, but never longer than the remaining timeout budget."""
        mock_cfg = MagicMock()
        mock_cfg.agent_litellm_model = "gpt-4o-mini"
        mock_cfg.litellm_model = None
        mock_cfg.litellm_fallback_models = ["openai/gpt-4.1-mini"]
        mock_cfg.llm_model_list = []
        mock_cfg.llm_temperature = 0.7
        mock_cfg.gemini_api_keys = []
        mock_cfg.anthropic_api_keys = []
        mock_cfg.openai_api_keys = []
        mock_cfg.deepseek_api_keys = []
        mock_cfg.openai_base_url = None

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        class FakeRateLimitError(Exception):
            pass

        timeouts = []
        sleep_calls = []
        clock = {"value": 0.0}

        def fake_time():
            return clock["value"]

        def fake_sleep(seconds):
            sleep_calls.append(seconds)
            clock["value"] += seconds

        def fake_call(_messages, _tools, model, **kwargs):
            timeouts.append((model, kwargs.get("timeout")))
            if model == "openai/gpt-4o-mini":
                clock["value"] += 8.0
                raise FakeRateLimitError("rate limited")
            return MagicMock(content="ok")

        adapter._call_litellm_model = MagicMock(side_effect=fake_call)

        with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
             patch("src.agent.llm_adapter.logger.warning"), \
             patch("src.agent.llm_adapter.time.time", side_effect=fake_time), \
             patch("src.agent.llm_adapter.time.sleep", side_effect=fake_sleep) as mock_sleep:
            result = adapter.call_completion(
                messages=[{"role": "user", "content": "hi"}],
                tools=[],
                timeout=10.0,
            )

        self.assertEqual(result.content, "ok")
        self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
        self.assertEqual(timeouts[1][0], "openai/gpt-4.1-mini")
        expected_backoff = min(2.0, 8.0 * 0.1 + 0.5)
        expected_next_timeout = 10.0 - (8.0 + expected_backoff)
        self.assertAlmostEqual(timeouts[1][1], expected_next_timeout)
        mock_sleep.assert_called_once()
        self.assertAlmostEqual(mock_sleep.call_args.args[0], expected_backoff)
        self.assertAlmostEqual(sleep_calls[0], expected_backoff)
        self.assertAlmostEqual(clock["value"], 8.0 + expected_backoff)

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_context_window_error_skips_sleep(self, _mock_router):
        """Context-window errors should continue fallback immediately without backoff."""
        mock_cfg = MagicMock()
        mock_cfg.agent_litellm_model = "gpt-4o-mini"
        mock_cfg.litellm_model = None
        mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
        mock_cfg.llm_model_list = []
        mock_cfg.llm_temperature = 0.7
        mock_cfg.gemini_api_keys = []
        mock_cfg.anthropic_api_keys = []
        mock_cfg.openai_api_keys = []
        mock_cfg.deepseek_api_keys = []
        mock_cfg.openai_base_url = None

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        class FakeContextWindowExceededError(Exception):
            pass

        def fake_call(_messages, _tools, model, **_kwargs):
            if model == "openai/gpt-4o-mini":
                raise FakeContextWindowExceededError("window exceeded")
            return MagicMock(content="ok")

        adapter._call_litellm_model = MagicMock(side_effect=fake_call)

        with patch(
            "src.agent.llm_adapter.litellm.ContextWindowExceededError",
            FakeContextWindowExceededError,
        ), patch("src.agent.llm_adapter.time.sleep") as mock_sleep:
            result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])

        self.assertEqual(result.content, "ok")
        mock_sleep.assert_not_called()

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_reports_rate_limit_suffix_when_any_fallback_hit_limit(self, _mock_router):
        """Final error should note earlier rate limiting even if the last error differs."""
        mock_cfg = MagicMock()
        mock_cfg.agent_litellm_model = "gpt-4o-mini"
        mock_cfg.litellm_model = None
        mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
        mock_cfg.llm_model_list = []
        mock_cfg.llm_temperature = 0.7
        mock_cfg.gemini_api_keys = []
        mock_cfg.anthropic_api_keys = []
        mock_cfg.openai_api_keys = []
        mock_cfg.deepseek_api_keys = []
        mock_cfg.openai_base_url = None

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        class FakeRateLimitError(Exception):
            pass

        class FakeContextWindowExceededError(Exception):
            pass

        def fake_call(_messages, _tools, model, **_kwargs):
            if model == "openai/gpt-4o-mini":
                raise FakeRateLimitError("rate limited")
            raise FakeContextWindowExceededError("window exceeded")

        adapter._call_litellm_model = MagicMock(side_effect=fake_call)

        with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
             patch(
                 "src.agent.llm_adapter.litellm.ContextWindowExceededError",
                 FakeContextWindowExceededError,
             ), \
             patch("src.agent.llm_adapter.time.sleep") as mock_sleep:
            result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])

        self.assertEqual(result.provider, "error")
        self.assertIn("All LLM models failed (rate-limit encountered during fallback).", result.content)
        self.assertIn("window exceeded", result.content)
        mock_sleep.assert_not_called()

    @patch("src.agent.llm_adapter.Router")
    def test_llm_adapter_reports_missing_configuration_without_generic_none_error(self, _mock_router):
        """Missing Agent model config should return a stable, actionable error message."""
        mock_cfg = SimpleNamespace(
            agent_litellm_model="",
            litellm_model="",
            litellm_fallback_models=[],
            llm_model_list=[],
            llm_temperature=0.7,
            gemini_api_keys=[],
            anthropic_api_keys=[],
            openai_api_keys=[],
            deepseek_api_keys=[],
            openai_base_url=None,
        )

        from src.agent.llm_adapter import LLMToolAdapter
        adapter = LLMToolAdapter(config=mock_cfg)

        result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])

        self.assertEqual(result.provider, "error")
        self.assertEqual(
            result.content,
            "No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, or provider API keys before using Agent.",
        )


# ============================================================
# _safe_int tests
# ============================================================

class TestSafeInt(unittest.TestCase):
    """Test the _safe_int helper for robust sentiment_score parsing."""

    def _get_safe_int(self):
        """Get reference to StockAnalysisPipeline._safe_int static method."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'):

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = False
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            return StockAnalysisPipeline._safe_int

    def test_int_passthrough(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int(80), 80)

    def test_float_truncate(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int(75.6), 75)

    def test_string_numeric(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int("80"), 80)

    def test_string_with_unit(self):
        """LLM may return '80分' instead of 80."""
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int("80分"), 80)

    def test_string_with_percent(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int("75%"), 75)

    def test_none_default(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int(None), 50)
        self.assertEqual(safe_int(None, 60), 60)

    def test_empty_string(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int(""), 50)

    def test_non_numeric_string(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int("high"), 50)

    def test_negative(self):
        safe_int = self._get_safe_int()
        self.assertEqual(safe_int("-10"), -10)


# ============================================================
# Skill activation semantics
# ============================================================

class TestSkillActivation(unittest.TestCase):
    """Test that skill activation follows the correct semantics."""

    def test_skills_default_disabled(self):
        """After registration, skills should be disabled by default."""
        from src.agent.skills.base import SkillManager, Skill

        manager = SkillManager()
        # Create a fresh Skill with default enabled=False
        test_skill = Skill(
            name="test_disabled",
            display_name="Test",
            description="test",
            instructions="test",
        )
        manager.register(test_skill)
        active = manager.list_active_skills()
        self.assertEqual(len(active), 0, "Skills should be disabled by default")

    def test_activate_all(self):
        """activate(['all']) should enable all registered skills."""
        from src.agent.skills.base import SkillManager, Skill

        manager = SkillManager()
        # Create test skills instead of importing deleted Python modules
        skill1 = Skill(name="dragon_head", display_name="龙头策略",
                       description="test", instructions="test")
        skill2 = Skill(name="shrink_pullback", display_name="缩量回踩",
                       description="test", instructions="test")
        manager.register(skill1)
        manager.register(skill2)
        manager.activate(["all"])
        active = manager.list_active_skills()
        self.assertEqual(len(active), 2)

    def test_activate_specific(self):
        """activate with specific names should only enable those."""
        from src.agent.skills.base import SkillManager, Skill

        manager = SkillManager()
        skill1 = Skill(name="dragon_head", display_name="龙头策略",
                       description="test", instructions="test")
        skill2 = Skill(name="shrink_pullback", display_name="缩量回踩",
                       description="test", instructions="test")
        skill3 = Skill(name="volume_breakout", display_name="放量突破",
                       description="test", instructions="test")
        manager.register(skill1)
        manager.register(skill2)
        manager.register(skill3)
        manager.activate(["dragon_head"])
        active = manager.list_active_skills()
        self.assertEqual(len(active), 1)
        self.assertEqual(active[0].name, "dragon_head")

    def test_empty_config_uses_primary_default_skill(self):
        """Empty agent_skills config should activate the primary default skill only."""
        from src.agent.skills.base import SkillManager
        from src.agent.skills.defaults import get_default_active_skill_ids

        skill_manager = SkillManager()
        count = skill_manager.load_builtin_strategies()
        self.assertEqual(count, len(_builtin_strategy_names()), "Should load all built-in strategies from YAML")

        default_ids = get_default_active_skill_ids(skill_manager.list_skills())
        self.assertEqual(default_ids, ["bull_trend"])
        skill_manager.activate(default_ids)

        active = skill_manager.list_active_skills()
        self.assertEqual([skill.name for skill in active], ["bull_trend"])

    def test_sentiment_score_parsed_from_dashboard(self):
        """Verify _agent_result_to_analysis_result handles non-numeric sentiment_score."""
        with patch('src.core.pipeline.get_config') as mock_config, \
             patch('src.core.pipeline.get_db'), \
             patch('src.core.pipeline.DataFetcherManager'), \
             patch('src.core.pipeline.GeminiAnalyzer'), \
             patch('src.core.pipeline.NotificationService'), \
             patch('src.core.pipeline.SearchService'):

            mock_cfg = MagicMock()
            mock_cfg.max_workers = 2
            mock_cfg.agent_mode = True
            mock_cfg.agent_max_steps = 10
            mock_cfg.agent_skills = []
            mock_cfg.bocha_api_keys = []
            mock_cfg.tavily_api_keys = []
            mock_cfg.brave_api_keys = []
            mock_cfg.serpapi_keys = []
            mock_cfg.searxng_base_urls = []
            mock_cfg.searxng_public_instances_enabled = False
            mock_cfg.news_max_age_days = 7
            mock_cfg.enable_realtime_quote = True
            mock_cfg.enable_chip_distribution = True
            mock_cfg.realtime_source_priority = []
            mock_cfg.save_context_snapshot = False
            mock_config.return_value = mock_cfg

            from src.core.pipeline import StockAnalysisPipeline
            from src.agent.executor import AgentResult
            from src.enums import ReportType
            pipeline = StockAnalysisPipeline(config=mock_cfg)

            # Dashboard with "80分" instead of 80
            agent_result = AgentResult(
                success=True,
                content="{}",
                dashboard={
                    "stock_name": "TestCo",
                    "sentiment_score": "80分",
                    "trend_prediction": "看多",
                    "operation_advice": "买入",
                    "decision_type": "buy",
                },
                provider="gemini",
            )

            result = pipeline._agent_result_to_analysis_result(
                agent_result, "600519", "TestCo", ReportType.SIMPLE, "q1"
            )
            self.assertEqual(result.sentiment_score, 80)


if __name__ == '__main__':
    unittest.main()
