已合并
fix(harness): support the creation, loading, and execution of templates and plugins #2558
fix(harness): support the creation, loading, and execution of templates and plugins #2558
已合并
zhangyao创建于 9 天前
8 个文件变更+634-13
@@ -122,6 +122,7 @@ from openjiuwen.harness.prompts import (
122 resolve_mode,122 resolve_mode,
123)123)
124from openjiuwen.harness.prompts.prompt_attachment_manager import (124from openjiuwen.harness.prompts.prompt_attachment_manager import (
125+ PromptAttachmentKind,
125 PromptAttachmentManager,126 PromptAttachmentManager,
126)127)
127from openjiuwen.harness.prompts.sections import SectionName128from 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 is189# Tools that remain visible to the model when progressive tool loading is
187# enabled. The registration switch still decides whether a tool exists at190# 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+ 
257class DeepAgent(BaseAgent):287class 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 = False304 self._auto_invoke_scheduled: bool = False
275 self._bound_session_id: Optional[str] = None305 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 = None308 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.model1975 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.model2002 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 labels2064 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 chunk2803 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 None2888 self._deep_config is not None
2791 and self._deep_config.enable_task_loop2889 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 None2933 self._deep_config is not None
2835 and self._deep_config.enable_task_loop2934 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, so238+ # Insert after already-bound package roots, before host roots. SkillUseRail
239- # workspace/skills wins over a same-named package skill. Do not write leaf239+ # 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 = False260 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+ 
416def _build_tool_specs(items: Any, *, base_dir: Path, package_root: Path) -> list[BuiltinToolSpec]:450def _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
14from openjiuwen.agent_teams.schema.deep_agent_spec import DeepAgentSpec14from openjiuwen.agent_teams.schema.deep_agent_spec import DeepAgentSpec
15from openjiuwen.harness import deep_agent as deep_agent_module15from openjiuwen.harness import deep_agent as deep_agent_module
16from openjiuwen.harness.deep_agent import DeepAgent16from openjiuwen.harness.deep_agent import DeepAgent
17+from openjiuwen.harness.resources import LoadRecord
17from openjiuwen.harness.schema.build_context import BuildContext18from openjiuwen.harness.schema.build_context import BuildContext
18from openjiuwen.harness.schema.extension_spec import AgentTemplateSpec19from openjiuwen.harness.schema.extension_spec import AgentTemplateSpec
19from tests.unit_tests.agent_teams.harness.fixtures import make_spec20from 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_uri79+ 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 template95 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@pytest.mark.asyncio526@pytest.mark.asyncio
@@ -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+@pytest.fixture
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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+@pytest.mark.asyncio
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@pytest.mark.asyncio143@pytest.mark.asyncio
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@pytest.mark.asyncio184@pytest.mark.asyncio