已合并
fix(harness): support the creation, loading, and execution of templates and plugins #2558
zhangyao创建于 9 天前
fix(harness): support the creation, loading, and execution of templates and plugins #2558
已合并
共 8 个文件变更+634-13
| @@ -122,6 +122,7 @@ from openjiuwen.harness.prompts import ( | |||
| 122 | resolve_mode, | 122 | resolve_mode, |
| 123 | ) | 123 | ) |
| 124 | from openjiuwen.harness.prompts.prompt_attachment_manager import ( | 124 | from openjiuwen.harness.prompts.prompt_attachment_manager import ( |
| 125 | + PromptAttachmentKind, | ||
| 125 | PromptAttachmentManager, | 126 | PromptAttachmentManager, |
| 126 | ) | 127 | ) |
| 127 | from openjiuwen.harness.prompts.sections import SectionName | 128 | from openjiuwen.harness.prompts.sections import SectionName |
| @@ -182,6 +183,8 @@ _DEEP_EVENTS = frozenset( | |||
| 182 | ) | 183 | ) |
| 183 | 184 | ||
| 184 | _SUB_AGENTS_DIR = "sub_agents" | 185 | _SUB_AGENTS_DIR = "sub_agents" |
| 186 | +_EXPERT_ROLE_SECTION = "expert_role" | ||
| 187 | +_EXPERT_ROLE_SOURCE = "deep_agent.agent_template" | ||
| 185 | 188 | ||
| 186 | # Tools that remain visible to the model when progressive tool loading is | 189 | # Tools that remain visible to the model when progressive tool loading is |
| 187 | # enabled. The registration switch still decides whether a tool exists at | 190 | # enabled. The registration switch still decides whether a tool exists at |
| @@ -254,6 +257,33 @@ def _render_identity_prompt(prompt_builder: SystemPromptBuilder, language: str) | |||
| 254 | return identity_section.render(language) | 257 | return identity_section.render(language) |
| 255 | 258 | ||
| 256 | 259 | ||
| 260 | +def _expert_role_load_content(role_name: str, language: str = "cn") -> str: | ||
| 261 | + """Build the model-visible load notice for one expert role.""" | ||
| 262 | + if language == "en": | ||
| 263 | + return ( | ||
| 264 | + f"The user selected the {role_name} expert. You are {role_name}. " | ||
| 265 | + "Previous expert roles are cancelled; do not use their persona or related capabilities." | ||
| 266 | + ) | ||
| 267 | + return ( | ||
| 268 | + f"用户选择了{role_name}专家,你是{role_name}。" | ||
| 269 | + "此前专家角色已取消,不再使用其角色设定和相关能力。" | ||
| 270 | + ) | ||
| 271 | + | ||
| 272 | + | ||
| 273 | +def _expert_role_unload_content(role_name: str, language: str = "cn") -> str: | ||
| 274 | + """Build the model-visible unload notice for one expert role.""" | ||
| 275 | + if language == "en": | ||
| 276 | + return ( | ||
| 277 | + f"The user cancelled the {role_name} expert selection. Immediately stop using that " | ||
| 278 | + "expert's role, workflows, and exclusive capabilities, and fall back to your default " | ||
| 279 | + "role and capabilities." | ||
| 280 | + ) | ||
| 281 | + return ( | ||
| 282 | + f"用户取消了{role_name}专家选择,立即停止使用之前该专家的角色、工作流和独有能力," | ||
| 283 | + "回退到你的默认角色和能力。" | ||
| 284 | + ) | ||
| 285 | + | ||
| 286 | + | ||
| 257 | class DeepAgent(BaseAgent): | 287 | class DeepAgent(BaseAgent): |
| 258 | """High-level agent that delegates to an internal ReActAgent.""" | 288 | """High-level agent that delegates to an internal ReActAgent.""" |
| 259 | 289 | ||
| @@ -274,6 +304,7 @@ class DeepAgent(BaseAgent): | |||
| 274 | self._auto_invoke_scheduled: bool = False | 304 | self._auto_invoke_scheduled: bool = False |
| 275 | self._bound_session_id: Optional[str] = None | 305 | self._bound_session_id: Optional[str] = None |
| 276 | self._load_records: dict[str, LoadRecord] = {} | 306 | self._load_records: dict[str, LoadRecord] = {} |
| 307 | + self._active_agent_template: tuple[str, str] | None = None | ||
| 277 | self._session_toolkit: SessionToolkit | None = None | 308 | self._session_toolkit: SessionToolkit | None = None |
| 278 | self._pending_harness_configs: List[str] = [] | 309 | self._pending_harness_configs: List[str] = [] |
| 279 | self.prompt_attachment_manager: PromptAttachmentManager = PromptAttachmentManager() | 310 | self.prompt_attachment_manager: PromptAttachmentManager = PromptAttachmentManager() |
| @@ -1943,7 +1974,9 @@ class DeepAgent(BaseAgent): | |||
| 1943 | ctx.extras["source_root"] = str(manifest_path.parent) | 1974 | ctx.extras["source_root"] = str(manifest_path.parent) |
| 1944 | ctx.extras["_parent_model"] = self.deep_config.model | 1975 | ctx.extras["_parent_model"] = self.deep_config.model |
| 1945 | parts = resolve_agent_template_parts(spec, ctx) | 1976 | parts = resolve_agent_template_parts(spec, ctx) |
| 1946 | - return await self._apply_extension_parts(parts, source_uri=str(manifest_path)) | 1977 | + record = await self._apply_extension_parts(parts, source_uri=str(manifest_path)) |
| 1978 | + self._active_agent_template = (record.load_id, spec.agent_card.name) | ||
| 1979 | + return record | ||
| 1947 | except Exception as exc: | 1980 | except Exception as exc: |
| 1948 | raise build_error( | 1981 | raise build_error( |
| 1949 | StatusCode.DEEPAGENT_LOAD_AGENT_TEMPLATE_ERROR, | 1982 | StatusCode.DEEPAGENT_LOAD_AGENT_TEMPLATE_ERROR, |
| @@ -1968,7 +2001,9 @@ class DeepAgent(BaseAgent): | |||
| 1968 | ctx = self._new_extension_context(context) | 2001 | ctx = self._new_extension_context(context) |
| 1969 | ctx.extras["_parent_model"] = self.deep_config.model | 2002 | ctx.extras["_parent_model"] = self.deep_config.model |
| 1970 | parts = resolve_agent_template_parts(spec, ctx) | 2003 | parts = resolve_agent_template_parts(spec, ctx) |
| 1971 | - return await self._apply_extension_parts(parts, source_uri=None) | 2004 | + record = await self._apply_extension_parts(parts, source_uri=None) |
| 2005 | + self._active_agent_template = (record.load_id, spec.agent_card.name) | ||
| 2006 | + return record | ||
| 1972 | except Exception as exc: | 2007 | except Exception as exc: |
| 1973 | raise build_error( | 2008 | raise build_error( |
| 1974 | StatusCode.DEEPAGENT_LOAD_AGENT_TEMPLATE_ERROR, | 2009 | StatusCode.DEEPAGENT_LOAD_AGENT_TEMPLATE_ERROR, |
| @@ -2021,6 +2056,11 @@ class DeepAgent(BaseAgent): | |||
| 2021 | return [] | 2056 | return [] |
| 2022 | labels = await unapply_extension_hot(self, owned.refs) | 2057 | labels = await unapply_extension_hot(self, owned.refs) |
| 2023 | self._load_records.pop(record.load_id, None) | 2058 | self._load_records.pop(record.load_id, None) |
| 2059 | + if ( | ||
| 2060 | + self._active_agent_template is not None | ||
| 2061 | + and self._active_agent_template[0] == record.load_id | ||
| 2062 | + ): | ||
| 2063 | + self._active_agent_template = None | ||
| 2024 | return labels | 2064 | return labels |
| 2025 | except Exception as exc: | 2065 | except Exception as exc: |
| 2026 | raise build_error( | 2066 | raise build_error( |
| @@ -2762,6 +2802,63 @@ class DeepAgent(BaseAgent): | |||
| 2762 | ): | 2802 | ): |
| 2763 | yield chunk | 2803 | yield chunk |
| 2764 | 2804 | ||
| 2805 | + async def _sync_expert_role_attachment( | ||
| 2806 | + self, | ||
| 2807 | + invoke_inputs: InvokeInputs, | ||
| 2808 | + session: Session | None, | ||
| 2809 | + ) -> None: | ||
| 2810 | + """Materialize the current AgentTemplate role onto this round's session. | ||
| 2811 | + """ | ||
| 2812 | + try: | ||
| 2813 | + session_id = ( | ||
| 2814 | + session.get_session_id() | ||
| 2815 | + if session is not None | ||
| 2816 | + else invoke_inputs.conversation_id | ||
| 2817 | + ) | ||
| 2818 | + if not session_id: | ||
| 2819 | + return | ||
| 2820 | + | ||
| 2821 | + manager = self.prompt_attachment_manager | ||
| 2822 | + language = resolve_language( | ||
| 2823 | + self._deep_config.language if self._deep_config is not None else None | ||
| 2824 | + ) | ||
| 2825 | + if self._active_agent_template is not None: | ||
| 2826 | + role_name = self._active_agent_template[1] | ||
| 2827 | + await manager.add_section( | ||
| 2828 | + session_id=session_id, | ||
| 2829 | + section=_EXPERT_ROLE_SECTION, | ||
| 2830 | + content=_expert_role_load_content(role_name, language), | ||
| 2831 | + kind=PromptAttachmentKind.RUNTIME, | ||
| 2832 | + source=_EXPERT_ROLE_SOURCE, | ||
| 2833 | + metadata={"role_name": role_name}, | ||
| 2834 | + ) | ||
| 2835 | + return | ||
| 2836 | + | ||
| 2837 | + attachments = await manager.collect_for_session(session_id) | ||
| 2838 | + current = next( | ||
| 2839 | + (item for item in attachments if item.section == _EXPERT_ROLE_SECTION), | ||
| 2840 | + None, | ||
| 2841 | + ) | ||
| 2842 | + if current is None: | ||
| 2843 | + return | ||
| 2844 | + role_name = current.metadata.get("role_name") | ||
| 2845 | + if not role_name: | ||
| 2846 | + return | ||
| 2847 | + await manager.add_section( | ||
| 2848 | + session_id=session_id, | ||
| 2849 | + section=_EXPERT_ROLE_SECTION, | ||
| 2850 | + content=_expert_role_unload_content(role_name, language), | ||
| 2851 | + kind=PromptAttachmentKind.RUNTIME, | ||
| 2852 | + source=_EXPERT_ROLE_SOURCE, | ||
| 2853 | + metadata={"role_name": role_name}, | ||
| 2854 | + ) | ||
| 2855 | + except Exception as exc: # noqa: BLE001 - role notices must not block the model | ||
| 2856 | + logger.warning( | ||
| 2857 | + "[DeepAgent] failed to sync expert_role attachment: %s", | ||
| 2858 | + exc, | ||
| 2859 | + exc_info=True, | ||
| 2860 | + ) | ||
| 2861 | + | ||
| 2765 | async def invoke( | 2862 | async def invoke( |
| 2766 | self, | 2863 | self, |
| 2767 | inputs: Any, | 2864 | inputs: Any, |
| @@ -2786,6 +2883,7 @@ class DeepAgent(BaseAgent): | |||
| 2786 | AgentCallbackEvent.BEFORE_INVOKE, | 2883 | AgentCallbackEvent.BEFORE_INVOKE, |
| 2787 | AgentCallbackEvent.AFTER_INVOKE, | 2884 | AgentCallbackEvent.AFTER_INVOKE, |
| 2788 | ): | 2885 | ): |
| 2886 | + await self._sync_expert_role_attachment(invoke_inputs, session) | ||
| 2789 | if ( | 2887 | if ( |
| 2790 | self._deep_config is not None | 2888 | self._deep_config is not None |
| 2791 | and self._deep_config.enable_task_loop | 2889 | and self._deep_config.enable_task_loop |
| @@ -2830,6 +2928,7 @@ class DeepAgent(BaseAgent): | |||
| 2830 | AgentCallbackEvent.BEFORE_INVOKE, | 2928 | AgentCallbackEvent.BEFORE_INVOKE, |
| 2831 | AgentCallbackEvent.AFTER_INVOKE, | 2929 | AgentCallbackEvent.AFTER_INVOKE, |
| 2832 | ): | 2930 | ): |
| 2931 | + await self._sync_expert_role_attachment(invoke_inputs, session) | ||
| 2833 | if ( | 2932 | if ( |
| 2834 | self._deep_config is not None | 2933 | self._deep_config is not None |
| 2835 | and self._deep_config.enable_task_loop | 2934 | and self._deep_config.enable_task_loop |
| @@ -3090,6 +3189,7 @@ class DeepAgent(BaseAgent): | |||
| 3090 | AgentCallbackEvent.BEFORE_INVOKE, | 3189 | AgentCallbackEvent.BEFORE_INVOKE, |
| 3091 | AgentCallbackEvent.AFTER_INVOKE, | 3190 | AgentCallbackEvent.AFTER_INVOKE, |
| 3092 | ): | 3191 | ): |
| 3192 | + await self._sync_expert_role_attachment(invoke_inputs, session) | ||
| 3093 | if is_resume_input: | 3193 | if is_resume_input: |
| 3094 | result = await self._run_single_round_invoke(ctx, session) | 3194 | result = await self._run_single_round_invoke(ctx, session) |
| 3095 | else: | 3195 | else: |
| @@ -235,10 +235,28 @@ async def _bind_skill(agent: DeepAgent, skill: ResolvedSkill) -> ResourceRef: | |||
| 235 | 235 | ||
| 236 | previous_enabled = None if target.enabled_skills is None else set(target.enabled_skills) | 236 | previous_enabled = None if target.enabled_skills is None else set(target.enabled_skills) |
| 237 | 237 | ||
| 238 | - # Append after host roots. SkillUseRail keeps the first loaded name, so | 238 | + # Insert after already-bound package roots, before host roots. SkillUseRail |
| 239 | - # workspace/skills wins over a same-named package skill. Do not write leaf | 239 | + # keeps the first loaded name, so earlier packages win over later ones and |
| 240 | + # all packages win over a same-named workspace skill. Do not write leaf | ||
| 240 | # names into enabled_skills: that global allow-list would hide host skills. | 241 | # names into enabled_skills: that global allow-list would hide host skills. |
| 241 | - target.skills_dir = [*previous_dirs, *(root for root in roots if root not in current_dirs)] | 242 | + package_roots: set[str] = set() |
| 243 | + for record in (getattr(agent, "_load_records", None) or {}).values(): | ||
| 244 | + for ref in record.refs: | ||
| 245 | + if ref.kind != ResourceKind.SKILL: | ||
| 246 | + continue | ||
| 247 | + path = Path(str(ref.extra.get("directory") or ref.identity)).expanduser().resolve() | ||
| 248 | + package_roots.add(str(path.parent if _is_skill_leaf_dir(path) else path)) | ||
| 249 | + package_dirs = [ | ||
| 250 | + item for item in previous_dirs if str(Path(item).expanduser().resolve()) in package_roots | ||
| 251 | + ] | ||
| 252 | + host_dirs = [ | ||
| 253 | + item for item in previous_dirs if str(Path(item).expanduser().resolve()) not in package_roots | ||
| 254 | + ] | ||
| 255 | + target.skills_dir = [ | ||
| 256 | + *package_dirs, | ||
| 257 | + *(root for root in roots if root not in current_dirs), | ||
| 258 | + *host_dirs, | ||
| 259 | + ] | ||
| 242 | target.enable_cache = False | 260 | target.enable_cache = False |
| 243 | target.clear_skills() | 261 | target.clear_skills() |
| 244 | try: | 262 | try: |
| @@ -261,7 +261,9 @@ def _load_plugin_manifest_json(manifest: Path) -> PluginSpec: | |||
| 261 | id=str(plugin_id), | 261 | id=str(plugin_id), |
| 262 | name=payload.get("name"), | 262 | name=payload.get("name"), |
| 263 | description=payload.get("description"), | 263 | description=payload.get("description"), |
| 264 | - prompt_sections=[PromptSectionSpec.model_validate(item) for item in _as_list(payload.get("prompt_sections"))], | 264 | + prompt_sections=_build_prompt_section_specs( |
| 265 | + payload.get("prompt_sections"), base_dir=base_dir, package_root=package_root | ||
| 266 | + ), | ||
| 265 | tools=_build_tool_specs(payload.get("tools"), base_dir=base_dir, package_root=package_root), | 267 | tools=_build_tool_specs(payload.get("tools"), base_dir=base_dir, package_root=package_root), |
| 266 | mcps=_build_mcp_specs(payload.get("mcps"), base_dir=base_dir, package_root=package_root), | 268 | mcps=_build_mcp_specs(payload.get("mcps"), base_dir=base_dir, package_root=package_root), |
| 267 | rails=_build_rail_specs(payload.get("rails"), base_dir=base_dir, package_root=package_root), | 269 | rails=_build_rail_specs(payload.get("rails"), base_dir=base_dir, package_root=package_root), |
| @@ -413,6 +415,38 @@ def _build_model_spec(model_ref: Any, *, base_dir: Path, package_root: Path) -> | |||
| 413 | return ModelSpec.model_validate(model_payload) | 415 | return ModelSpec.model_validate(model_payload) |
| 414 | 416 | ||
| 415 | 417 | ||
| 418 | +def _build_prompt_section_specs( | ||
| 419 | + items: Any, *, base_dir: Path, package_root: Path | ||
| 420 | +) -> list[PromptSectionSpec]: | ||
| 421 | + """Load Plugin prompt sections from ``prompt_sections/*.md`` routed by ``file``. | ||
| 422 | + | ||
| 423 | + ``name`` is the markdown stem. Inline ``name`` + ``content`` entries stay valid. | ||
| 424 | + """ | ||
| 425 | + specs: list[PromptSectionSpec] = [] | ||
| 426 | + sections_root = (package_root / "prompt_sections").resolve() | ||
| 427 | + for item in _as_list(items): | ||
| 428 | + if not isinstance(item, dict): | ||
| 429 | + raise ValueError(f"prompt_sections entry must be a mapping: {item!r}") | ||
| 430 | + if "file" not in item: | ||
| 431 | + specs.append(PromptSectionSpec.model_validate(item)) | ||
| 432 | + continue | ||
| 433 | + file_path = _resolve_new_manifest_path( | ||
| 434 | + str(item["file"]), base_dir=base_dir, package_root=package_root, must_be_dir=False | ||
| 435 | + ) | ||
| 436 | + if not file_path.is_relative_to(sections_root) or file_path.suffix.lower() != ".md": | ||
| 437 | + raise ValueError(f"prompt_sections file must be a .md under prompt_sections/: {item['file']!r}") | ||
| 438 | + payload: dict[str, Any] = { | ||
| 439 | + "name": file_path.stem, | ||
| 440 | + "content": _section_content(file_path.read_text(encoding="utf-8")), | ||
| 441 | + } | ||
| 442 | + if "priority" in item: | ||
| 443 | + payload["priority"] = item["priority"] | ||
| 444 | + if "render_params" in item: | ||
| 445 | + payload["render_params"] = item["render_params"] | ||
| 446 | + specs.append(PromptSectionSpec.model_validate(payload)) | ||
| 447 | + return specs | ||
| 448 | + | ||
| 449 | + | ||
| 416 | def _build_tool_specs(items: Any, *, base_dir: Path, package_root: Path) -> list[BuiltinToolSpec]: | 450 | def _build_tool_specs(items: Any, *, base_dir: Path, package_root: Path) -> list[BuiltinToolSpec]: |
| 417 | specs: list[BuiltinToolSpec] = [] | 451 | specs: list[BuiltinToolSpec] = [] |
| 418 | for item in _as_list(items): | 452 | for item in _as_list(items): |
| @@ -14,6 +14,7 @@ from openjiuwen.agent_teams.harness.native_harness import NativeHarness | |||
| 14 | from openjiuwen.agent_teams.schema.deep_agent_spec import DeepAgentSpec | 14 | from openjiuwen.agent_teams.schema.deep_agent_spec import DeepAgentSpec |
| 15 | from openjiuwen.harness import deep_agent as deep_agent_module | 15 | from openjiuwen.harness import deep_agent as deep_agent_module |
| 16 | from openjiuwen.harness.deep_agent import DeepAgent | 16 | from openjiuwen.harness.deep_agent import DeepAgent |
| 17 | +from openjiuwen.harness.resources import LoadRecord | ||
| 17 | from openjiuwen.harness.schema.build_context import BuildContext | 18 | from openjiuwen.harness.schema.build_context import BuildContext |
| 18 | from openjiuwen.harness.schema.extension_spec import AgentTemplateSpec | 19 | from openjiuwen.harness.schema.extension_spec import AgentTemplateSpec |
| 19 | from tests.unit_tests.agent_teams.harness.fixtures import make_spec | 20 | from tests.unit_tests.agent_teams.harness.fixtures import make_spec |
| @@ -74,8 +75,10 @@ async def test_load_agent_template_spec_uses_in_memory_resolver( | |||
| 74 | parts: object, | 75 | parts: object, |
| 75 | *, | 76 | *, |
| 76 | source_uri: str | None, | 77 | source_uri: str | None, |
| 77 | - ) -> tuple[object, str | None]: | 78 | + ) -> LoadRecord: |
| 78 | - return parts, source_uri | 79 | + captured["parts"] = parts |
| 80 | + captured["source_uri"] = source_uri | ||
| 81 | + return LoadRecord(source_uri=source_uri) | ||
| 79 | 82 | ||
| 80 | monkeypatch.setattr( | 83 | monkeypatch.setattr( |
| 81 | deep_agent_module, | 84 | deep_agent_module, |
| @@ -85,7 +88,10 @@ async def test_load_agent_template_spec_uses_in_memory_resolver( | |||
| 85 | 88 | ||
| 86 | result = await DeepAgent.load_agent_template_spec(_Host(), template) # type: ignore[arg-type] | 89 | result = await DeepAgent.load_agent_template_spec(_Host(), template) # type: ignore[arg-type] |
| 87 | 90 | ||
| 88 | - assert result == (resolved_parts, None) | 91 | + assert isinstance(result, LoadRecord) |
| 92 | + assert result.source_uri is None | ||
| 93 | + assert captured["parts"] is resolved_parts | ||
| 94 | + assert captured["source_uri"] is None | ||
| 89 | assert captured["template"] is template | 95 | assert captured["template"] is template |
| 90 | context = captured["context"] | 96 | context = captured["context"] |
| 91 | assert isinstance(context, BuildContext) | 97 | assert isinstance(context, BuildContext) |
| @@ -228,3 +228,38 @@ def test_build_mcp_specs_rejects_invalid_connector_value( | |||
| 228 | 228 | ||
| 229 | with pytest.raises(ValueError, match="connector"): | 229 | with pytest.raises(ValueError, match="connector"): |
| 230 | load_plugin_package(manifest) | 230 | load_plugin_package(manifest) |
| 231 | + | ||
| 232 | + | ||
| 233 | +def test_plugin_manifest_loads_prompt_sections_from_routed_files(tmp_path: Path) -> None: | ||
| 234 | + """Plugin prompt_sections are md files under prompt_sections/, routed by file.""" | ||
| 235 | + from openjiuwen.harness.resources.extension_loader import load_plugin_package | ||
| 236 | + | ||
| 237 | + package_dir = tmp_path / "prompt_plugin" | ||
| 238 | + sections_dir = package_dir / "prompt_sections" | ||
| 239 | + sections_dir.mkdir(parents=True) | ||
| 240 | + (sections_dir / "wellness_guidance.md").write_text("drink water", encoding="utf-8") | ||
| 241 | + (sections_dir / "safety_rules.md").write_text("do not harm", encoding="utf-8") | ||
| 242 | + (sections_dir / "draft.md").write_text("ignored until routed", encoding="utf-8") | ||
| 243 | + manifest = package_dir / "manifest.json" | ||
| 244 | + manifest.write_text( | ||
| 245 | + json.dumps( | ||
| 246 | + { | ||
| 247 | + "package_type": "plugin", | ||
| 248 | + "id": "prompt_plugin", | ||
| 249 | + "prompt_sections": [ | ||
| 250 | + {"file": "prompt_sections/wellness_guidance.md", "priority": 30}, | ||
| 251 | + {"file": "prompt_sections/safety_rules.md"}, | ||
| 252 | + ], | ||
| 253 | + } | ||
| 254 | + ), | ||
| 255 | + encoding="utf-8", | ||
| 256 | + ) | ||
| 257 | + | ||
| 258 | + spec = load_plugin_package(manifest) | ||
| 259 | + | ||
| 260 | + assert [(section.name, section.priority) for section in spec.prompt_sections] == [ | ||
| 261 | + ("wellness_guidance", 30), | ||
| 262 | + ("safety_rules", 100), | ||
| 263 | + ] | ||
| 264 | + assert spec.prompt_sections[0].content == {"cn": "drink water", "en": "drink water"} | ||
| 265 | + assert spec.prompt_sections[1].content == {"cn": "do not harm", "en": "do not harm"} | ||
| @@ -518,7 +518,9 @@ async def test_stream_single_round_branch() -> None: | |||
| 518 | chunks = [chunk async for chunk in agent.stream("stream_input")] | 518 | chunks = [chunk async for chunk in agent.stream("stream_input")] |
| 519 | 519 | ||
| 520 | assert [chunk["chunk"] for chunk in chunks] == [1, 2] | 520 | assert [chunk["chunk"] for chunk in chunks] == [1, 2] |
| 521 | - assert fake_react.stream_calls[0]["inputs"] == {"query": "stream_input"} | 521 | + assert fake_react.stream_calls[0]["inputs"] == { |
| 522 | + "query": "stream_input", | ||
| 523 | + } | ||
| 522 | 524 | ||
| 523 | 525 | ||
| 524 | 526 | ||
| @@ -0,0 +1,426 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
| 3 | +"""Unit tests for DeepAgent AgentTemplate expert_role attachments.""" | ||
| 4 | + | ||
| 5 | +# pylint: disable=protected-access | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from typing import Any | ||
| 9 | +from unittest.mock import AsyncMock, MagicMock | ||
| 10 | + | ||
| 11 | +import pytest | ||
| 12 | + | ||
| 13 | +from openjiuwen.core.context_engine.context.context import SessionModelContext | ||
| 14 | +from openjiuwen.core.context_engine.schema.config import ContextEngineConfig | ||
| 15 | +from openjiuwen.core.foundation.llm import SystemMessage, UserMessage | ||
| 16 | +from openjiuwen.core.session.agent import Session | ||
| 17 | +from openjiuwen.core.single_agent.schema.agent_card import AgentCard | ||
| 18 | +from openjiuwen.harness.deep_agent import ( | ||
| 19 | + DeepAgent, | ||
| 20 | + _EXPERT_ROLE_SECTION, | ||
| 21 | + _expert_role_load_content, | ||
| 22 | + _expert_role_unload_content, | ||
| 23 | +) | ||
| 24 | +from openjiuwen.harness.resources import LoadRecord | ||
| 25 | +from openjiuwen.harness.schema.config import DeepAgentConfig | ||
| 26 | +from openjiuwen.harness.schema.extension_spec import AgentTemplateSpec, PluginSpec | ||
| 27 | +from openjiuwen.harness.schema.interaction import RoundWorkItem | ||
| 28 | +from tests.unit_tests.harness.test_deep_agent import FakeReactAgent | ||
| 29 | + | ||
| 30 | +_SESSION_ID = "sess-a" | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def _template(name: str) -> AgentTemplateSpec: | ||
| 34 | + return AgentTemplateSpec(agent_card=AgentCard(name=name, description=f"{name} expert")) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _session(session_id: str = _SESSION_ID) -> Session: | ||
| 38 | + return Session(session_id=session_id) | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def _configured_agent( | ||
| 42 | + *, | ||
| 43 | + enable_task_loop: bool = False, | ||
| 44 | + language: str | None = "cn", | ||
| 45 | +) -> DeepAgent: | ||
| 46 | + agent = DeepAgent(AgentCard(name="deep", description="test")).configure( | ||
| 47 | + DeepAgentConfig(enable_task_loop=enable_task_loop, language=language) | ||
| 48 | + ) | ||
| 49 | + agent.set_react_agent(FakeReactAgent(), initialized=True) | ||
| 50 | + return agent | ||
| 51 | + | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def stub_extension_hot(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| 55 | + async def apply_hot(_agent: Any, _parts: Any) -> list: | ||
| 56 | + return [] | ||
| 57 | + | ||
| 58 | + async def unapply_hot(_agent: Any, _refs: Any) -> list[str]: | ||
| 59 | + return ["unapplied"] | ||
| 60 | + | ||
| 61 | + monkeypatch.setattr("openjiuwen.harness.extension_binder.apply_extension_hot", apply_hot) | ||
| 62 | + monkeypatch.setattr("openjiuwen.harness.extension_binder.unapply_extension_hot", unapply_hot) | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +async def _expert_role(agent: DeepAgent, session_id: str): | ||
| 66 | + items = await agent.prompt_attachment_manager.collect_for_session(session_id) | ||
| 67 | + return next((item for item in items if item.section == _EXPERT_ROLE_SECTION), None) | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def _empty_context(session_id: str) -> SessionModelContext: | ||
| 71 | + return SessionModelContext( | ||
| 72 | + f"ctx-{session_id}", | ||
| 73 | + session_id, | ||
| 74 | + ContextEngineConfig(), | ||
| 75 | + history_messages=[], | ||
| 76 | + processors=[], | ||
| 77 | + ) | ||
| 78 | + | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +async def test_load_a_invoke_writes_snapshot_before_user_message( | ||
| 82 | + stub_extension_hot: None, | ||
| 83 | +) -> None: | ||
| 84 | + """T-01: load A then invoke with a session materializes expert_role before UserMessage.""" | ||
| 85 | + agent = _configured_agent() | ||
| 86 | + await agent.load_agent_template_spec(_template("A")) | ||
| 87 | + | ||
| 88 | + await agent.invoke({"query": "hello"}, session=_session()) | ||
| 89 | + | ||
| 90 | + session_id = _SESSION_ID | ||
| 91 | + attachment = await _expert_role(agent, session_id) | ||
| 92 | + assert attachment is not None | ||
| 93 | + assert attachment.content == _expert_role_load_content("A") | ||
| 94 | + assert attachment.metadata.get("role_name") == "A" | ||
| 95 | + | ||
| 96 | + context = _empty_context(session_id) | ||
| 97 | + snapshot = await agent.prompt_attachment_manager.sync_to_context(context, session_id) | ||
| 98 | + user_message = UserMessage(content="hello") | ||
| 99 | + await context.add_messages(user_message) | ||
| 100 | + | ||
| 101 | + assert isinstance(snapshot, SystemMessage) | ||
| 102 | + assert context.get_messages() == [snapshot, user_message] | ||
| 103 | + assert "用户选择了A专家" in snapshot.content | ||
| 104 | + | ||
| 105 | + | ||
| 106 | + | ||
| 107 | +async def test_same_session_repeat_invoke_does_not_append_delta( | ||
| 108 | + stub_extension_hot: None, | ||
| 109 | +) -> None: | ||
| 110 | + """T-02: unchanged role on the same session does not append an attachment delta.""" | ||
| 111 | + agent = _configured_agent() | ||
| 112 | + await agent.load_agent_template_spec(_template("A")) | ||
| 113 | + await agent.invoke({"query": "first"}, session=_session()) | ||
| 114 | + | ||
| 115 | + context = _empty_context(_SESSION_ID) | ||
| 116 | + snapshot = await agent.prompt_attachment_manager.sync_to_context( | ||
| 117 | + context, _SESSION_ID | ||
| 118 | + ) | ||
| 119 | + assert snapshot is not None | ||
| 120 | + | ||
| 121 | + await agent.invoke({"query": "second"}, session=_session()) | ||
| 122 | + assert await agent.prompt_attachment_manager.sync_to_context( | ||
| 123 | + context, _SESSION_ID | ||
| 124 | + ) is None | ||
| 125 | + | ||
| 126 | + | ||
| 127 | + | ||
| 128 | +async def test_switch_a_to_b_writes_self_contained_b_load_delta( | ||
| 129 | + stub_extension_hot: None, | ||
| 130 | +) -> None: | ||
| 131 | + """T-03: A then unload A, load B yields one B load delta that cancels prior roles.""" | ||
| 132 | + agent = _configured_agent() | ||
| 133 | + record_a = await agent.load_agent_template_spec(_template("A")) | ||
| 134 | + await agent.invoke({"query": "with-a"}, session=_session()) | ||
| 135 | + | ||
| 136 | + context = _empty_context(_SESSION_ID) | ||
| 137 | + assert await agent.prompt_attachment_manager.sync_to_context( | ||
| 138 | + context, _SESSION_ID | ||
| 139 | + ) is not None | ||
| 140 | + | ||
| 141 | + await agent.unload_extension(record_a) | ||
| 142 | + await agent.load_agent_template_spec(_template("B")) | ||
| 143 | + await agent.invoke({"query": "with-b"}, session=_session()) | ||
| 144 | + | ||
| 145 | + delta = await agent.prompt_attachment_manager.sync_to_context( | ||
| 146 | + context, _SESSION_ID | ||
| 147 | + ) | ||
| 148 | + assert delta is not None | ||
| 149 | + assert "用户选择了B专家" in delta.content | ||
| 150 | + assert "此前专家角色已取消" in delta.content | ||
| 151 | + assert "用户选择了A专家" not in delta.content | ||
| 152 | + | ||
| 153 | + | ||
| 154 | + | ||
| 155 | +async def test_unload_a_then_invoke_writes_cancel_delta( | ||
| 156 | + stub_extension_hot: None, | ||
| 157 | +) -> None: | ||
| 158 | + """T-04: session that saw A receives an unload notice after A is removed.""" | ||
| 159 | + agent = _configured_agent() | ||
| 160 | + record_a = await agent.load_agent_template_spec(_template("A")) | ||
| 161 | + await agent.invoke({"query": "with-a"}, session=_session()) | ||
| 162 | + | ||
| 163 | + context = _empty_context(_SESSION_ID) | ||
| 164 | + assert await agent.prompt_attachment_manager.sync_to_context( | ||
| 165 | + context, _SESSION_ID | ||
| 166 | + ) is not None | ||
| 167 | + | ||
| 168 | + await agent.unload_extension(record_a) | ||
| 169 | + await agent.invoke({"query": "after-unload"}, session=_session()) | ||
| 170 | + | ||
| 171 | + delta = await agent.prompt_attachment_manager.sync_to_context( | ||
| 172 | + context, _SESSION_ID | ||
| 173 | + ) | ||
| 174 | + assert delta is not None | ||
| 175 | + assert "用户取消了A专家选择" in delta.content | ||
| 176 | + assert "回退到你的默认角色和能力" in delta.content | ||
| 177 | + attachment = await _expert_role(agent, _SESSION_ID) | ||
| 178 | + assert attachment is not None | ||
| 179 | + assert attachment.content == _expert_role_unload_content("A") | ||
| 180 | + | ||
| 181 | + | ||
| 182 | + | ||
| 183 | +async def test_unload_plugin_or_unknown_load_id_keeps_current_role( | ||
| 184 | + stub_extension_hot: None, | ||
| 185 | +) -> None: | ||
| 186 | + """T-05: unloading a plugin record or unknown load_id does not clear the role.""" | ||
| 187 | + agent = _configured_agent() | ||
| 188 | + template_record = await agent.load_agent_template_spec(_template("A")) | ||
| 189 | + plugin_record = await agent.load_plugin_spec(PluginSpec(id="plugin-1", name="helper")) | ||
| 190 | + | ||
| 191 | + assert agent._active_agent_template == (template_record.load_id, "A") | ||
| 192 | + | ||
| 193 | + await agent.unload_extension(plugin_record) | ||
| 194 | + assert agent._active_agent_template == (template_record.load_id, "A") | ||
| 195 | + | ||
| 196 | + unknown = LoadRecord(load_id="missing") | ||
| 197 | + assert await agent.unload_extension(unknown) == [] | ||
| 198 | + assert agent._active_agent_template == (template_record.load_id, "A") | ||
| 199 | + | ||
| 200 | + | ||
| 201 | + | ||
| 202 | +async def test_failed_template_load_keeps_role_and_writes_no_attachment( | ||
| 203 | + monkeypatch: pytest.MonkeyPatch, | ||
| 204 | +) -> None: | ||
| 205 | + """T-06: bind failure leaves the current role unchanged and writes no attachment.""" | ||
| 206 | + agent = _configured_agent() | ||
| 207 | + agent._active_agent_template = ("old-id", "OldRole") | ||
| 208 | + | ||
| 209 | + async def boom(_agent: Any, _parts: Any) -> list: | ||
| 210 | + raise RuntimeError("bind failed") | ||
| 211 | + | ||
| 212 | + monkeypatch.setattr("openjiuwen.harness.extension_binder.apply_extension_hot", boom) | ||
| 213 | + | ||
| 214 | + with pytest.raises(Exception, match="bind failed"): | ||
| 215 | + await agent.load_agent_template_spec(_template("B")) | ||
| 216 | + | ||
| 217 | + assert agent._active_agent_template == ("old-id", "OldRole") | ||
| 218 | + assert await _expert_role(agent, _SESSION_ID) is None | ||
| 219 | + | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +async def test_two_sessions_each_get_independent_snapshot( | ||
| 223 | + stub_extension_hot: None, | ||
| 224 | +) -> None: | ||
| 225 | + """T-07: two sessions each receive their own expert_role snapshot while A is active.""" | ||
| 226 | + agent = _configured_agent() | ||
| 227 | + await agent.load_agent_template_spec(_template("A")) | ||
| 228 | + | ||
| 229 | + session_one = Session(session_id="sess-one") | ||
| 230 | + session_two = Session(session_id="sess-two") | ||
| 231 | + await agent.invoke({"query": "one"}, session=session_one) | ||
| 232 | + await agent.invoke({"query": "two"}, session=session_two) | ||
| 233 | + | ||
| 234 | + first = await _expert_role(agent, "sess-one") | ||
| 235 | + second = await _expert_role(agent, "sess-two") | ||
| 236 | + assert first is not None and second is not None | ||
| 237 | + assert first.session_id == "sess-one" | ||
| 238 | + assert second.session_id == "sess-two" | ||
| 239 | + assert first.content == second.content == _expert_role_load_content("A") | ||
| 240 | + | ||
| 241 | + | ||
| 242 | + | ||
| 243 | +async def test_stream_syncs_role_change_before_inner_call( | ||
| 244 | + stub_extension_hot: None, | ||
| 245 | +) -> None: | ||
| 246 | + """T-08: stream materializes role changes before the inner agent is called.""" | ||
| 247 | + agent = _configured_agent() | ||
| 248 | + fake = agent.react_agent | ||
| 249 | + record = await agent.load_agent_template_spec(_template("A")) | ||
| 250 | + | ||
| 251 | + chunks = [chunk async for chunk in agent.stream("first", session=_session())] | ||
| 252 | + assert chunks | ||
| 253 | + assert await _expert_role(agent, _SESSION_ID) is not None | ||
| 254 | + | ||
| 255 | + await agent.unload_extension(record) | ||
| 256 | + [chunk async for chunk in agent.stream("continuation", session=_session())] | ||
| 257 | + | ||
| 258 | + attachment = await _expert_role(agent, _SESSION_ID) | ||
| 259 | + assert attachment is not None | ||
| 260 | + assert attachment.content == _expert_role_unload_content("A") | ||
| 261 | + assert len(fake.stream_calls) == 2 | ||
| 262 | + | ||
| 263 | + | ||
| 264 | + | ||
| 265 | +async def test_same_role_name_different_load_id_is_idempotent( | ||
| 266 | + stub_extension_hot: None, | ||
| 267 | +) -> None: | ||
| 268 | + """T-09: reloading the same role_name with a new load_id does not emit a delta.""" | ||
| 269 | + agent = _configured_agent() | ||
| 270 | + first = await agent.load_agent_template_spec(_template("A")) | ||
| 271 | + await agent.invoke({"query": "first"}, session=_session()) | ||
| 272 | + | ||
| 273 | + context = _empty_context(_SESSION_ID) | ||
| 274 | + assert await agent.prompt_attachment_manager.sync_to_context( | ||
| 275 | + context, _SESSION_ID | ||
| 276 | + ) is not None | ||
| 277 | + | ||
| 278 | + await agent.unload_extension(first) | ||
| 279 | + second = await agent.load_agent_template_spec(_template("A")) | ||
| 280 | + assert second.load_id != first.load_id | ||
| 281 | + await agent.invoke({"query": "reload"}, session=_session()) | ||
| 282 | + | ||
| 283 | + assert await agent.prompt_attachment_manager.sync_to_context( | ||
| 284 | + context, _SESSION_ID | ||
| 285 | + ) is None | ||
| 286 | + attachment = await _expert_role(agent, _SESSION_ID) | ||
| 287 | + assert attachment is not None | ||
| 288 | + assert attachment.content == _expert_role_load_content("A") | ||
| 289 | + | ||
| 290 | + | ||
| 291 | + | ||
| 292 | +async def test_task_loop_invoke_still_writes_expert_role( | ||
| 293 | + stub_extension_hot: None, | ||
| 294 | +) -> None: | ||
| 295 | + """T-10: enable_task_loop=True still materializes expert_role on invoke.""" | ||
| 296 | + agent = _configured_agent(enable_task_loop=True) | ||
| 297 | + await agent.load_agent_template_spec(_template("A")) | ||
| 298 | + session = Session(session_id="loop-session") | ||
| 299 | + | ||
| 300 | + result = await agent.invoke("loop_input", session=session) | ||
| 301 | + | ||
| 302 | + assert result["output"] == "echo:loop_input" | ||
| 303 | + attachment = await _expert_role(agent, "loop-session") | ||
| 304 | + assert attachment is not None | ||
| 305 | + assert attachment.content == _expert_role_load_content("A") | ||
| 306 | + | ||
| 307 | + | ||
| 308 | + | ||
| 309 | +async def test_run_one_round_writes_attachment_for_bound_session( | ||
| 310 | + stub_extension_hot: None, | ||
| 311 | + monkeypatch: pytest.MonkeyPatch, | ||
| 312 | +) -> None: | ||
| 313 | + """T-11: the interaction path materializes expert_role onto the start()-bound session.""" | ||
| 314 | + agent = _configured_agent() | ||
| 315 | + await agent.load_agent_template_spec(_template("A")) | ||
| 316 | + session = Session(session_id="interact-session") | ||
| 317 | + coordinator = MagicMock() | ||
| 318 | + controller = MagicMock() | ||
| 319 | + controller.submit_round = AsyncMock() | ||
| 320 | + controller.wait_round_completion = AsyncMock( | ||
| 321 | + return_value={"output": "ok", "result_type": "answer"} | ||
| 322 | + ) | ||
| 323 | + | ||
| 324 | + monkeypatch.setattr( | ||
| 325 | + agent, | ||
| 326 | + "prepare_interaction_task_loop", | ||
| 327 | + AsyncMock(return_value=(coordinator, controller)), | ||
| 328 | + ) | ||
| 329 | + monkeypatch.setattr(agent, "_write_round_result_to_stream", AsyncMock()) | ||
| 330 | + monkeypatch.setattr(agent, "_build_interaction_next_work", MagicMock(return_value=None)) | ||
| 331 | + monkeypatch.setattr(agent, "save_state", MagicMock()) | ||
| 332 | + monkeypatch.setattr(agent, "clear_state", MagicMock()) | ||
| 333 | + | ||
| 334 | + work = RoundWorkItem.user( | ||
| 335 | + request_id="r1", | ||
| 336 | + inputs={"query": "hello"}, | ||
| 337 | + reset_loop=False, | ||
| 338 | + ) | ||
| 339 | + await agent.run_one_round(work, "task-1", session) | ||
| 340 | + | ||
| 341 | + attachment = await _expert_role(agent, session.get_session_id()) | ||
| 342 | + assert attachment is not None | ||
| 343 | + assert attachment.content == _expert_role_load_content("A") | ||
| 344 | + assert attachment.session_id == "interact-session" | ||
| 345 | + | ||
| 346 | + | ||
| 347 | + | ||
| 348 | +async def test_direct_call_without_session_skips_expert_role_attachment( | ||
| 349 | + stub_extension_hot: None, | ||
| 350 | +) -> None: | ||
| 351 | + """T-12: Core invoke/stream without a session does not invent default_session.""" | ||
| 352 | + agent = _configured_agent() | ||
| 353 | + fake = agent.react_agent | ||
| 354 | + await agent.load_agent_template_spec(_template("A")) | ||
| 355 | + | ||
| 356 | + await agent.invoke("hello") | ||
| 357 | + | ||
| 358 | + assert fake.invoke_calls[0]["inputs"] == {"query": "hello"} | ||
| 359 | + assert fake.invoke_calls[0]["session"] is None | ||
| 360 | + assert await _expert_role(agent, "default_session") is None | ||
| 361 | + | ||
| 362 | + chunks = [chunk async for chunk in agent.stream("streamed")] | ||
| 363 | + assert chunks | ||
| 364 | + assert fake.stream_calls[0]["inputs"] == {"query": "streamed"} | ||
| 365 | + assert fake.stream_calls[0]["session"] is None | ||
| 366 | + assert await _expert_role(agent, "default_session") is None | ||
| 367 | + | ||
| 368 | + | ||
| 369 | + | ||
| 370 | +async def test_subagent_invoke_does_not_inherit_parent_role( | ||
| 371 | + stub_extension_hot: None, | ||
| 372 | +) -> None: | ||
| 373 | + """T-13: a child DeepAgent session does not receive the parent's expert_role notice.""" | ||
| 374 | + parent = _configured_agent() | ||
| 375 | + await parent.load_agent_template_spec(_template("A")) | ||
| 376 | + await parent.invoke({"query": "parent", "conversation_id": "parent-session"}) | ||
| 377 | + | ||
| 378 | + child = _configured_agent() | ||
| 379 | + await child.invoke({"query": "child", "conversation_id": "child-session"}) | ||
| 380 | + | ||
| 381 | + assert parent._active_agent_template is not None | ||
| 382 | + assert child._active_agent_template is None | ||
| 383 | + assert await _expert_role(parent, "parent-session") is not None | ||
| 384 | + assert await _expert_role(child, "child-session") is None | ||
| 385 | + assert await _expert_role(child, "parent-session") is None | ||
| 386 | + | ||
| 387 | + | ||
| 388 | +def test_expert_role_notices_follow_prompt_language() -> None: | ||
| 389 | + """Load/unload notices must render in the same cn/en pair as identity prompts.""" | ||
| 390 | + cn_load = _expert_role_load_content("A", "cn") | ||
| 391 | + en_load = _expert_role_load_content("A", "en") | ||
| 392 | + cn_unload = _expert_role_unload_content("A", "cn") | ||
| 393 | + en_unload = _expert_role_unload_content("A", "en") | ||
| 394 | + | ||
| 395 | + assert "用户选择了A专家" in cn_load | ||
| 396 | + assert "The user selected the A expert" in en_load | ||
| 397 | + assert "用户取消了A专家选择" in cn_unload | ||
| 398 | + assert "The user cancelled the A expert selection" in en_unload | ||
| 399 | + assert cn_load != en_load | ||
| 400 | + assert cn_unload != en_unload | ||
| 401 | + | ||
| 402 | + | ||
| 403 | + | ||
| 404 | +async def test_english_locale_invoke_writes_english_load_and_unload( | ||
| 405 | + stub_extension_hot: None, | ||
| 406 | +) -> None: | ||
| 407 | + """An English-locale agent must receive English runtime role notices.""" | ||
| 408 | + agent = _configured_agent(language="en") | ||
| 409 | + record = await agent.load_agent_template_spec(_template("A")) | ||
| 410 | + | ||
| 411 | + await agent.invoke({"query": "hello"}, session=_session()) | ||
| 412 | + | ||
| 413 | + load_attachment = await _expert_role(agent, _SESSION_ID) | ||
| 414 | + assert load_attachment is not None | ||
| 415 | + assert load_attachment.content == _expert_role_load_content("A", "en") | ||
| 416 | + assert "The user selected the A expert" in load_attachment.content | ||
| 417 | + assert "用户选择了" not in load_attachment.content | ||
| 418 | + | ||
| 419 | + await agent.unload_extension(record) | ||
| 420 | + await agent.invoke({"query": "after-unload"}, session=_session()) | ||
| 421 | + | ||
| 422 | + unload_attachment = await _expert_role(agent, _SESSION_ID) | ||
| 423 | + assert unload_attachment is not None | ||
| 424 | + assert unload_attachment.content == _expert_role_unload_content("A", "en") | ||
| 425 | + assert "The user cancelled the A expert selection" in unload_attachment.content | ||
| 426 | + assert "用户取消了" not in unload_attachment.content | ||
| @@ -141,7 +141,7 @@ def test_load_runtime_resources_from_manifest(tmp_path: Path): | |||
| 141 | 141 | ||
| 142 | 142 | ||
| 143 | 143 | ||
| 144 | -async def test_runtime_extension_skills_are_refreshed_and_preferred( | 144 | +async def test_runtime_extension_skill_overrides_same_named_host_skill( |
| 145 | tmp_path: Path, | 145 | tmp_path: Path, |
| 146 | ): | 146 | ): |
| 147 | old_root = tmp_path / "old_skills" | 147 | old_root = tmp_path / "old_skills" |
| @@ -176,9 +176,9 @@ async def test_runtime_extension_skills_are_refreshed_and_preferred( | |||
| 176 | assert any(ref.kind.value == "skill" for ref in record.refs) | 176 | assert any(ref.kind.value == "skill" for ref in record.refs) |
| 177 | skill_rail = next(rail for rail in agent._registered_rails if isinstance(rail, SkillUseRail)) | 177 | skill_rail = next(rail for rail in agent._registered_rails if isinstance(rail, SkillUseRail)) |
| 178 | skill_dirs = list(skill_rail.skills_dir) | 178 | skill_dirs = list(skill_rail.skills_dir) |
| 179 | - assert Path(skill_dirs[-1]).as_posix().endswith("demo_ext/skills") | 179 | + assert Path(skill_dirs[0]).as_posix().endswith("demo_ext/skills") |
| 180 | assert skill_rail.skills[0].name == "shared_skill" | 180 | assert skill_rail.skills[0].name == "shared_skill" |
| 181 | - assert Path(skill_rail.skills[0].directory).as_posix().endswith("old_skills/shared_skill") | 181 | + assert Path(skill_rail.skills[0].directory).as_posix().endswith("demo_ext/skills/shared_skill") |
| 182 | 182 | ||
| 183 | 183 | ||
| 184 | 184 | ||