"""G7/U2 — Emergency layer rule template + TaskResult extension. Verifies: - EmergencyRules.classify maps each exception type to correct error_code - TaskCancelledError raises ValueError (caller must propagate as-is) - EmergencyError.to_dict produces all 5 fields - EmergencyError.to_error_message formats suggestions as "建议:1) ... 2) ..." - Config overrides apply (suggestions, retryable, message) - TaskResult.error_struct field: default None preserves byte-for-byte to_dict() output (backward compat) - TaskResult round-trip serialization includes error_struct when set """ from datetime import datetime, timezone import pytest from agentkit.core.exceptions import ( LLMProviderError, LoopDetectedError, TaskCancelledError, TaskTimeoutError, ) from agentkit.core.fallback import ( EMPTY_LLM_RESPONSE, MAX_STEPS_REACHED, SHELL_NO_OUTPUT, EmergencyError, EmergencyRules, ) from agentkit.core.protocol import TaskResult # ── Constants unchanged (contract preservation) ────── class TestExistingConstantsUnchanged: """Existing 3 constants preserved byte-for-byte.""" def test_empty_llm_response_unchanged(self): assert "模型未返回有效内容" in EMPTY_LLM_RESPONSE assert "建议" in EMPTY_LLM_RESPONSE def test_max_steps_reached_unchanged(self): assert "已达到最大推理步数" in MAX_STEPS_REACHED def test_shell_no_output_unchanged(self): assert SHELL_NO_OUTPUT == "[命令执行成功,无输出内容]" # ── EmergencyRules.classify ────────────────────────── class TestEmergencyRulesClassify: """classify() maps exception types to EmergencyError.""" def test_timeout(self): exc = TaskTimeoutError(task_id="t1", timeout_seconds=30) err = EmergencyRules.classify(exc) assert err.error_code == "timeout" assert err.retryable is True assert "稍后重试" in err.suggestions assert "简化任务范围" in err.suggestions assert err.original_error == str(exc) def test_loop_detected(self): exc = LoopDetectedError(tool_name="shell", repetitions=3) err = EmergencyRules.classify(exc) assert err.error_code == "loop_detected" assert err.retryable is True assert "拆分任务" in err.suggestions assert "检查工具参数" in err.suggestions def test_llm_provider_error(self): exc = LLMProviderError("openai", "rate limited") err = EmergencyRules.classify(exc) assert err.error_code == "llm_failure" assert err.retryable is True assert "稍后重试" in err.suggestions assert "切换模型" in err.suggestions def test_llm_error_subclass_also_classified(self): """LLMProviderError is a subclass of LLMError; ensure isinstance check works.""" from agentkit.core.exceptions import LLMError class CustomLLMError(LLMError): pass err = EmergencyRules.classify(CustomLLMError("custom")) # CustomLLMError is NOT a LLMProviderError, falls through to generic assert err.error_code == "internal_error" def test_generic_exception_internal_error(self): err = EmergencyRules.classify(Exception("unknown boom")) assert err.error_code == "internal_error" assert err.retryable is False assert "联系管理员" in err.suggestions assert err.original_error == "unknown boom" def test_task_cancelled_raises(self): """TaskCancelledError must propagate; classify() raises ValueError.""" exc = TaskCancelledError(task_id="t1") with pytest.raises(ValueError, match="TaskCancelledError"): EmergencyRules.classify(exc) def test_subclass_of_timeout_classified(self): """Subclasses of TaskTimeoutError are classified as timeout.""" class CustomTimeout(TaskTimeoutError): def __init__(self): super().__init__(task_id="custom", timeout_seconds=10) err = EmergencyRules.classify(CustomTimeout()) assert err.error_code == "timeout" # ── EmergencyError serialization ───────────────────── class TestEmergencyErrorSerialization: """to_dict / to_error_message on EmergencyError.""" def test_to_dict_produces_all_five_fields(self): err = EmergencyError( error_code="timeout", message="任务执行超时。", suggestions=["稍后重试", "简化任务范围"], retryable=True, original_error="Task t1 timed out after 30s", ) d = err.to_dict() assert set(d.keys()) == { "error_code", "message", "suggestions", "retryable", "original_error", } assert d["error_code"] == "timeout" assert d["message"] == "任务执行超时。" assert d["suggestions"] == ["稍后重试", "简化任务范围"] assert d["retryable"] is True assert d["original_error"] == "Task t1 timed out after 30s" def test_to_dict_suggestions_list_is_copy(self): """to_dict returns a fresh list, not the internal reference.""" suggestions = ["a", "b"] err = EmergencyError( error_code="x", message="m", suggestions=suggestions, retryable=False, original_error="e", ) d = err.to_dict() assert d["suggestions"] is not suggestions d["suggestions"].append("c") assert err.suggestions == ["a", "b"] def test_to_error_message_with_suggestions(self): err = EmergencyError( error_code="timeout", message="任务执行超时。", suggestions=["稍后重试", "简化任务范围"], retryable=True, original_error="err", ) msg = err.to_error_message() assert msg.startswith("任务执行超时。建议:") assert "1) 稍后重试" in msg assert "2) 简化任务范围" in msg # Format mirrors EMPTY_LLM_RESPONSE style assert msg.endswith("。") def test_to_error_message_no_suggestions(self): err = EmergencyError( error_code="x", message="just a message", suggestions=[], retryable=False, original_error="e", ) assert err.to_error_message() == "just a message" def test_to_error_message_single_suggestion(self): err = EmergencyError( error_code="x", message="msg", suggestions=["only one"], retryable=False, original_error="e", ) msg = err.to_error_message() assert msg == "msg建议:1) only one。" # ── Config override ────────────────────────────────── class TestConfigOverride: """classify() applies per-rule config overrides.""" def test_override_suggestions(self): exc = TaskTimeoutError(task_id="t", timeout_seconds=1) cfg = {"timeout": {"suggestions": ["自定义建议 A", "自定义建议 B"]}} err = EmergencyRules.classify(exc, config=cfg) assert err.suggestions == ["自定义建议 A", "自定义建议 B"] assert err.error_code == "timeout" def test_override_retryable(self): exc = LLMProviderError("openai", "boom") cfg = {"llm_failure": {"retryable": False}} err = EmergencyRules.classify(exc, config=cfg) assert err.retryable is False def test_override_message(self): exc = LoopDetectedError(tool_name="x", repetitions=2) cfg = {"loop_detected": {"message": "循环啦!"}} err = EmergencyRules.classify(exc, config=cfg) assert err.message == "循环啦!" def test_override_internal_error_rule(self): cfg = {"internal_error": {"suggestions": ["联系客服"]}} err = EmergencyRules.classify(Exception("boom"), config=cfg) assert err.error_code == "internal_error" assert err.suggestions == ["联系客服"] def test_config_none_uses_defaults(self): err = EmergencyRules.classify(TaskTimeoutError(task_id="t", timeout_seconds=1)) assert err.error_code == "timeout" assert err.retryable is True def test_config_empty_dict_uses_defaults(self): err = EmergencyRules.classify( TaskTimeoutError(task_id="t", timeout_seconds=1), config={} ) assert err.error_code == "timeout" assert err.retryable is True # ── TaskResult.error_struct extension ──────────────── def _make_task_result( error_struct: dict | None = None, error_message: str | None = None ) -> TaskResult: now = datetime.now(timezone.utc) return TaskResult( task_id="t1", agent_name="a1", status="completed", output_data={"k": "v"}, error_message=error_message, started_at=now, completed_at=now, metrics={"m": 1}, error_struct=error_struct, ) class TestTaskResultErrorStruct: """TaskResult.error_struct field — backward-compatible extension.""" def test_default_error_struct_is_none(self): tr = _make_task_result() assert tr.error_struct is None def test_to_dict_without_error_struct_preserves_existing_shape(self): """error_struct=None → to_dict() output has NO error_struct key (byte-for-byte).""" tr = _make_task_result() d = tr.to_dict() assert "error_struct" not in d # Existing keys unchanged assert set(d.keys()) == { "task_id", "agent_name", "status", "output_data", "error_message", "started_at", "completed_at", "metrics", } def test_to_dict_with_error_struct_includes_key(self): struct = { "error_code": "timeout", "message": "超时", "suggestions": ["重试"], "retryable": True, "original_error": "boom", } tr = _make_task_result(error_struct=struct, error_message="超时建议:1) 重试。") d = tr.to_dict() assert d["error_struct"] == struct assert d["error_message"] == "超时建议:1) 重试。" def test_from_dict_round_trip_with_error_struct(self): struct = {"error_code": "loop_detected", "message": "m", "suggestions": [], "retryable": True, "original_error": "e"} tr = _make_task_result(error_struct=struct) d = tr.to_dict() restored = TaskResult.from_dict(d) assert restored.error_struct == struct def test_from_dict_without_error_struct_defaults_none(self): tr = _make_task_result() d = tr.to_dict() # Simulate legacy data without error_struct key restored = TaskResult.from_dict(d) assert restored.error_struct is None def test_error_message_and_error_struct_coexist(self): """Both fields can be set simultaneously (parallel contract per KTD2).""" struct = {"error_code": "timeout", "message": "超时", "suggestions": ["重试"], "retryable": True, "original_error": "err"} tr = _make_task_result(error_struct=struct, error_message="超时建议:1) 重试。") d = tr.to_dict() assert d["error_message"] == "超时建议:1) 重试。" assert d["error_struct"] == struct