# -*- coding: utf-8 -*- """ Tests for the multi-agent architecture modules. Covers: - _extract_stock_code: Chinese boundary, HK, US, common word filtering - AgentContext / AgentOpinion / StageResult protocol basics - AgentOrchestrator: pipeline execution, mode selection, error handling - StrategyRouter: regime detection, manual mode, user override - StrategyAggregator: weighted consensus, empty input - PortfolioAgent.post_process: JSON parsing via try_parse_json """ import json import sys import os import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # Keep test runnable when optional LLM deps are missing try: import litellm # noqa: F401 except ModuleNotFoundError: sys.modules["litellm"] = MagicMock() from src.agent.orchestrator import _extract_stock_code, _COMMON_WORDS from src.agent.protocols import ( AgentContext, AgentOpinion, AgentRunStats, Signal, StageResult, StageStatus, ) from src.agent.stock_scope import StockScope, resolve_stock_scope from src.config import AGENT_MAX_STEPS_DEFAULT, Config from src.storage import DatabaseManager # ============================================================ # _extract_stock_code # ============================================================ class TestExtractStockCode(unittest.TestCase): """Validate stock code extraction from free text.""" # --- A-share --- def test_a_share_plain(self): self.assertEqual(_extract_stock_code("600519"), "600519") def test_a_share_chinese_prefix(self): """Critical: Chinese char + digits must still match (no \\b).""" self.assertEqual(_extract_stock_code("分析600519"), "600519") def test_a_share_chinese_suffix(self): self.assertEqual(_extract_stock_code("600519怎么样"), "600519") def test_a_share_in_sentence(self): self.assertEqual(_extract_stock_code("请帮我看看600519的走势"), "600519") def test_a_share_with_prefix_0(self): self.assertEqual(_extract_stock_code("分析000858"), "000858") def test_a_share_with_prefix_3(self): self.assertEqual(_extract_stock_code("分析300750"), "300750") def test_a_share_not_match_7_digits(self): """Should not match 7-digit number.""" self.assertEqual(_extract_stock_code("1234567"), "") def test_a_share_embedded_in_longer_number(self): """Should not extract from within a longer number.""" self.assertEqual(_extract_stock_code("86006005190001"), "") # --- HK --- def test_hk_lowercase(self): self.assertEqual(_extract_stock_code("look at hk00700"), "HK00700") def test_hk_uppercase(self): self.assertEqual(_extract_stock_code("HK00700 analysis"), "HK00700") def test_hk_chinese(self): self.assertEqual(_extract_stock_code("分析hk00700"), "HK00700") def test_hk_not_match_alpha_prefix(self): """Letters before 'hk' should not prevent match.""" # "xhk00700" has alpha before hk, lookbehind should block self.assertNotEqual(_extract_stock_code("xhk00700"), "HK00700") # --- US --- def test_us_ticker(self): self.assertEqual(_extract_stock_code("analyze AAPL"), "AAPL") def test_us_ticker_in_chinese(self): self.assertEqual(_extract_stock_code("看看TSLA"), "TSLA") def test_us_ticker_5_chars(self): self.assertEqual(_extract_stock_code("check GOOGL"), "GOOGL") def test_lowercase_us_ticker_with_analysis_hint(self): self.assertEqual(_extract_stock_code("分析tsla"), "TSLA") def test_lowercase_us_ticker_bare(self): self.assertEqual(_extract_stock_code("tsla"), "TSLA") def test_bse_code_with_8_prefix(self): self.assertEqual(_extract_stock_code("分析830799"), "830799") def test_bse_code_with_92_prefix(self): self.assertEqual(_extract_stock_code("看看920748"), "920748") # --- Common word filtering --- def test_common_word_buy(self): self.assertEqual(_extract_stock_code("should I BUY"), "") def test_common_word_sell(self): self.assertEqual(_extract_stock_code("should I SELL"), "") def test_common_word_hold(self): self.assertEqual(_extract_stock_code("should I HOLD"), "") def test_common_word_etf(self): self.assertEqual(_extract_stock_code("what about ETF"), "") def test_common_word_rsi(self): self.assertEqual(_extract_stock_code("RSI is high"), "") def test_common_word_macd(self): self.assertEqual(_extract_stock_code("check MACD"), "") def test_common_word_stock(self): self.assertEqual(_extract_stock_code("good STOCK pick"), "") def test_common_word_trend(self): self.assertEqual(_extract_stock_code("the TREND is up"), "") def test_finance_abbrev_excluded(self): for text in [ "TTM", "市盈率 TTM 怎么看", "PE 怎么看", "PE TTM", "WHAT IS PE", "PE IS HIGH", "WHAT IS TTM", "YOY", "QOQ", "EBITDA", "DCF", "CAGR", ]: with self.subTest(text=text): self.assertEqual(_extract_stock_code(text), "") def test_finance_abbrev_before_real_ticker(self): self.assertEqual(_extract_stock_code("PE AAPL 怎么看"), "AAPL") self.assertEqual(_extract_stock_code("TTM AAPL 怎么看"), "AAPL") self.assertEqual(_extract_stock_code("WHAT IS PE AAPL"), "AAPL") # --- Priority: A-share > HK > US --- def test_a_share_takes_priority_over_us(self): """When both A-share code and US ticker appear, A-share wins.""" self.assertEqual(_extract_stock_code("600519 vs AAPL"), "600519") # --- Empty / irrelevant --- def test_empty_string(self): self.assertEqual(_extract_stock_code(""), "") def test_no_code(self): self.assertEqual(_extract_stock_code("hello world"), "") def test_single_char_uppercase(self): """Single uppercase letter should not match.""" self.assertEqual(_extract_stock_code("I think"), "") def test_lowercase_not_us_ticker(self): """Lowercase letters should not match US regex.""" self.assertEqual(_extract_stock_code("analyze aapl"), "") def test_common_words_set_completeness(self): """Ensure critical finance terms are in _COMMON_WORDS.""" expected_in_set = { "BUY", "SELL", "HOLD", "ETF", "IPO", "RSI", "MACD", "STOCK", "TREND", "TTM", "PE", "YOY", "QOQ", "EBITDA", "DCF", "CAGR", "KDJ", "IS", "WHAT", "HIGH", } self.assertTrue(expected_in_set.issubset(_COMMON_WORDS)) # ============================================================ # Stock scope resolution # ============================================================ class TestStockScopeResolution(unittest.TestCase): """Validate chat stock-scope state transitions.""" def test_maintain_keeps_current_stock_for_finance_abbrev_followup(self): result = resolve_stock_scope( "如果不考虑 TTM 呢", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "maintain") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_name"], "匿名标的") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"}) def test_switch_clears_old_stock_context_fields(self): result = resolve_stock_scope( "换成 AAPL 看看", { "stock_code": "600519", "stock_name": "匿名标的", "previous_analysis_summary": {"summary": "old"}, "previous_strategy": {"action": "hold"}, "previous_price": 1800, "previous_change_pct": 1.2, "realtime_quote": {"price": 1800}, "analysis_context_pack_summary": "old pack", "report_language": "zh", }, ) self.assertEqual(result.stock_scope.mode, "switch") self.assertEqual(result.stock_scope.expected_stock_code, "AAPL") self.assertEqual(result.stock_scope.allowed_stock_codes, {"AAPL"}) self.assertEqual(result.effective_context["stock_code"], "AAPL") self.assertEqual(result.effective_context["stock_name"], "") self.assertEqual(result.effective_context["report_language"], "zh") for stale_key in ( "previous_analysis_summary", "previous_strategy", "previous_price", "previous_change_pct", "realtime_quote", "analysis_context_pack_summary", ): self.assertNotIn(stale_key, result.effective_context) def test_switch_allows_single_new_code_when_current_code_is_mentioned(self): result = resolve_stock_scope( "换成 AAPL 看看,不考虑 600519", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "switch") self.assertEqual(result.stock_scope.expected_stock_code, "AAPL") self.assertEqual(result.stock_scope.allowed_stock_codes, {"AAPL"}) self.assertEqual(result.effective_context["stock_code"], "AAPL") self.assertEqual(result.effective_context["stock_name"], "") def test_compare_allows_multiple_codes_without_polluting_current_context(self): result = resolve_stock_scope( "比较 600519 和 AAPL", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_name"], "匿名标的") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "AAPL"}) def test_compare_allows_plain_five_digit_hk_code(self): result = resolve_stock_scope( "比较 01810 和 AAPL", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_name"], "匿名标的") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "HK01810", "AAPL"}) def test_compare_hints_allow_multiple_codes_without_switching_context(self): cases = [ "分析 600519 和 AAPL 的差异", "AAPL 相比 600519 怎么样", "和 AAPL 的差异怎么看", ] for message in cases: with self.subTest(message=message): result = resolve_stock_scope( message, {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_name"], "匿名标的") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "AAPL"}) def test_multiple_explicit_codes_are_compare_scope(self): cases = [ ("AAPL 和 TSLA 哪个更值得买", {"600519", "AAPL", "TSLA"}), ("AAPL 和 TSLA 谁更适合", {"600519", "AAPL", "TSLA"}), ("分析 AAPL 和 TSLA", {"600519", "AAPL", "TSLA"}), ] for message, expected_allowed in cases: with self.subTest(message=message): result = resolve_stock_scope( message, {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_name"], "匿名标的") self.assertEqual(result.stock_scope.allowed_stock_codes, expected_allowed) def test_multiple_lowercase_explicit_codes_are_compare_scope_with_choice_hint(self): result = resolve_stock_scope( "aapl 和 tsla 哪个更值得买", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "AAPL", "TSLA"}) def test_single_stock_difference_phrase_still_switches_context(self): result = resolve_stock_scope( "分析 AAPL 的差异化优势", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "switch") self.assertEqual(result.stock_scope.expected_stock_code, "AAPL") self.assertEqual(result.effective_context["stock_code"], "AAPL") self.assertEqual(result.effective_context["stock_name"], "") def test_moving_average_indicator_token_does_not_switch_context(self): cases = [ "分析 MA 均线", "看看 MA 怎么排列", "分析 KDJ 指标", "KDJ 怎么看", ] for message in cases: with self.subTest(message=message): result = resolve_stock_scope( message, {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "maintain") self.assertEqual(result.stock_scope.expected_stock_code, "600519") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"}) self.assertEqual(result.effective_context["stock_code"], "600519") def test_dotted_us_ticker_stays_intact_in_scope_resolution(self): result = resolve_stock_scope( "比较 BRK.B 和 AAPL", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "BRK.B", "AAPL"}) self.assertEqual(result.effective_context["stock_code"], "600519") def test_invalid_context_exchange_token_is_not_trusted_as_current_stock(self): result = resolve_stock_scope( "继续看", {"stock_code": "HK", "stock_name": "港股"}, ) self.assertEqual(result.stock_scope.mode, "maintain") self.assertEqual(result.stock_scope.expected_stock_code, "") self.assertEqual(result.stock_scope.allowed_stock_codes, set()) self.assertNotIn("stock_code", result.effective_context) self.assertNotIn("stock_name", result.effective_context) def test_compare_does_not_treat_exchange_affixes_as_standalone_tickers(self): cases = [ ("比较 01810 和 AAPL", {"600519", "HK01810", "AAPL"}, set()), ("比较 1810.HK 和 AAPL", {"600519", "HK01810", "AAPL"}, {"HK"}), ("比较 0700.HK 和 600519", {"600519", "HK00700"}, {"HK"}), ("比较 600519.SH 和 AAPL", {"600519", "AAPL"}, {"SH"}), ("比较 000001.SZ 和 AAPL", {"600519", "000001", "AAPL"}, {"SZ"}), ("比较 600519.SS 和 AAPL", {"600519", "AAPL"}, {"SS"}), ("比较 1810.hk 和 tsla", {"600519", "HK01810", "TSLA"}, {"HK"}), ("比较 SH600519 和 AAPL", {"600519", "AAPL"}, {"SH"}), ("比较 SZ000001 和 AAPL", {"600519", "000001", "AAPL"}, {"SZ"}), ("比较 BJ920748 和 AAPL", {"600519", "920748", "AAPL"}, {"BJ"}), ("比较 HK01810 和 AAPL", {"600519", "HK01810", "AAPL"}, {"HK"}), ("比较 hk01810 和 tsla", {"600519", "HK01810", "TSLA"}, {"HK"}), ("比较 600519 SH 和 AAPL", {"600519", "AAPL"}, {"SH"}), ("比较 000001 SZ 和 AAPL", {"600519", "000001", "AAPL"}, {"SZ"}), ("比较 920748 BJ 和 AAPL", {"600519", "920748", "AAPL"}, {"BJ"}), ("比较 01810 HK 和 AAPL", {"600519", "HK01810", "AAPL"}, {"HK"}), ("比较 600519 SS 和 AAPL", {"600519", "AAPL"}, {"SS"}), ] for message, expected_allowed, forbidden_tokens in cases: with self.subTest(message=message): result = resolve_stock_scope( message, {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.stock_scope.allowed_stock_codes, expected_allowed) for token in forbidden_tokens: self.assertNotIn(token, result.stock_scope.allowed_stock_codes) def test_switch_recognizes_lowercase_us_ticker_with_explicit_hint(self): result = resolve_stock_scope( "分析tsla", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "switch") self.assertEqual(result.stock_scope.expected_stock_code, "TSLA") self.assertEqual(result.effective_context["stock_code"], "TSLA") self.assertEqual(result.effective_context["stock_name"], "") def test_compare_recognizes_lowercase_us_tickers(self): result = resolve_stock_scope( "比较 600519 和 tsla", {"stock_code": "600519", "stock_name": "匿名标的"}, ) self.assertEqual(result.stock_scope.mode, "compare") self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "TSLA"}) # ============================================================ # Protocol dataclasses # ============================================================ class TestAgentContext(unittest.TestCase): """Test AgentContext helpers.""" def test_add_opinion(self): ctx = AgentContext(query="test", stock_code="600519") op = AgentOpinion(agent_name="tech", signal="buy", confidence=0.8) ctx.add_opinion(op) self.assertEqual(len(ctx.opinions), 1) self.assertGreater(op.timestamp, 0) def test_add_risk_flag(self): ctx = AgentContext() ctx.add_risk_flag("insider", "major sell-down", severity="high") self.assertTrue(ctx.has_risk_flags) self.assertEqual(ctx.risk_flags[0]["severity"], "high") def test_set_get_data(self): ctx = AgentContext() ctx.set_data("foo", {"bar": 1}) self.assertEqual(ctx.get_data("foo"), {"bar": 1}) self.assertIsNone(ctx.get_data("missing")) self.assertEqual(ctx.get_data("missing", "default"), "default") class TestAgentOpinion(unittest.TestCase): """Test AgentOpinion clamping and signal parsing.""" def test_confidence_clamp_high(self): op = AgentOpinion(confidence=1.5) self.assertEqual(op.confidence, 1.0) def test_confidence_clamp_low(self): op = AgentOpinion(confidence=-0.3) self.assertEqual(op.confidence, 0.0) def test_signal_enum_valid(self): op = AgentOpinion(signal="buy") self.assertEqual(op.signal_enum, Signal.BUY) def test_signal_enum_invalid(self): op = AgentOpinion(signal="maybe") self.assertIsNone(op.signal_enum) class TestAgentRunStats(unittest.TestCase): """Test AgentRunStats aggregation.""" def test_record_stage(self): stats = AgentRunStats() r1 = StageResult( stage_name="tech", status=StageStatus.COMPLETED, tokens_used=100, tool_calls_count=3, duration_s=1.2, ) r2 = StageResult( stage_name="intel", status=StageStatus.FAILED, tokens_used=50, tool_calls_count=1, duration_s=0.8, ) stats.record_stage(r1) stats.record_stage(r2) self.assertEqual(stats.total_stages, 2) self.assertEqual(stats.completed_stages, 1) self.assertEqual(stats.failed_stages, 1) self.assertEqual(stats.total_tokens, 150) self.assertEqual(stats.total_tool_calls, 4) def test_to_dict(self): stats = AgentRunStats() d = stats.to_dict() self.assertIn("total_stages", d) self.assertIn("models_used", d) # ============================================================ # Legacy StrategyRouter Compatibility # ============================================================ class TestStrategyRouter(unittest.TestCase): """Test the legacy StrategyRouter alias for SkillRouter.""" def test_user_requested_strategies_take_priority(self): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext(query="test") ctx.meta["strategies_requested"] = ["chan_theory", "wave_theory"] result = router.select_strategies(ctx) self.assertEqual(result, ["chan_theory", "wave_theory"]) def test_user_requested_capped_at_max(self): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext() ctx.meta["strategies_requested"] = ["a", "b", "c", "d", "e"] result = router.select_strategies(ctx, max_count=2) self.assertEqual(len(result), 2) @patch("src.agent.skills.router.StrategyRouter._get_routing_mode", return_value="manual") @patch( "src.agent.skills.router.StrategyRouter._get_available_skills", return_value=[ SimpleNamespace(name="chan_theory"), SimpleNamespace(name="wave_theory"), ], ) @patch("src.config.get_config", return_value=SimpleNamespace(agent_skills=["chan_theory", "wave_theory"])) def test_manual_mode_uses_configured_agent_skills(self, _mock_config, _mock_available, _mock): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext() result = router.select_strategies(ctx) self.assertEqual(result, ["chan_theory", "wave_theory"]) @patch("src.agent.skills.router.StrategyRouter._get_routing_mode", return_value="manual") @patch( "src.agent.skills.router.StrategyRouter._get_available_skills", return_value=[ SimpleNamespace(name="bull_trend", default_router=True, default_priority=10), SimpleNamespace(name="shrink_pullback", default_router=True, default_priority=40), ], ) @patch("src.config.get_config", return_value=SimpleNamespace(agent_skills=[])) def test_manual_mode_falls_back_to_defaults_when_no_skills_configured(self, _mock_config, _mock_available, _mock): from src.agent.strategies.router import StrategyRouter, _DEFAULT_STRATEGIES router = StrategyRouter() ctx = AgentContext() result = router.select_strategies(ctx) self.assertEqual(result, list(_DEFAULT_STRATEGIES[:3])) def test_detect_regime_bullish(self): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext() ctx.add_opinion(AgentOpinion( agent_name="technical", signal="buy", confidence=0.8, raw_data={"ma_alignment": "bullish", "trend_score": 80, "volume_status": "normal"}, )) regime = router._detect_regime(ctx) self.assertEqual(regime, "trending_up") def test_detect_regime_bearish(self): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext() ctx.add_opinion(AgentOpinion( agent_name="technical", signal="sell", confidence=0.7, raw_data={"ma_alignment": "bearish", "trend_score": 20, "volume_status": "light"}, )) regime = router._detect_regime(ctx) self.assertEqual(regime, "trending_down") def test_detect_regime_none_without_technical(self): from src.agent.strategies.router import StrategyRouter router = StrategyRouter() ctx = AgentContext() regime = router._detect_regime(ctx) self.assertIsNone(regime) # ============================================================ # StrategyAggregator # ============================================================ class TestStrategyAggregator(unittest.TestCase): """Test StrategyAggregator consensus logic.""" def test_no_strategy_opinions_returns_none(self): from src.agent.strategies.aggregator import StrategyAggregator agg = StrategyAggregator() ctx = AgentContext() ctx.add_opinion(AgentOpinion(agent_name="technical", signal="buy", confidence=0.8)) result = agg.aggregate(ctx) self.assertIsNone(result) def test_single_strategy_consensus(self): from src.agent.strategies.aggregator import StrategyAggregator agg = StrategyAggregator() ctx = AgentContext() ctx.add_opinion(AgentOpinion(agent_name="strategy_bull_trend", signal="buy", confidence=0.7)) result = agg.aggregate(ctx) self.assertIsNotNone(result) self.assertEqual(result.agent_name, "skill_consensus") self.assertEqual(result.signal, "buy") def test_mixed_signals_produce_hold(self): from src.agent.strategies.aggregator import StrategyAggregator agg = StrategyAggregator() ctx = AgentContext() ctx.add_opinion(AgentOpinion(agent_name="strategy_a", signal="buy", confidence=0.6)) ctx.add_opinion(AgentOpinion(agent_name="strategy_b", signal="sell", confidence=0.6)) result = agg.aggregate(ctx) self.assertIsNotNone(result) # Average of buy(4) + sell(2) = 3.0, which maps to "hold" self.assertEqual(result.signal, "hold") # ============================================================ # PortfolioAgent.post_process # ============================================================ class TestPortfolioAgentPostProcess(unittest.TestCase): """Test PortfolioAgent.post_process uses try_parse_json correctly.""" def _make_agent(self): from src.agent.agents.portfolio_agent import PortfolioAgent mock_registry = MagicMock() mock_adapter = MagicMock() return PortfolioAgent(tool_registry=mock_registry, llm_adapter=mock_adapter) def test_parse_plain_json(self): agent = self._make_agent() ctx = AgentContext() data = {"portfolio_risk_score": 3, "summary": "Looks good"} op = agent.post_process(ctx, json.dumps(data)) self.assertIsNotNone(op) self.assertEqual(op.signal, "buy") self.assertEqual(ctx.data.get("portfolio_assessment"), data) def test_parse_markdown_json(self): agent = self._make_agent() ctx = AgentContext() data = {"portfolio_risk_score": 8, "summary": "High risk"} raw = f"Here is the analysis:\n```json\n{json.dumps(data)}\n```" op = agent.post_process(ctx, raw) self.assertIsNotNone(op) self.assertEqual(op.signal, "sell") def test_parse_failure_returns_hold(self): agent = self._make_agent() ctx = AgentContext() op = agent.post_process(ctx, "This is not JSON at all") self.assertIsNotNone(op) self.assertEqual(op.signal, "hold") self.assertAlmostEqual(op.confidence, 0.3) class TestDecisionAgentPostProcess(unittest.TestCase): """Test DecisionAgent dashboard normalization behaviour.""" def test_normalizes_strong_decision_type_to_legacy_enum(self): from src.agent.agents.decision_agent import DecisionAgent agent = DecisionAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) ctx = AgentContext(query="test", stock_code="600519") dashboard = { "decision_type": "strong_buy", "sentiment_score": 88, "analysis_summary": "High conviction", "stock_name": "贵州茅台", } opinion = agent.post_process(ctx, json.dumps(dashboard)) self.assertIsNotNone(opinion) self.assertEqual(opinion.signal, "buy") self.assertEqual(ctx.get_data("final_dashboard")["decision_type"], "buy") class TestIntelAgentPostProcess(unittest.TestCase): """Test IntelAgent JSON parsing and context caching behaviour.""" def test_repairs_json_and_caches_intel_context(self): from src.agent.agents.intel_agent import IntelAgent agent = IntelAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) ctx = AgentContext(query="test", stock_code="600519") raw = """```json { "signal": "hold", "confidence": 0.72, "reasoning": "情绪中性偏谨慎", "risk_alerts": ["股东减持"], "positive_catalysts": ["行业复苏"], } ```""" opinion = agent.post_process(ctx, raw) self.assertIsNotNone(opinion) self.assertEqual(opinion.signal, "hold") self.assertEqual(ctx.get_data("intel_opinion")["positive_catalysts"], ["行业复苏"]) self.assertEqual(ctx.risk_flags[0]["description"], "股东减持") # ============================================================ # AgentOrchestrator (with mocked sub-agents) # ============================================================ class TestOrchestratorModes(unittest.TestCase): """Test that _build_agent_chain returns the right agents for each mode.""" def _make_orchestrator(self, mode="standard"): from src.agent.orchestrator import AgentOrchestrator mock_registry = MagicMock() mock_adapter = MagicMock() return AgentOrchestrator( tool_registry=mock_registry, llm_adapter=mock_adapter, mode=mode, ) def test_quick_mode(self): orch = self._make_orchestrator("quick") ctx = AgentContext(query="test", stock_code="600519") chain = orch._build_agent_chain(ctx) names = [a.agent_name for a in chain] self.assertEqual(names, ["technical", "decision"]) def test_standard_mode(self): orch = self._make_orchestrator("standard") ctx = AgentContext(query="test", stock_code="600519") chain = orch._build_agent_chain(ctx) names = [a.agent_name for a in chain] self.assertEqual(names, ["technical", "intel", "decision"]) def test_full_mode(self): orch = self._make_orchestrator("full") ctx = AgentContext(query="test", stock_code="600519") chain = orch._build_agent_chain(ctx) names = [a.agent_name for a in chain] self.assertEqual(names, ["technical", "intel", "risk", "decision"]) def test_invalid_mode_falls_back_to_standard(self): orch = self._make_orchestrator("nonsense") self.assertEqual(orch.mode, "standard") def test_chain_agents_inherit_orchestrator_max_steps(self): """Default/lowered limits cap agents; raised limits hard-override all agents.""" orch = self._make_orchestrator("full") orch.max_steps = AGENT_MAX_STEPS_DEFAULT high_limit_chain = orch._build_agent_chain(AgentContext(query="test", stock_code="600519")) self.assertEqual( {agent.agent_name: agent.max_steps for agent in high_limit_chain}, {"technical": 6, "intel": 4, "risk": 4, "decision": 3}, ) orch.max_steps = 5 low_limit_chain = orch._build_agent_chain(AgentContext(query="test", stock_code="600519")) self.assertEqual( {agent.agent_name: agent.max_steps for agent in low_limit_chain}, {"technical": 5, "intel": 4, "risk": 4, "decision": 3}, ) orch.max_steps = AGENT_MAX_STEPS_DEFAULT + 2 raised_limit_chain = orch._build_agent_chain(AgentContext(query="test", stock_code="600519")) self.assertEqual( {agent.agent_name: agent.max_steps for agent in raised_limit_chain}, {"technical": AGENT_MAX_STEPS_DEFAULT + 2, "intel": AGENT_MAX_STEPS_DEFAULT + 2, "risk": AGENT_MAX_STEPS_DEFAULT + 2, "decision": AGENT_MAX_STEPS_DEFAULT + 2}, ) def test_prepare_agent_raised_limit_overrides_low_default_agent(self): orch = self._make_orchestrator("full") orch.max_steps = AGENT_MAX_STEPS_DEFAULT + 2 decision = MagicMock(agent_name="decision", max_steps=3) prepared = orch._prepare_agent(decision) self.assertIs(prepared, decision) self.assertEqual(prepared.max_steps, AGENT_MAX_STEPS_DEFAULT + 2) def test_build_context_from_dict(self): orch = self._make_orchestrator() ctx = orch._build_context( "Analyze 600519", context={"stock_code": "600519", "stock_name": "贵州茅台", "skills": ["bull_trend"]}, ) self.assertEqual(ctx.stock_code, "600519") self.assertEqual(ctx.stock_name, "贵州茅台") self.assertEqual(ctx.meta["skills_requested"], ["bull_trend"]) def test_build_context_keeps_market_phase_context_in_meta_not_data(self): orch = self._make_orchestrator() phase_context = {"phase": "intraday", "is_partial_bar": True} pack_summary = "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n" market_structure_context = { "market_theme_context": {"status": "ok", "active_themes": []}, "stock_market_position": {"status": "ok", "primary_theme": {"name": "机器人概念"}}, } ctx = orch._build_context( "Analyze 600519", context={ "stock_code": "600519", "stock_name": "贵州茅台", "market_phase_context": phase_context, "analysis_context_pack_summary": pack_summary, "market_structure_context": market_structure_context, }, ) self.assertEqual(ctx.meta["market_phase_context"], phase_context) self.assertEqual(ctx.meta["analysis_context_pack_summary"], pack_summary) self.assertEqual(ctx.meta["market_structure_context"], market_structure_context) self.assertNotIn("market_phase_context", ctx.data) self.assertNotIn("analysis_context_pack_summary", ctx.data) self.assertNotIn("market_structure_context", ctx.data) def test_build_context_extracts_code_from_query(self): orch = self._make_orchestrator() ctx = orch._build_context("分析600519的走势") self.assertEqual(ctx.stock_code, "600519") def test_fallback_summary(self): orch = self._make_orchestrator() ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台") ctx.add_opinion(AgentOpinion(agent_name="tech", signal="buy", confidence=0.8, reasoning="Strong trend")) ctx.add_risk_flag("insider", "Minor sell-down", severity="low") summary = orch._fallback_summary(ctx) self.assertIn("600519", summary) self.assertIn("Strong trend", summary) self.assertIn("Minor sell-down", summary) class TestOrchestratorExecution(unittest.TestCase): """Test main orchestrator execution paths.""" @staticmethod def _make_orchestrator(config=None): from src.agent.orchestrator import AgentOrchestrator return AgentOrchestrator( tool_registry=MagicMock(), llm_adapter=MagicMock(), config=config, ) @staticmethod def _stage_result(name, status=StageStatus.COMPLETED, error=None, raw_text="ok"): result = StageResult(stage_name=name, status=status, error=error) result.meta["raw_text"] = raw_text result.meta["models_used"] = ["test/model"] return result @staticmethod def _decision_agent(): from src.agent.agents.decision_agent import DecisionAgent return DecisionAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) @staticmethod def _dashboard_json(decision_type="buy"): return json.dumps({ "stock_name": "Test Stock", "sentiment_score": 72, "trend_prediction": "up", "operation_advice": "buy", "decision_type": decision_type, "confidence_level": "Medium", "dashboard": { "phase_decision": { "phase_context": "regular", "action_window": "now", "immediate_action": "watch", "watch_conditions": [], "next_check_time": "next session", "confidence_reason": "test fixture", "data_limitations": [], }, "core_conclusion": { "one_sentence": "test decision", "signal_type": "buy", "position_advice": { "no_position": "watch", "has_position": "hold", }, }, }, "analysis_summary": "test summary", "key_points": ["technical fixture"], "risk_warning": "", }, ensure_ascii=False) class _OpinionStage: def __init__( self, agent_name, *, signal="hold", confidence=0.5, reasoning="fixture opinion", raw_data=None, ): self.agent_name = agent_name self.signal = signal self.confidence = confidence self.reasoning = reasoning self.raw_data = raw_data or {} def run(self, ctx, progress_callback=None, timeout_seconds=None): ctx.add_opinion(AgentOpinion( agent_name=self.agent_name, signal=self.signal, confidence=self.confidence, reasoning=self.reasoning, raw_data=self.raw_data, )) result = StageResult(stage_name=self.agent_name, status=StageStatus.COMPLETED) result.meta["raw_text"] = self.reasoning result.meta["models_used"] = ["test/model"] return result class _FailedStage: def __init__(self, agent_name, error="stage failed"): self.agent_name = agent_name self.error = error def run(self, ctx, progress_callback=None, timeout_seconds=None): result = StageResult( stage_name=self.agent_name, status=StageStatus.FAILED, error=self.error, ) result.meta["raw_text"] = "" result.meta["models_used"] = ["test/model"] return result def test_prepare_agent_uses_default_constant_as_raise_threshold(self): orch = self._make_orchestrator() agent = MagicMock(agent_name="technical", max_steps=6) prepared = orch._prepare_agent(agent) self.assertIs(prepared, agent) self.assertEqual(agent.max_steps, 6) orch.max_steps = 12 agent.max_steps = 6 orch._prepare_agent(agent) self.assertEqual(agent.max_steps, 12) orch.max_steps = 5 agent.max_steps = 6 orch._prepare_agent(agent) self.assertEqual(agent.max_steps, 5) def test_execute_pipeline_stops_on_critical_failure(self): orch = self._make_orchestrator() technical = MagicMock(agent_name="technical") technical.run.return_value = self._stage_result("technical", StageStatus.FAILED, error="boom") with patch.object(orch, "_build_agent_chain", return_value=[technical]): result = orch._execute_pipeline(AgentContext(query="test")) self.assertFalse(result.success) self.assertIn("technical", result.error) self.assertEqual(result.total_tokens, 0) def test_execute_pipeline_degrades_on_intel_failure(self): orch = self._make_orchestrator() ctx = AgentContext(query="test", stock_code="600519") ctx.add_opinion(AgentOpinion(agent_name="technical", signal="buy", confidence=0.8, reasoning="Strong trend")) intel = MagicMock(agent_name="intel") intel.run.return_value = self._stage_result("intel", StageStatus.FAILED, error="news down") decision = MagicMock(agent_name="decision") decision.run.return_value = self._stage_result("decision") with patch.object(orch, "_build_agent_chain", return_value=[intel, decision]): result = orch._execute_pipeline(ctx, parse_dashboard=False) self.assertTrue(result.success) self.assertIn("Analysis Summary", result.content) def test_execute_pipeline_degrades_on_skill_agent_failure_and_continues_to_decision(self): orch = self._make_orchestrator() orch.mode = "specialist" ctx = AgentContext(query="test", stock_code="600519") ctx.add_opinion(AgentOpinion(agent_name="technical", signal="buy", confidence=0.8, reasoning="Strong trend")) technical = MagicMock(agent_name="technical") technical.run.return_value = self._stage_result("technical") intel = MagicMock(agent_name="intel") intel.run.return_value = self._stage_result("intel") risk = MagicMock(agent_name="risk") risk.run.return_value = self._stage_result("risk") skill = MagicMock(agent_name="strategy_bull_trend") skill.run.return_value = self._stage_result("strategy_bull_trend", StageStatus.FAILED, error="skill boom") decision = MagicMock(agent_name="decision") decision.run.return_value = self._stage_result("decision") with patch.object(orch, "_build_agent_chain", return_value=[technical, intel, risk, decision]): with patch.object(orch, "_build_specialist_agents", return_value=[skill]): result = orch._execute_pipeline(ctx, parse_dashboard=False) self.assertTrue(result.success) self.assertIn("Analysis Summary", result.content) skill.run.assert_called_once() decision.run.assert_called_once() def test_pipeline_summary_and_risk_override_share_disabled_override_contract(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_risk_override=False)) ctx = AgentContext(query="test", stock_code="600519") captured_messages = [] def fake_run_agent_loop(messages, **kwargs): captured_messages.append(messages) return SimpleNamespace( success=True, content=self._dashboard_json(decision_type="buy"), total_tokens=11, tool_calls_log=[], models_used=["test/model"], ) technical = self._OpinionStage("technical", signal="buy", confidence=0.8) risk = self._OpinionStage( "risk", signal="sell", confidence=0.9, raw_data={"veto_buy": True}, ) decision = self._decision_agent() with patch.object(orch, "_build_agent_chain", return_value=[technical, risk, decision]): with patch("src.agent.runner.parse_dashboard_json", side_effect=lambda raw: json.loads(raw)): with patch("src.agent.agents.base_agent.run_agent_loop", side_effect=fake_run_agent_loop): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertEqual(result.dashboard["decision_type"], "buy") self.assertIsNone(ctx.get_data("risk_override_applied")) combined = "\n".join( str(message.get("content", "")) for messages in captured_messages for message in messages ) self.assertEqual(combined.count("## Agent Disagreement Summary"), 1) self.assertIn('"risk_override_present": false', combined) self.assertIn('"override_enabled": false', combined) self.assertIn('"override_trigger_present": true', combined) self.assertNotIn('"conflict_type": "risk_override"', combined) self.assertNotIn("[Pre-fetched: agent_disagreement_summary]", combined) def test_pipeline_risk_level_high_is_evidence_not_runtime_override(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_risk_override=True)) ctx = AgentContext(query="test", stock_code="600519") captured_messages = [] def fake_run_agent_loop(messages, **kwargs): captured_messages.append(messages) return SimpleNamespace( success=True, content=self._dashboard_json(decision_type="buy"), total_tokens=11, tool_calls_log=[], models_used=["test/model"], ) technical = self._OpinionStage("technical", signal="buy", confidence=0.8) risk = self._OpinionStage( "risk", signal="sell", confidence=0.9, raw_data={"risk_level": "high"}, ) decision = self._decision_agent() with patch.object(orch, "_build_agent_chain", return_value=[technical, risk, decision]): with patch("src.agent.runner.parse_dashboard_json", side_effect=lambda raw: json.loads(raw)): with patch("src.agent.agents.base_agent.run_agent_loop", side_effect=fake_run_agent_loop): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertEqual(result.dashboard["decision_type"], "buy") self.assertIsNone(ctx.get_data("risk_override_applied")) combined = "\n".join( str(message.get("content", "")) for messages in captured_messages for message in messages ) self.assertEqual(combined.count("## Agent Disagreement Summary"), 1) self.assertIn('"evidence_present": true', combined) self.assertIn('"override_trigger_present": false', combined) self.assertIn('"risk_override_present": false', combined) self.assertNotIn('"conflict_type": "risk_override"', combined) def test_pipeline_enabled_risk_veto_is_reflected_in_summary_and_final_dashboard(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_risk_override=True)) ctx = AgentContext(query="test", stock_code="600519") captured_messages = [] def fake_run_agent_loop(messages, **kwargs): captured_messages.append(messages) return SimpleNamespace( success=True, content=self._dashboard_json(decision_type="buy"), total_tokens=11, tool_calls_log=[], models_used=["test/model"], ) technical = self._OpinionStage("technical", signal="buy", confidence=0.8) risk = self._OpinionStage( "risk", signal="sell", confidence=0.9, raw_data={"veto_buy": True, "reasoning": "material risk"}, ) decision = self._decision_agent() with patch.object(orch, "_build_agent_chain", return_value=[technical, risk, decision]): with patch("src.agent.runner.parse_dashboard_json", side_effect=lambda raw: json.loads(raw)): with patch("src.agent.agents.base_agent.run_agent_loop", side_effect=fake_run_agent_loop): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertEqual(result.dashboard["decision_type"], "hold") self.assertEqual(ctx.get_data("risk_override_applied"), { "from": "buy", "to": "hold", "adjustment": "veto", "reason": "risk_veto", }) combined = "\n".join( str(message.get("content", "")) for messages in captured_messages for message in messages ) self.assertEqual(combined.count("## Agent Disagreement Summary"), 1) self.assertIn('"conflict_type": "risk_override"', combined) self.assertIn('"risk_override_present": true', combined) self.assertIn('"override_enabled": true', combined) self.assertIn('"override_trigger_present": true', combined) def test_pipeline_degraded_directional_input_is_not_reported_as_consensus(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_risk_override=True)) ctx = AgentContext(query="test", stock_code="600519") captured_messages = [] def fake_run_agent_loop(messages, **kwargs): captured_messages.append(messages) return SimpleNamespace( success=True, content=self._dashboard_json(decision_type="buy"), total_tokens=11, tool_calls_log=[], models_used=["test/model"], ) technical = self._OpinionStage("technical", signal="buy", confidence=0.8) intel = self._FailedStage("intel", error="news source failed") decision = self._decision_agent() with patch.object(orch, "_build_agent_chain", return_value=[technical, intel, decision]): with patch("src.agent.runner.parse_dashboard_json", side_effect=lambda raw: json.loads(raw)): with patch("src.agent.agents.base_agent.run_agent_loop", side_effect=fake_run_agent_loop): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertEqual(ctx.meta["degraded_stages"], [ {"stage_name": "intel", "status": "failed", "non_critical": True} ]) combined = "\n".join( str(message.get("content", "")) for messages in captured_messages for message in messages ) self.assertEqual(combined.count("## Agent Disagreement Summary"), 1) self.assertIn('"conflict_type": "partial_bullish_with_degraded_inputs"', combined) self.assertIn('"decision_path_hint": "state_degraded_inputs_before_any_bullish_lean"', combined) self.assertIn('"stage_name": "intel"', combined) self.assertIn('"non_critical": true', combined) self.assertNotIn('"conflict_type": "aligned_bullish"', combined) def test_pipeline_specialist_failure_uses_runtime_non_critical_contract_in_summary(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_risk_override=True)) orch.mode = "specialist" ctx = AgentContext(query="test", stock_code="600519") captured_messages = [] def fake_run_agent_loop(messages, **kwargs): captured_messages.append(messages) return SimpleNamespace( success=True, content=self._dashboard_json(decision_type="sell"), total_tokens=11, tool_calls_log=[], models_used=["test/model"], ) technical = self._OpinionStage("technical", signal="sell", confidence=0.8) intel = self._OpinionStage("intel", signal="hold", confidence=0.5) risk = self._OpinionStage("risk", signal="hold", confidence=0.5) specialist = self._FailedStage("chan_theory", error="specialist failed") decision = self._decision_agent() with patch.object(orch, "_build_agent_chain", return_value=[technical, intel, risk, decision]): with patch.object(orch, "_build_specialist_agents", return_value=[specialist]): with patch.object(orch, "_aggregate_skill_opinions", return_value=None): with patch("src.agent.runner.parse_dashboard_json", side_effect=lambda raw: json.loads(raw)): with patch("src.agent.agents.base_agent.run_agent_loop", side_effect=fake_run_agent_loop): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertEqual(ctx.meta["degraded_stages"], [ {"stage_name": "chan_theory", "status": "failed", "non_critical": True} ]) combined = "\n".join( str(message.get("content", "")) for messages in captured_messages for message in messages ) self.assertEqual(combined.count("## Agent Disagreement Summary"), 1) self.assertIn('"conflict_type": "partial_bearish_with_degraded_inputs"', combined) self.assertIn('"stage_name": "chan_theory"', combined) self.assertIn('"non_critical_stage_present": true', combined) self.assertIn('"non_critical": true', combined) def test_execute_pipeline_skips_stage_when_remaining_budget_below_minimum(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=20)) ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台") technical = MagicMock(agent_name="technical") def _run_technical(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="technical", signal="buy", confidence=0.8, reasoning="技术面结构未出现明显拐点,趋势偏强。", raw_data={"ma_alignment": "bullish", "trend_score": 82, "volume_status": "normal"}, )) return self._stage_result("technical") technical.run.side_effect = _run_technical intel = MagicMock(agent_name="intel", tool_names=["news_search"]) intel.run.side_effect = AssertionError("intel should be skipped due to budget guard") times = iter([0.0, 0.2, 0.3, 14.6, 14.7]) def _next_time(): return next(times, 100.0) with patch.object(orch, "_build_agent_chain", return_value=[technical, intel]): with patch("src.agent.orchestrator.time.time", side_effect=_next_time): result = orch._execute_pipeline(ctx) self.assertTrue(result.success) self.assertIsNotNone(result.dashboard) self.assertIsNotNone(result.content) self.assertIn("insufficient budget", (result.error or "").lower()) self.assertIn("[降级结果]", result.dashboard["analysis_summary"]) technical.run.assert_called_once() intel.run.assert_not_called() def test_execute_pipeline_skips_toolless_decision_with_low_remaining_budget(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=20)) ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台") technical = MagicMock(agent_name="technical") def _run_technical(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="technical", signal="buy", confidence=0.8, reasoning="技术面结构未出现明显拐点,趋势偏强。", raw_data={"ma_alignment": "bullish", "trend_score": 82, "volume_status": "normal"}, )) return self._stage_result("technical") technical.run.side_effect = _run_technical decision = MagicMock(agent_name="decision", tool_names=[]) def _run_decision(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="decision", signal="buy", confidence=0.87, reasoning="综合技术与情绪判断,倾向于买入。", )) return self._stage_result("decision") decision.run.side_effect = _run_decision times = iter([0.0, 0.2, 0.3, 14.6, 14.7]) def _next_time(): return next(times, 100.0) with patch.object(orch, "_build_agent_chain", return_value=[technical, decision]): with patch("src.agent.orchestrator.time.time", side_effect=_next_time): result = orch._execute_pipeline(ctx) self.assertTrue(result.success) self.assertIsNotNone(result.content) self.assertIn("insufficient budget", (result.error or "").lower()) self.assertEqual(result.total_steps, 1) technical.run.assert_called_once() decision.run.assert_not_called() def test_execute_pipeline_first_stage_still_runs_when_timeout_short(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=10)) ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台") technical = MagicMock(agent_name="technical") technical.run.side_effect = lambda run_ctx, progress_callback=None: self._stage_result("technical") times = iter([0.0, 0.2, 0.3, 0.4, 0.5]) def _next_time(): return next(times, 1.0) with patch.object(orch, "_build_agent_chain", return_value=[technical]): with patch("src.agent.orchestrator.time.time", side_effect=_next_time): result = orch._execute_pipeline(ctx) self.assertIsNotNone(result.error) self.assertEqual(result.total_steps, 1) technical.run.assert_called_once() self.assertNotIn("insufficient budget", (result.error or "").lower()) def test_execute_pipeline_times_out_after_stage(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=1)) agent = MagicMock(agent_name="technical") agent.run.return_value = self._stage_result("technical") with patch.object(orch, "_build_agent_chain", return_value=[agent]): with patch("src.agent.orchestrator.time.time", side_effect=[0.0, 0.1, 1.2, 1.2, 1.2, 1.2]): result = orch._execute_pipeline(AgentContext(query="test")) self.assertFalse(result.success) self.assertIn("timed out", result.error) def test_execute_pipeline_timeout_after_decision_preserves_dashboard(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=1, agent_risk_override=True)) ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台") decision = MagicMock(agent_name="decision") def _run_decision(run_ctx, progress_callback=None): dashboard = { "stock_name": "贵州茅台", "decision_type": "strong_buy", "sentiment_score": 88, "operation_advice": { "no_position": "分批布局", "has_position": "继续持有", }, "analysis_summary": "趋势仍强,回踩可观察。", "dashboard": { "key_levels": { "support": 1800, "stop_loss": 1760, "resistance": 1900, } }, } run_ctx.set_data("final_dashboard", dashboard) run_ctx.add_opinion(AgentOpinion( agent_name="decision", signal="buy", confidence=0.88, reasoning="趋势仍强,回踩可观察。", raw_data=dashboard, )) return self._stage_result("decision") decision.run.side_effect = _run_decision with patch.object(orch, "_build_agent_chain", return_value=[decision]): with patch("src.agent.orchestrator.time.time", side_effect=[0.0, 0.1, 1.2, 1.2, 1.2]): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertIn("timed out", result.error) self.assertEqual(result.dashboard["decision_type"], "buy") self.assertEqual(result.dashboard["operation_advice"], "买入") self.assertEqual( result.dashboard["dashboard"]["battle_plan"]["sniper_points"]["stop_loss"], 1760.0, ) def test_execute_pipeline_timeout_after_intel_synthesizes_dashboard(self): orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=1, agent_risk_override=True)) ctx = AgentContext(query="test", stock_code="301308", stock_name="江波龙") ctx.set_data("realtime_quote", {"price": 326.17, "volume_ratio": 1.0, "turnover_rate": 6.77}) ctx.set_data("chip_distribution", {"profit_ratio": 68.8, "avg_cost": 307.67, "concentration_90": 15.28}) technical = MagicMock(agent_name="technical") intel = MagicMock(agent_name="intel") def _run_technical(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="technical", signal="buy", confidence=0.75, reasoning="强势多头排列,价格回踩 MA5。", key_levels={"support": 301.61, "resistance": 340.44, "stop_loss": 295.0}, raw_data={"ma_alignment": "bullish", "trend_score": 73, "volume_status": "normal"}, )) return self._stage_result("technical") technical.run.side_effect = _run_technical intel.run.return_value = self._stage_result("intel") with patch.object(orch, "_build_agent_chain", return_value=[technical, intel]): with patch("src.agent.orchestrator.time.time", side_effect=[0.0, 0.1, 0.2, 0.3, 1.2, 1.2, 1.2]): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertTrue(result.success) self.assertIn("timed out", result.error) self.assertEqual(result.dashboard["decision_type"], "buy") self.assertIn("降级结果", result.dashboard["analysis_summary"]) self.assertEqual( result.dashboard["dashboard"]["battle_plan"]["sniper_points"]["stop_loss"], 295.0, ) # --- Sub-agent timeout clamp regression (AGENT_*_TIMEOUT_S) --- def _make_config_with_sub_agent_timeouts(self, **kwargs): """Return a SimpleNamespace config with sub-agent timeout fields.""" defaults = { "agent_orchestrator_timeout_s": 0, "agent_technical_agent_timeout_s": 0, "agent_intel_agent_timeout_s": 0, "agent_risk_agent_timeout_s": 0, "agent_decision_agent_timeout_s": 0, "agent_portfolio_agent_timeout_s": 0, "agent_skill_agent_timeout_s": 0, "agent_risk_override": True, } defaults.update(kwargs) return SimpleNamespace(**defaults) def test_run_stage_agent_no_pipeline_budget_uses_sub_agent_limit(self): """When pipeline budget is 0 (timeout_seconds=None), sub-agent limit applies standalone.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts( agent_technical_agent_timeout_s=180, ) ) agent = MagicMock(agent_name="technical") result = self._stage_result("technical") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=None) call_kwargs = agent.run.call_args.kwargs self.assertEqual(call_kwargs["timeout_seconds"], 180) def test_run_stage_agent_pipeline_budget_larger_than_agent_limit_clamps_to_agent(self): """Pipeline remaining > sub-agent limit → use smaller agent limit.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts( agent_technical_agent_timeout_s=120, ) ) agent = MagicMock(agent_name="technical") result = self._stage_result("technical") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=300) call_kwargs = agent.run.call_args.kwargs self.assertEqual(call_kwargs["timeout_seconds"], 120) def test_run_stage_agent_pipeline_budget_smaller_than_agent_limit_uses_pipeline(self): """Pipeline remaining < sub-agent limit → use smaller pipeline remaining.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts( agent_technical_agent_timeout_s=300, ) ) agent = MagicMock(agent_name="technical") result = self._stage_result("technical") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=60) call_kwargs = agent.run.call_args.kwargs self.assertEqual(call_kwargs["timeout_seconds"], 60) def test_run_stage_agent_no_sub_agent_limit_passes_pipeline_budget_through(self): """No sub-agent limit configured (all 0) → pipeline budget passed through unchanged.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts(), ) agent = MagicMock(agent_name="technical") result = self._stage_result("technical") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=300) call_kwargs = agent.run.call_args.kwargs self.assertEqual(call_kwargs["timeout_seconds"], 300) def test_run_stage_agent_skill_agent_fallback_applies_skill_clamp(self): """Skill agents (in _skill_agent_names) use the 'skill' clamp key as fallback.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts( agent_skill_agent_timeout_s=90, ) ) orch._skill_agent_names = {"bull_trend_specialist", "volume_breakout_specialist"} agent = MagicMock(agent_name="bull_trend_specialist") result = self._stage_result("bull_trend_specialist") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=300) call_kwargs = agent.run.call_args.kwargs self.assertEqual(call_kwargs["timeout_seconds"], 90) def test_run_stage_agent_skill_agent_exact_name_match_wins_over_skill_fallback(self): """Exact agent_name match takes priority over _skill_agent_names fallback.""" orch = self._make_orchestrator( config=self._make_config_with_sub_agent_timeouts( agent_skill_agent_timeout_s=90, agent_decision_agent_timeout_s=150, ) ) orch._skill_agent_names = {"decision"} agent = MagicMock(agent_name="decision") result = self._stage_result("decision") agent.run.return_value = result orch._run_stage_agent(agent, AgentContext(query="test"), timeout_seconds=300) call_kwargs = agent.run.call_args.kwargs # Exact name "decision" → 150, not skill fallback 90 self.assertEqual(call_kwargs["timeout_seconds"], 150) def test_run_wraps_orchestrator_result(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() fake_result = OrchestratorResult(success=True, content="done", total_steps=2, total_tokens=11, model="x") with patch.object(orch, "_execute_pipeline", return_value=fake_result): result = orch.run("Analyze 600519") self.assertTrue(result.success) self.assertEqual(result.content, "done") self.assertEqual(result.total_steps, 2) def test_chat_loads_prior_history_into_context(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() history = [ {"role": "user", "content": "之前的问题"}, {"role": "assistant", "content": "之前的回答"}, ] captured = {} def fake_execute(ctx, parse_dashboard=False, progress_callback=None): captured["history"] = ctx.meta.get("conversation_history") return OrchestratorResult(success=True, content="assistant reply") with patch.object(orch, "_execute_pipeline", side_effect=fake_execute): with patch("src.agent.orchestrator.build_visible_chat_history", return_value=history): with patch("src.agent.conversation.conversation_manager.get_or_create"): with patch("src.agent.conversation.conversation_manager.add_message"): orch.chat("hello", "session-1") self.assertEqual(captured["history"], history) def test_chat_uses_compressed_history_builder(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() with patch.object(orch, "_execute_pipeline", return_value=OrchestratorResult(success=True, content="ok")): with patch("src.agent.orchestrator.build_visible_chat_history", return_value=[]) as build_history: with patch("src.agent.conversation.conversation_manager.get_or_create"): with patch("src.agent.conversation.conversation_manager.add_message"): orch.chat("hello", "session-1") build_history.assert_called_once() self.assertEqual(build_history.call_args.args[0], "session-1") self.assertIs(build_history.call_args.args[1], orch.llm_adapter) def test_chat_resolves_scope_and_stores_it_for_multi_agent_chain(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() captured = {} def fake_execute(ctx, parse_dashboard=False, progress_callback=None): captured["ctx"] = ctx return OrchestratorResult(success=True, content="assistant reply") with patch.object(orch, "_execute_pipeline", side_effect=fake_execute): with patch("src.agent.orchestrator.build_visible_chat_history", return_value=[]): with patch("src.agent.conversation.conversation_manager.get_or_create"): with patch("src.agent.conversation.conversation_manager.add_message"): orch.chat( "换成 AAPL 看看", "session-1", context={ "stock_code": "600519", "stock_name": "匿名标的", "previous_analysis_summary": {"summary": "old"}, }, ) ctx = captured["ctx"] self.assertEqual(ctx.stock_code, "AAPL") self.assertEqual(ctx.stock_name, "") self.assertNotIn("previous_analysis_summary", ctx.meta) self.assertEqual(ctx.meta["stock_scope"].mode, "switch") self.assertEqual(ctx.meta["stock_scope"].expected_stock_code, "AAPL") def test_chat_does_not_read_or_write_provider_trace(self): from src.agent.orchestrator import OrchestratorResult DatabaseManager.reset_instance() Config.reset_instance() db = DatabaseManager(db_url="sqlite:///:memory:") session_id = "multi-agent-trace-boundary" user_id = db.save_conversation_message(session_id, "user", "previous question") assistant_id = db.save_conversation_message(session_id, "assistant", "previous answer") db.save_agent_provider_turn( session_id=session_id, run_id="run-existing", provider="deepseek", model="deepseek/deepseek-chat", anchor_user_message_id=user_id, anchor_assistant_message_id=assistant_id, messages=[ { "role": "assistant", "reasoning_content": "reasoning", "tool_calls": [{"id": "call_1", "name": "echo", "arguments": {}}], }, {"role": "tool", "tool_call_id": "call_1", "content": "tool-result"}, ], contains_reasoning=True, contains_tool_calls=True, contains_thinking_blocks=False, must_roundtrip=True, estimated_tokens=10, ) orch = self._make_orchestrator() try: with patch.object(orch, "_execute_pipeline", return_value=OrchestratorResult(success=True, content="ok")): with patch("src.agent.orchestrator.build_visible_chat_history", return_value=[]) as build_history: with patch.object(db, "get_agent_provider_turns", wraps=db.get_agent_provider_turns) as get_turns: result = orch.chat("hello", session_id) self.assertTrue(result.success) build_history.assert_called_once() get_turns.assert_not_called() rows = db.get_agent_provider_turns(session_id) self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["run_id"], "run-existing") finally: DatabaseManager.reset_instance() Config.reset_instance() def test_chat_persists_user_and_assistant_messages(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() fake_result = OrchestratorResult(success=True, content="assistant reply") with patch.object(orch, "_execute_pipeline", return_value=fake_result): with patch("src.agent.conversation.conversation_manager.add_message") as add_message: result = orch.chat("hello", "session-1") self.assertTrue(result.success) self.assertEqual(add_message.call_count, 2) add_message.assert_any_call("session-1", "user", "hello") add_message.assert_any_call("session-1", "assistant", "assistant reply") def test_chat_persists_failure_message(self): from src.agent.orchestrator import OrchestratorResult orch = self._make_orchestrator() fake_result = OrchestratorResult(success=False, error="boom") with patch.object(orch, "_execute_pipeline", return_value=fake_result): with patch("src.agent.conversation.conversation_manager.add_message") as add_message: result = orch.chat("hello", "session-2") self.assertFalse(result.success) add_message.assert_any_call("session-2", "assistant", "[分析失败] boom") def test_execute_pipeline_fails_when_dashboard_parse_fails(self): orch = self._make_orchestrator() ctx = AgentContext(query="test", stock_code="600519") decision = MagicMock(agent_name="decision") def fake_run(pipeline_ctx, progress_callback=None): pipeline_ctx.set_data("final_dashboard_raw", "not valid json") return self._stage_result("decision") decision.run.side_effect = fake_run with patch.object(orch, "_build_agent_chain", return_value=[decision]): result = orch._execute_pipeline(ctx, parse_dashboard=True) self.assertFalse(result.success) self.assertEqual(result.error, "Failed to parse dashboard JSON from agent response") def test_execute_pipeline_chat_prefers_free_form_response(self): orch = self._make_orchestrator() ctx = AgentContext(query="请总结一下", stock_code="600519") ctx.meta["response_mode"] = "chat" decision = MagicMock(agent_name="decision") def fake_run(pipeline_ctx, progress_callback=None): pipeline_ctx.set_data("final_dashboard", {"decision_type": "buy", "analysis_summary": "json dashboard"}) pipeline_ctx.set_data("final_response_text", "这是自然语言回复") return self._stage_result("decision", raw_text="这是自然语言回复") decision.run.side_effect = fake_run with patch.object(orch, "_build_agent_chain", return_value=[decision]): result = orch._execute_pipeline(ctx, parse_dashboard=False) self.assertTrue(result.success) self.assertEqual(result.content, "这是自然语言回复") def test_strategy_agents_are_selected_after_technical_stage(self): orch = self._make_orchestrator() orch.mode = "specialist" ctx = AgentContext(query="分析600519", stock_code="600519") ctx.meta["response_mode"] = "chat" technical = MagicMock(agent_name="technical") def _run_technical(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="technical", signal="buy", confidence=0.8, reasoning="trend ok", raw_data={"ma_alignment": "bullish", "trend_score": 78, "volume_status": "normal"}, )) return self._stage_result("technical") technical.run.side_effect = _run_technical intel = MagicMock(agent_name="intel") intel.run.return_value = self._stage_result("intel") risk = MagicMock(agent_name="risk") risk.run.return_value = self._stage_result("risk") strategy = MagicMock(agent_name="strategy_bull_trend") def _run_strategy(run_ctx, progress_callback=None): run_ctx.add_opinion(AgentOpinion( agent_name="strategy_bull_trend", signal="buy", confidence=0.7, reasoning="strategy ok", )) return self._stage_result("strategy_bull_trend") strategy.run.side_effect = _run_strategy decision = MagicMock(agent_name="decision") decision.run.return_value = self._stage_result("decision", raw_text="final answer") def _build_specialist_agents(run_ctx): self.assertTrue(any(op.agent_name == "technical" for op in run_ctx.opinions)) return [strategy] with patch.object(orch, "_build_agent_chain", return_value=[technical, intel, risk, decision]): with patch.object(orch, "_build_specialist_agents", side_effect=_build_specialist_agents) as build_specialist_agents: result = orch._execute_pipeline(ctx, parse_dashboard=False) self.assertTrue(result.success) self.assertEqual(result.content, "final answer") build_specialist_agents.assert_called_once() strategy.run.assert_called_once() class TestDecisionAgentChatMode(unittest.TestCase): """Test DecisionAgent chat-mode output path.""" def test_post_process_stores_free_form_response(self): from src.agent.agents.decision_agent import DecisionAgent agent = DecisionAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) ctx = AgentContext(query="帮我总结一下", stock_code="600519") ctx.meta["response_mode"] = "chat" ctx.add_opinion(AgentOpinion(agent_name="technical", signal="buy", confidence=0.8, reasoning="趋势偏强")) opinion = agent.post_process(ctx, "建议继续观察量价配合,分批参与。") self.assertIsNotNone(opinion) self.assertEqual(ctx.get_data("final_response_text"), "建议继续观察量价配合,分批参与。") self.assertIsNone(ctx.get_data("final_dashboard")) self.assertEqual(opinion.signal, "buy") def test_decision_agent_prompt_requires_phase_decision(self): from src.agent.agents.decision_agent import DecisionAgent agent = DecisionAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) prompt = agent.system_prompt(AgentContext(query="分析 600519", stock_code="600519")) self.assertIn("phase_decision", prompt) self.assertIn("watch_conditions", prompt) self.assertIn("data_limitations", prompt) self.assertIn("confidence_level", prompt) class TestTechnicalAgentSkillPolicy(unittest.TestCase): """TechnicalAgent should only receive the legacy trend baseline for implicit/default runs.""" def test_prompt_omits_legacy_default_policy_when_explicit_skill_selected(self): from src.agent.agents.technical_agent import TechnicalAgent agent = TechnicalAgent( tool_registry=MagicMock(), llm_adapter=MagicMock(), skill_instructions="### 技能 1: 缠论", technical_skill_policy="", ) prompt = agent.system_prompt(AgentContext(query="分析 600519", stock_code="600519")) self.assertNotIn("Bias from MA5 < 2%", prompt) self.assertIn("### 技能 1: 缠论", prompt) def test_prompt_includes_legacy_default_policy_for_implicit_default_run(self): from src.agent.agents.technical_agent import TechnicalAgent from src.agent.skills.defaults import TECHNICAL_SKILL_RULES_EN agent = TechnicalAgent( tool_registry=MagicMock(), llm_adapter=MagicMock(), skill_instructions="### 技能 1: 默认多头趋势", technical_skill_policy=TECHNICAL_SKILL_RULES_EN, ) prompt = agent.system_prompt(AgentContext(query="分析 600519", stock_code="600519")) self.assertIn("Bias from MA5 < 2%", prompt) self.assertIn("### 技能 1: 默认多头趋势", prompt) class TestBaseAgentMessageAssembly(unittest.TestCase): """Test BaseAgent message assembly helpers.""" @staticmethod def _make_agent(): from src.agent.agents.base_agent import BaseAgent class DummyAgent(BaseAgent): agent_name = "dummy" def system_prompt(self, ctx: AgentContext) -> str: return "system" def build_user_message(self, ctx: AgentContext) -> str: return "current turn" return DummyAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) def test_build_messages_includes_conversation_history(self): agent = self._make_agent() ctx = AgentContext(query="hello") ctx.meta["conversation_history"] = [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old answer"}, ] messages = agent._build_messages(ctx) self.assertEqual(messages[1], {"role": "user", "content": "old question"}) self.assertEqual(messages[2], {"role": "assistant", "content": "old answer"}) self.assertEqual(messages[-1], {"role": "user", "content": "current turn"}) def test_build_messages_injects_market_phase_before_cached_data(self): agent = self._make_agent() ctx = AgentContext(query="hello", stock_code="600519") ctx.meta["market_phase_context"] = { "market": "cn", "phase": "intraday", "market_local_time": "2026-03-27T10:00:00+08:00", "effective_daily_bar_date": "2026-03-26", "is_partial_bar": True, "minutes_to_close": 300, } ctx.meta["analysis_context_pack_summary"] = "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n" ctx.set_data("realtime_quote", {"price": 1880.0}) messages = agent._build_messages(ctx) phase_indexes = [ idx for idx, message in enumerate(messages) if "市场阶段上下文" in message.get("content", "") ] cached_indexes = [ idx for idx, message in enumerate(messages) if "[Pre-fetched: realtime_quote]" in message.get("content", "") ] pack_indexes = [ idx for idx, message in enumerate(messages) if "分析上下文包摘要" in message.get("content", "") ] self.assertEqual(len(phase_indexes), 1) self.assertEqual(len(pack_indexes), 1) self.assertEqual(len(cached_indexes), 1) self.assertLess(phase_indexes[0], pack_indexes[0]) self.assertLess(pack_indexes[0], cached_indexes[0]) phase_message = messages[phase_indexes[0]] self.assertEqual(phase_message["role"], "user") self.assertIn("盘中", phase_message["content"]) self.assertIn("不得当作完整日线复盘", phase_message["content"]) self.assertNotIn("market_phase_context", phase_message["content"]) self.assertNotIn("is_partial_bar", phase_message["content"]) pack_message = messages[pack_indexes[0]] self.assertEqual(pack_message["role"], "user") self.assertNotIn("analysis_context_pack_summary", pack_message["content"]) def test_run_passes_stock_scope_from_context_meta_to_shared_runner(self): from src.agent.runner import RunLoopResult agent = self._make_agent() ctx = AgentContext(query="hello", stock_code="600519") ctx.meta["stock_scope"] = StockScope( expected_stock_code="600519", allowed_stock_codes={"600519"}, ) with patch( "src.agent.agents.base_agent.run_agent_loop", return_value=RunLoopResult(success=True, content="ok"), ) as run_loop: result = agent.run(ctx) self.assertEqual(result.status, StageStatus.COMPLETED) self.assertIs(run_loop.call_args.kwargs["stock_scope"], ctx.meta["stock_scope"]) # ============================================================ # EventMonitor serialization # ============================================================ class TestEventMonitor(unittest.TestCase): """Test EventMonitor serialize/deserialize round-trip.""" def test_round_trip(self): from src.agent.events import EventMonitor, PriceAlert, PriceChangeAlert, VolumeAlert monitor = EventMonitor() monitor.add_alert(PriceAlert(stock_code="600519", direction="above", price=1800.0)) monitor.add_alert(PriceChangeAlert(stock_code="300750", direction="down", change_pct=3.5)) monitor.add_alert(VolumeAlert(stock_code="000858", multiplier=3.0)) data = monitor.to_dict_list() self.assertEqual(len(data), 3) self.assertEqual(data[1]["alert_type"], "price_change_percent") self.assertEqual(data[1]["change_pct"], 3.5) restored = EventMonitor.from_dict_list(data) self.assertEqual(len(restored.rules), 3) self.assertEqual(restored.rules[0].stock_code, "600519") self.assertEqual(restored.rules[1].stock_code, "300750") self.assertEqual(restored.rules[2].stock_code, "000858") def test_serialization_contract_keeps_supported_rule_keys_stable(self): from src.agent.events import ( AlertStatus, EventMonitor, PriceAlert, PriceChangeAlert, VolumeAlert, ) monitor = EventMonitor() monitor.add_alert(PriceAlert(stock_code="600519", direction="above", price=1800.0)) monitor.add_alert(PriceChangeAlert(stock_code="300750", direction="down", change_pct=3.5)) monitor.add_alert(VolumeAlert(stock_code="000858", multiplier=3.0)) monitor.rules[1].status = AlertStatus.TRIGGERED monitor.rules[2].status = AlertStatus.EXPIRED data = monitor.to_dict_list() common_keys = { "stock_code", "alert_type", "description", "status", "created_at", "ttl_hours", } self.assertEqual(set(data[0]), common_keys | {"direction", "price"}) self.assertEqual(set(data[1]), common_keys | {"direction", "change_pct"}) self.assertEqual(set(data[2]), common_keys | {"multiplier"}) known_status_values = {status.value for status in AlertStatus} for entry in data: self.assertIn(entry["status"], known_status_values) restored = EventMonitor.from_dict_list(data) self.assertEqual([rule.status for rule in restored.rules], [ AlertStatus.ACTIVE, AlertStatus.TRIGGERED, AlertStatus.EXPIRED, ]) def test_remove_expired(self): import time from src.agent.events import EventMonitor, PriceAlert monitor = EventMonitor() alert = PriceAlert(stock_code="600519", direction="above", price=1800.0, ttl_hours=0.0) alert.created_at = time.time() - 3600 # 1 hour ago monitor.rules.append(alert) removed = monitor.remove_expired() self.assertEqual(removed, 1) self.assertEqual(len(monitor.rules), 0) def test_add_alert_rejects_unsupported_rule_type(self): from src.agent.events import EventMonitor, SentimentAlert monitor = EventMonitor() with self.assertRaises(ValueError): monitor.add_alert(SentimentAlert(stock_code="600519")) def test_from_dict_list_skips_unsupported_placeholder_rule_type(self): from src.agent.events import EventMonitor data = [ {"stock_code": "600519", "alert_type": "sentiment_shift"}, { "stock_code": "000858", "alert_type": "volume_spike", "multiplier": 2.5, }, ] monitor = EventMonitor.from_dict_list(data) self.assertEqual(len(monitor.rules), 1) self.assertEqual(monitor.rules[0].stock_code, "000858") def test_from_dict_list_skips_price_change_without_change_pct(self): from src.agent.events import EventMonitor data = [ { "stock_code": "300750", "alert_type": "price_change_percent", "direction": "up", } ] monitor = EventMonitor.from_dict_list(data) self.assertEqual(monitor.rules, []) class TestEventMonitorAsync(unittest.IsolatedAsyncioTestCase): """Test async EventMonitor checks offload blocking fetches.""" async def test_check_price_uses_to_thread_and_triggers(self): from src.agent.events import EventMonitor, PriceAlert monitor = EventMonitor() rule = PriceAlert(stock_code="600519", direction="above", price=1800.0) quote = SimpleNamespace(price=1810.0) with patch("src.agent.events.asyncio.to_thread", new=AsyncMock(return_value=quote)) as to_thread: triggered = await monitor._check_price(rule) self.assertIsNotNone(triggered) self.assertEqual(triggered.rule.stock_code, "600519") to_thread.assert_awaited_once() async def test_check_price_change_uses_to_thread_and_triggers(self): from src.agent.events import EventMonitor, PriceChangeAlert monitor = EventMonitor() rule = PriceChangeAlert(stock_code="300750", direction="down", change_pct=3.0) quote = SimpleNamespace(change_pct=-3.25) with patch("src.agent.events.asyncio.to_thread", new=AsyncMock(return_value=quote)) as to_thread: triggered = await monitor._check_price_change(rule) self.assertIsNotNone(triggered) self.assertEqual(triggered.rule.stock_code, "300750") self.assertEqual(triggered.current_value, -3.25) self.assertIn("current = -3.25%", triggered.message) to_thread.assert_awaited_once() async def test_check_price_change_accepts_dict_payload_alias(self): from src.agent.events import EventMonitor, PriceChangeAlert monitor = EventMonitor() rule = PriceChangeAlert(stock_code="AAPL", direction="up", change_pct=2.0) with patch("src.agent.events.asyncio.to_thread", new=AsyncMock(return_value={"pct_chg": "2.35%"})): triggered = await monitor._check_price_change(rule) self.assertIsNotNone(triggered) self.assertEqual(triggered.current_value, 2.35) async def test_realtime_rules_create_fetcher_manager_per_quote_check(self): from src.agent.events import EventMonitor, PriceAlert, PriceChangeAlert monitor = EventMonitor() monitor.add_alert(PriceAlert(stock_code="600519", direction="above", price=1800.0)) monitor.add_alert(PriceChangeAlert(stock_code="600519", direction="up", change_pct=3.0)) managers = [MagicMock(), MagicMock()] for manager in managers: manager.get_realtime_quote.return_value = SimpleNamespace(price=1810.0, change_pct=3.25) async def _run_inline(func, *args, **kwargs): return func(*args, **kwargs) with patch("data_provider.DataFetcherManager", side_effect=managers) as manager_factory, patch( "src.agent.events.asyncio.to_thread", new=_run_inline ): triggered = await monitor.check_all() self.assertEqual(manager_factory.call_count, 2) for manager in managers: manager.get_realtime_quote.assert_called_once_with("600519") self.assertEqual(len(triggered), 2) async def test_check_volume_safe_when_fetch_returns_none(self): """_check_volume must not crash when get_daily_data returns None.""" from src.agent.events import EventMonitor, VolumeAlert monitor = EventMonitor() rule = VolumeAlert(stock_code="600519", multiplier=2.0) with patch("src.agent.events.asyncio.to_thread", new=AsyncMock(return_value=None)): result = await monitor._check_volume(rule) self.assertIsNone(result) async def test_check_all_async_callback(self): """on_trigger callbacks should be properly awaited if coroutine.""" from src.agent.events import EventMonitor, PriceAlert monitor = EventMonitor() rule = PriceAlert(stock_code="600519", direction="above", price=1800.0) monitor.add_alert(rule) callback_values = [] async_cb = AsyncMock(side_effect=lambda alert: callback_values.append(alert.rule.stock_code)) monitor.on_trigger(async_cb) quote = SimpleNamespace(price=1810.0) with patch("src.agent.events.asyncio.to_thread", new=AsyncMock(return_value=quote)): triggered = await monitor.check_all() self.assertEqual(len(triggered), 1) async_cb.assert_awaited_once() class TestEventMonitorConfigIntegration(unittest.TestCase): """Test config-driven EventMonitor construction.""" def test_build_event_monitor_from_config(self): from src.agent.events import build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json='[{"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800}]', ) with patch("src.notification.NotificationService", return_value=MagicMock()): monitor = build_event_monitor_from_config(config=config) self.assertIsNotNone(monitor) self.assertEqual(len(monitor.rules), 1) self.assertEqual(monitor.rules[0].stock_code, "600519") def test_configured_event_monitor_notification_uses_alert_route(self): from src.agent.events import TriggeredAlert, build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json='[{"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800}]', ) notifier = MagicMock() notifier.send.return_value = True monitor = build_event_monitor_from_config(config=config, notifier=notifier) self.assertIsNotNone(monitor) monitor._callbacks[0](TriggeredAlert(rule=monitor.rules[0], message="hit")) notifier.send.assert_called_once() self.assertIn("hit", notifier.send.call_args.args[0]) self.assertEqual(notifier.send.call_args.kwargs["route_type"], "alert") def test_build_event_monitor_from_config_accepts_price_change_percent(self): from src.agent.events import PriceChangeAlert, build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json=( '[{"stock_code":"300750","alert_type":"price_change_percent",' '"direction":"down","change_pct":3.5}]' ), ) with patch("src.notification.NotificationService", return_value=MagicMock()): monitor = build_event_monitor_from_config(config=config) self.assertIsNotNone(monitor) self.assertEqual(len(monitor.rules), 1) self.assertIsInstance(monitor.rules[0], PriceChangeAlert) self.assertEqual(monitor.rules[0].change_pct, 3.5) def test_build_event_monitor_returns_none_on_invalid_json(self): from src.agent.events import build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json='[invalid', ) monitor = build_event_monitor_from_config(config=config) self.assertIsNone(monitor) def test_build_event_monitor_skips_invalid_rule_entries(self): from src.agent.events import build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json=( '[{"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800},' '{"stock_code":"000858","alert_type":"price_cross","status":"bad","direction":"above","price":120}]' ), ) with patch("src.notification.NotificationService", return_value=MagicMock()): monitor = build_event_monitor_from_config(config=config) self.assertIsNotNone(monitor) self.assertEqual(len(monitor.rules), 1) self.assertEqual(monitor.rules[0].stock_code, "600519") def test_build_event_monitor_skips_unsupported_rule_types(self): from src.agent.events import build_event_monitor_from_config config = SimpleNamespace( agent_event_monitor_enabled=True, agent_event_alert_rules_json=( '[{"stock_code":"600519","alert_type":"sentiment_shift"},' '{"stock_code":"000858","alert_type":"price_cross","direction":"above","price":120}]' ), ) with patch("src.notification.NotificationService", return_value=MagicMock()): monitor = build_event_monitor_from_config(config=config) self.assertIsNotNone(monitor) self.assertEqual(len(monitor.rules), 1) self.assertEqual(monitor.rules[0].stock_code, "000858") # ============================================================ # AgentMemory # ============================================================ class TestAgentMemory(unittest.TestCase): """Test AgentMemory disabled mode.""" def test_disabled_returns_neutral(self): from src.agent.memory import AgentMemory mem = AgentMemory(enabled=False) cal = mem.get_calibration("technical") self.assertFalse(cal.calibrated) self.assertAlmostEqual(cal.calibration_factor, 1.0) def test_disabled_weights_all_equal(self): from src.agent.memory import AgentMemory mem = AgentMemory(enabled=False) weights = mem.compute_strategy_weights(["a", "b", "c"]) self.assertEqual(weights, {"a": 1.0, "b": 1.0, "c": 1.0}) def test_calibrate_confidence_passthrough_when_disabled(self): from src.agent.memory import AgentMemory mem = AgentMemory(enabled=False) self.assertAlmostEqual(mem.calibrate_confidence("tech", 0.75), 0.75) def test_get_stock_history_reads_orm_records(self): from src.agent.memory import AgentMemory record = SimpleNamespace( created_at=SimpleNamespace(date=lambda: SimpleNamespace(isoformat=lambda: "2026-03-01")), raw_result=json.dumps({"decision_type": "buy", "current_price": 1880.0}), sentiment_score=72, operation_advice="买入", ) db = MagicMock() db.get_analysis_history.return_value = [record] with patch("src.storage.get_db", return_value=db): mem = AgentMemory(enabled=True) history = mem.get_stock_history("600519", limit=1) self.assertEqual(len(history), 1) self.assertEqual(history[0].signal, "buy") self.assertEqual(history[0].price_at_analysis, 1880.0) class TestBaseAgentMemoryIntegration(unittest.TestCase): """Test BaseAgent hooks for memory injection and calibration.""" @staticmethod def _make_agent(memory): from src.agent.agents.base_agent import BaseAgent class DummyAgent(BaseAgent): agent_name = "technical" def system_prompt(self, ctx): return "system" def build_user_message(self, ctx): return "user" def post_process(self, ctx, raw_text): return AgentOpinion(agent_name="technical", signal="buy", confidence=0.8, reasoning=raw_text) with patch("src.agent.agents.base_agent.AgentMemory.from_config", return_value=memory): return DummyAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) def test_memory_context_is_injected(self): entry = SimpleNamespace( date="2026-03-01", signal="buy", sentiment_score=72, price_at_analysis=1880.0, outcome_5d=0.03, outcome_20d=None, was_correct=True, ) memory = MagicMock(enabled=True) memory.get_stock_history.return_value = [entry] agent = self._make_agent(memory) ctx = AgentContext(query="test", stock_code="600519") injected = agent._inject_cached_data(ctx) self.assertIn("Memory: recent analysis history", injected) self.assertIn("signal=buy", injected) def test_market_phase_meta_is_not_injected_as_prefetched_data(self): memory = MagicMock(enabled=False) agent = self._make_agent(memory) ctx = AgentContext(query="test", stock_code="600519") ctx.meta["market_phase_context"] = {"phase": "intraday"} ctx.meta["market_structure_context"] = { "market_theme_context": {"status": "ok"}, "stock_market_position": {"status": "ok"}, } ctx.meta["analysis_context_pack_summary"] = "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n" ctx.set_data("realtime_quote", {"price": 1880.0}) injected = agent._inject_cached_data(ctx) self.assertIn("[Pre-fetched: realtime_quote]", injected) self.assertNotIn("market_phase_context", injected) self.assertNotIn("[Pre-fetched: market_phase_context]", injected) self.assertNotIn("market_structure_context", injected) self.assertNotIn("[Pre-fetched: market_structure_context]", injected) self.assertNotIn("analysis_context_pack_summary", injected) self.assertNotIn("[Pre-fetched: analysis_context_pack_summary]", injected) self.assertNotIn("分析上下文包摘要", injected) def test_memory_calibration_updates_confidence(self): memory = MagicMock(enabled=True) memory.get_stock_history.return_value = [] memory.get_calibration.return_value = SimpleNamespace( calibrated=True, calibration_factor=0.5, total_samples=40, ) agent = self._make_agent(memory) ctx = AgentContext(query="test", stock_code="600519") loop_result = SimpleNamespace( success=True, content='{"signal":"buy","confidence":0.8,"reasoning":"ok"}', total_tokens=12, tool_calls_log=[], models_used=["test/model"], ) with patch("src.agent.agents.base_agent.run_agent_loop", return_value=loop_result): result = agent.run(ctx) self.assertTrue(result.success) self.assertIsNotNone(result.opinion) self.assertAlmostEqual(result.opinion.confidence, 0.4) self.assertEqual(result.meta["memory_calibration"]["factor"], 0.5) memory.calibrate_confidence.assert_not_called() def test_strategy_memory_calibration_uses_strategy_factor(self): from src.agent.agents.base_agent import BaseAgent class DummyStrategyAgent(BaseAgent): agent_name = "strategy_chan_theory" def system_prompt(self, ctx): return "system" def build_user_message(self, ctx): return "user" def post_process(self, ctx, raw_text): return AgentOpinion(agent_name=self.agent_name, signal="buy", confidence=0.8, reasoning=raw_text) memory = MagicMock(enabled=True) memory.get_stock_history.return_value = [] memory.get_calibration.return_value = SimpleNamespace( calibrated=True, calibration_factor=0.5, total_samples=40, ) with patch("src.agent.agents.base_agent.AgentMemory.from_config", return_value=memory): agent = DummyStrategyAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) ctx = AgentContext(query="test", stock_code="600519") loop_result = SimpleNamespace( success=True, content='{"signal":"buy","confidence":0.8,"reasoning":"ok"}', total_tokens=12, tool_calls_log=[], models_used=["test/model"], ) with patch("src.agent.agents.base_agent.run_agent_loop", return_value=loop_result): result = agent.run(ctx) self.assertTrue(result.success) self.assertAlmostEqual(result.opinion.confidence, 0.4) memory.get_calibration.assert_called_once_with( agent_name="strategy_chan_theory", stock_code="600519", skill_id="chan_theory", ) class TestRiskOverride(unittest.TestCase): """Test orchestrator-level risk override integration.""" def _make_dashboard(self): return { "decision_type": "buy", "sentiment_score": 76, "operation_advice": "买入", "analysis_summary": "原始结论", "risk_warning": "原风险提示", "dashboard": { "core_conclusion": { "one_sentence": "可以参与", "signal_type": "🟢买入信号", "position_advice": { "no_position": "分批买入", "has_position": "继续持有", }, } }, } def test_risk_override_vetoes_buy_signal(self): from src.agent.orchestrator import AgentOrchestrator orch = AgentOrchestrator( tool_registry=MagicMock(), llm_adapter=MagicMock(), config=SimpleNamespace(agent_risk_override=True), ) ctx = AgentContext(query="test", stock_code="600519") ctx.set_data("final_dashboard", self._make_dashboard()) ctx.add_opinion(AgentOpinion(agent_name="decision", signal="buy", confidence=0.8, reasoning="原始结论")) ctx.add_opinion(AgentOpinion( agent_name="risk", signal="strong_sell", confidence=0.9, reasoning="重大风险", raw_data={"veto_buy": True, "reasoning": "存在重大减持风险"}, )) ctx.add_risk_flag("insider", "大股东减持", severity="high") orch._apply_risk_override(ctx) dashboard = ctx.get_data("final_dashboard") self.assertEqual(dashboard["decision_type"], "hold") self.assertLessEqual(dashboard["sentiment_score"], 59) self.assertIn("风控接管", dashboard["risk_warning"]) self.assertEqual(ctx.opinions[0].signal, "hold") def test_risk_override_normalizes_strong_buy_before_veto(self): from src.agent.orchestrator import AgentOrchestrator orch = AgentOrchestrator( tool_registry=MagicMock(), llm_adapter=MagicMock(), config=SimpleNamespace(agent_risk_override=True), ) ctx = AgentContext(query="test", stock_code="600519") dashboard = self._make_dashboard() dashboard["decision_type"] = "strong_buy" dashboard["sentiment_score"] = 92 ctx.set_data("final_dashboard", dashboard) ctx.add_opinion(AgentOpinion(agent_name="decision", signal="strong_buy", confidence=0.9, reasoning="原始结论")) ctx.add_opinion(AgentOpinion( agent_name="risk", signal="strong_sell", confidence=0.9, raw_data={"veto_buy": True, "reasoning": "存在重大风险"}, )) ctx.add_risk_flag("insider", "大股东减持", severity="high") orch._apply_risk_override(ctx) self.assertEqual(dashboard["decision_type"], "hold") self.assertEqual(ctx.opinions[0].signal, "hold") def test_risk_override_respects_disable_flag(self): from src.agent.orchestrator import AgentOrchestrator orch = AgentOrchestrator( tool_registry=MagicMock(), llm_adapter=MagicMock(), config=SimpleNamespace(agent_risk_override=False), ) ctx = AgentContext(query="test", stock_code="600519") dashboard = self._make_dashboard() ctx.set_data("final_dashboard", dashboard) ctx.add_opinion(AgentOpinion( agent_name="risk", signal="strong_sell", confidence=0.9, raw_data={"veto_buy": True}, )) ctx.add_risk_flag("insider", "大股东减持", severity="high") orch._apply_risk_override(ctx) self.assertEqual(dashboard["decision_type"], "buy") self.assertIsNone(ctx.get_data("risk_override_applied")) def test_risk_level_high_alone_does_not_override_buy_signal(self): from src.agent.orchestrator import AgentOrchestrator orch = AgentOrchestrator( tool_registry=MagicMock(), llm_adapter=MagicMock(), config=SimpleNamespace(agent_risk_override=True), ) ctx = AgentContext(query="test", stock_code="600519") dashboard = self._make_dashboard() ctx.set_data("final_dashboard", dashboard) ctx.add_opinion(AgentOpinion(agent_name="decision", signal="buy", confidence=0.8, reasoning="base")) ctx.add_opinion(AgentOpinion( agent_name="risk", signal="sell", confidence=0.9, raw_data={"risk_level": "high"}, )) orch._apply_risk_override(ctx) self.assertEqual(dashboard["decision_type"], "buy") self.assertIsNone(ctx.get_data("risk_override_applied")) # ============================================================ # ResearchCommand timeout guard # ============================================================ class TestResearchCommandTimeout(unittest.TestCase): """Verify that ResearchCommand respects the configured timeout.""" def test_research_timeout_returns_timeout_response(self): """Timed-out research results should surface the timeout response text.""" from bot.commands.research import ResearchCommand from bot.models import BotMessage cmd = ResearchCommand() msg = MagicMock(spec=BotMessage) msg.platform = "test" msg.user_id = "u1" config = SimpleNamespace( agent_deep_research_budget=30000, agent_deep_research_timeout=0.01, # 10ms — will trigger timeout litellm_model="test-model", agent_mode=True, ) with patch("bot.commands.research.get_config", return_value=config), \ patch("src.agent.factory.get_tool_registry", return_value=MagicMock()), \ patch("src.agent.llm_adapter.LLMToolAdapter", return_value=MagicMock()), \ patch("src.agent.research.ResearchAgent.research", return_value=SimpleNamespace( success=False, report="", sub_questions=["q"], findings_count=1, total_tokens=100, duration_s=0.01, error="Deep research timed out after 0.01s", timed_out=True, )): response = cmd.execute(msg, ["600519"]) self.assertIn("超时", response.text) def test_research_recognizes_five_letter_us_ticker(self): from bot.commands.research import ResearchCommand from bot.models import BotMessage cmd = ResearchCommand() msg = MagicMock(spec=BotMessage) msg.platform = "test" msg.user_id = "u1" result = SimpleNamespace( success=True, report="ok", sub_questions=["q"], findings_count=1, total_tokens=100, duration_s=1.0, error=None, timed_out=False, ) captured = {} def _capture_research(query, context=None, timeout_seconds=None): captured["query"] = query captured["context"] = context captured["timeout_seconds"] = timeout_seconds return result config = SimpleNamespace( agent_deep_research_budget=30000, agent_deep_research_timeout=1, litellm_model="test-model", agent_mode=True, ) with patch("bot.commands.research.get_config", return_value=config), \ patch("src.agent.factory.get_tool_registry", return_value=MagicMock()), \ patch("src.agent.llm_adapter.LLMToolAdapter", return_value=MagicMock()), \ patch("src.agent.research.ResearchAgent.research", side_effect=_capture_research): response = cmd.execute(msg, ["googl", "风险"]) self.assertIn("Deep Research Report", response.text) self.assertEqual(captured["context"], {"stock_code": "GOOGL", "stock_name": ""}) self.assertEqual(captured["timeout_seconds"], 1) self.assertTrue(captured["query"].startswith("[Stock: GOOGL]")) # ============================================================ # ResearchAgent filtered registry & API endpoint # ============================================================ class TestResearchAgentFilteredRegistry(unittest.TestCase): """Test that ResearchAgent._filtered_registry delegates to BaseAgent's implementation.""" def test_filtered_registry_delegates_to_base(self): from src.agent.research import ResearchAgent from src.agent.tools.registry import ToolRegistry registry = ToolRegistry() fake_tool = MagicMock() fake_tool.name = "search_stock_news" registry.register(fake_tool) llm_adapter = MagicMock() agent = ResearchAgent(tool_registry=registry, llm_adapter=llm_adapter) filtered = agent._filtered_registry() self.assertIsInstance(filtered, ToolRegistry) self.assertIsNotNone(filtered.get("search_stock_news")) def test_decompose_query_uses_shared_adapter(self): from src.agent.research import ResearchAgent llm_adapter = MagicMock() llm_adapter.call_text.return_value = SimpleNamespace( provider="gemini", content='{"questions":["Q1","Q2"]}', usage={"total_tokens": 42}, ) agent = ResearchAgent(tool_registry=MagicMock(), llm_adapter=llm_adapter) result = agent._decompose_query("分析 600519", {"stock_code": "600519"}) self.assertEqual(result["questions"], ["Q1", "Q2"]) llm_adapter.call_text.assert_called_once() def test_synthesise_report_uses_shared_adapter(self): from src.agent.research import ResearchAgent llm_adapter = MagicMock() llm_adapter.call_text.return_value = SimpleNamespace( provider="gemini", content="Final research report", usage={"total_tokens": 88}, ) agent = ResearchAgent(tool_registry=MagicMock(), llm_adapter=llm_adapter) result = agent._synthesise_report( "分析 600519", [{"question": "Q1", "content": "A1"}], {"stock_code": "600519"}, ) self.assertEqual(result["content"], "Final research report") llm_adapter.call_text.assert_called_once() def test_research_marks_synthesis_fallback_as_failure(self): from src.agent.research import ResearchAgent agent = ResearchAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) with patch.object(agent, "_decompose_query", return_value={"questions": ["Q1"], "tokens": 3}), \ patch.object(agent, "_research_sub_question", return_value={"summary": "done", "tokens": 7}), \ patch.object(agent, "_synthesise_report", return_value={"content": "fallback", "tokens": 5, "error": "boom"}): result = agent.research("分析 600519") self.assertFalse(result.success) self.assertEqual(result.error, "boom") def test_research_sub_question_marks_budget_guard_as_timeout(self): from src.agent.research import ResearchAgent agent = ResearchAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) with patch("src.agent.research.run_agent_loop", return_value=SimpleNamespace( success=False, content="", total_tokens=7, error="Agent step skipped due to insufficient budget: 3.0s remaining, minimum 8.0s required", )): result = agent._research_sub_question( "Q1", {}, 0, timeout_seconds=10, ) self.assertFalse(result["success"]) self.assertTrue(result["timed_out"]) self.assertIn("insufficient budget", (result["error"] or "").lower()) self.assertEqual(result["tokens"], 7) def test_research_returns_timeout_result_when_overall_deadline_is_exceeded(self): import time as _time from src.agent.research import ResearchAgent agent = ResearchAgent(tool_registry=MagicMock(), llm_adapter=MagicMock()) def _slow_sub_question(*args, **kwargs): _time.sleep(0.02) return {"question": "Q1", "content": "done", "tokens": 7, "success": True} with patch.object(agent, "_decompose_query", return_value={"questions": ["Q1"], "tokens": 3}), \ patch.object(agent, "_research_sub_question", side_effect=_slow_sub_question): result = agent.research("分析 600519", timeout_seconds=0.01) self.assertFalse(result.success) self.assertTrue(result.timed_out) self.assertIn("timed out", result.error) class TestAgentResearchEndpoint(unittest.IsolatedAsyncioTestCase): async def test_agent_research_returns_timeout_response(self): from api.v1.endpoints.agent import ResearchRequest, agent_research config = SimpleNamespace( litellm_model="gemini/test-model", agent_deep_research_budget=30000, agent_deep_research_timeout=1, is_agent_available=lambda: True, ) research_result = AsyncMock(return_value=SimpleNamespace( success=False, report="", sub_questions=[], findings_count=0, total_tokens=0, duration_s=1.0, error="Deep research timed out after 1s", timed_out=True, )) with ( patch("api.v1.endpoints.agent.get_config", return_value=config), patch("api.v1.endpoints.agent._run_research_in_background", new=research_result), patch("src.agent.factory.get_tool_registry", return_value=MagicMock()), patch("src.agent.llm_adapter.LLMToolAdapter", return_value=MagicMock()), ): response = await agent_research(ResearchRequest(question="600519 风险")) self.assertFalse(response.success) self.assertIn("timed out", response.error) if __name__ == '__main__': unittest.main()