diff --git a/MANIFEST.in b/MANIFEST.in
index bfebf92f3..33fc54410 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -7,6 +7,9 @@ recursive-include ms_agent/ *.yaml
# agent_hub cross-framework conversion templates (markdown, not yaml)
recursive-include ms_agent/agent_hub/default_configs *
+# Bundled skills directory (harness skills are not shipped right now).
+recursive-include ms_agent/skills *
+
# Include projects
recursive-include projects *
diff --git a/README.md b/README.md
index 6df9e7e47..710495799 100644
--- a/README.md
+++ b/README.md
@@ -282,7 +282,7 @@ The first run of the default agent may install additional dependencies for local
Start the terminal interface and enter a task. Use `/help` to see commands for session management and more, and `/quit` to exit:
```bash
-ms-agent tui
+ms-agent
```
diff --git a/README_ZH.md b/README_ZH.md
index 4fb19c7a7..ba91efe9e 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -277,7 +277,7 @@ export MODELSCOPE_API_KEY="your_modelscope_api_key"
启动终端交互界面,输入任务即可开始。使用 `/help` 查看会话管理等命令,使用 `/quit` 退出:
```bash
-ms-agent tui
+ms-agent
```
diff --git a/docs/en/Components/Config.md b/docs/en/Components/Config.md
index 286fd2daa..46398bc5e 100644
--- a/docs/en/Components/Config.md
+++ b/docs/en/Components/Config.md
@@ -38,7 +38,7 @@ llm:
# Large model service backend
service: modelscope
# Model id
- model: Qwen/Qwen3-235B-A22B-Instruct-2507
+ model: Qwen/Qwen3.8-Flash-Next
# Model api_key
modelscope_api_key:
# Model base_url
diff --git a/docs/en/GetStarted/CLI.md b/docs/en/GetStarted/CLI.md
index 26e5a1188..e8004963e 100644
--- a/docs/en/GetStarted/CLI.md
+++ b/docs/en/GetStarted/CLI.md
@@ -78,9 +78,10 @@ agent YAML configuration. Existing snapshots are retained when this is disabled.
## tui — Terminal UI
-Launch an interactive UI in the terminal.
+Launch an interactive UI in the terminal. With no subcommand, `ms-agent` is the same as `ms-agent tui`.
```shell
+ms-agent
ms-agent tui --config path/to/agent.yaml
```
diff --git a/docs/en/Projects/DeepResearch.md b/docs/en/Projects/DeepResearch.md
index 0189e3be7..003bdf1f4 100644
--- a/docs/en/Projects/DeepResearch.md
+++ b/docs/en/Projects/DeepResearch.md
@@ -154,12 +154,12 @@ if __name__ == '__main__':
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
)
# Get web-search engine client
@@ -243,12 +243,12 @@ if __name__ == "__main__":
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
generation_config={'extra_body': {
'enable_thinking': False
}})
diff --git a/docs/zh/Components/config.md b/docs/zh/Components/config.md
index 99f71d6b2..6391a1b2c 100644
--- a/docs/zh/Components/config.md
+++ b/docs/zh/Components/config.md
@@ -38,7 +38,7 @@ llm:
# 大模型服务 backend
service: modelscope
# 模型 id
- model: Qwen/Qwen3-235B-A22B-Instruct-2507
+ model: Qwen/Qwen3.8-Flash-Next
# 模型 api_key
modelscope_api_key:
# 模型 base_url
diff --git a/docs/zh/GetStarted/cli.md b/docs/zh/GetStarted/cli.md
index bb76be973..ec33dd633 100644
--- a/docs/zh/GetStarted/cli.md
+++ b/docs/zh/GetStarted/cli.md
@@ -73,9 +73,10 @@ ms-agent run --config path/to/agent.yaml --query "你好"
## tui — 终端交互界面
-在终端启动交互式界面。
+在终端启动交互式界面。不带子命令时,`ms-agent` 与 `ms-agent tui` 相同。
```shell
+ms-agent
ms-agent tui --config path/to/agent.yaml
```
diff --git a/docs/zh/Projects/deep-research.md b/docs/zh/Projects/deep-research.md
index 1a56eae0b..24cecebfc 100644
--- a/docs/zh/Projects/deep-research.md
+++ b/docs/zh/Projects/deep-research.md
@@ -152,12 +152,12 @@ if __name__ == '__main__':
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
)
# Get web-search engine client
@@ -241,12 +241,12 @@ if __name__ == "__main__":
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
generation_config={'extra_body': {
'enable_thinking': False
}})
diff --git a/ms_agent/agent/agent.yaml b/ms_agent/agent/agent.yaml
index 69fb3109a..0edf4bd51 100644
--- a/ms_agent/agent/agent.yaml
+++ b/ms_agent/agent/agent.yaml
@@ -1,6 +1,6 @@
llm:
service: modelscope
- model: Qwen/Qwen3-235B-A22B-Instruct-2507
+ model: Qwen/Qwen3.8-Flash-Next
modelscope_api_key:
modelscope_base_url: https://api-inference.modelscope.cn/v1
diff --git a/ms_agent/agent/base.py b/ms_agent/agent/base.py
index b576744cd..07530147e 100644
--- a/ms_agent/agent/base.py
+++ b/ms_agent/agent/base.py
@@ -53,7 +53,7 @@ def __init__(self,
self.config.output_dir = self.output_dir
except Exception:
pass
- # Merge the work-dir project patch (e.g. a persisted /model override) so
+ # Merge a work-dir ``.ms_agent/config.yaml`` pin if one exists so
# config overrides round-trip from /.ms_agent/config.yaml —
# anchored to the project (the work dir), not the config file's
# directory. This keeps running a shared/template config from picking up
diff --git a/ms_agent/agent/code_agent.py b/ms_agent/agent/code_agent.py
index 63d378867..9c84eb5b7 100644
--- a/ms_agent/agent/code_agent.py
+++ b/ms_agent/agent/code_agent.py
@@ -35,6 +35,8 @@ async def run(self, inputs: Union[str, List[Message]],
if self.load_cache:
_config, _messages = self.read_history(inputs)
if _config is not None and _messages is not None:
+ from ms_agent.utils.history_secrets import refill_history_config
+ refill_history_config(_config, getattr(self, 'output_dir', None))
self.config = _config
return _messages
messages = await self.execute_code(inputs, **kwargs)
diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py
index bcecb13f7..6effb634a 100644
--- a/ms_agent/agent/llm_agent.py
+++ b/ms_agent/agent/llm_agent.py
@@ -48,7 +48,7 @@
from ms_agent.skill.skill_tools import SkillToolSet
from ms_agent.tools import ToolManager
from ms_agent.ui.events import (ContentDelta, ContentEnd, ContextCompacted,
- ErrorRaised, ImageDelivered, PlanEntry,
+ ErrorRaised, ImageDelivered, Notice, PlanEntry,
PlanUpdated, ReasoningDelta, ReasoningEnded,
ReasoningStarted, ToolCallCompleted,
ToolCallComposing, ToolCallStarted,
@@ -72,6 +72,16 @@
# metadata (not this literal), so the placeholder never needs localization.
INTERRUPTED_PLACEHOLDER = '[interrupted]'
_INTERRUPTED_TOOL_RESULT = '[Interrupted: tool execution was cancelled]'
+# One keyless episode prints the setup paragraph once. Startup and the
+# first message share that copy. Building a client, or clearing a key,
+# ends the episode so the next one can speak.
+_credential_notice_sent = False
+
+
+def reset_credential_notice() -> None:
+ """End the current keyless episode so the next one can speak once."""
+ global _credential_notice_sent
+ _credential_notice_sent = False
def build_partial_round_records(
@@ -213,9 +223,10 @@ def _coerce_enable_snapshots_value(value: Any) -> bool:
def resolve_enable_snapshots(config: Any) -> bool:
"""Resolve whether to take automatic pre-task snapshots.
- Disabled by default for all agents. An explicit ``enable_snapshots``
- in config always wins (including string forms like ``\"false\"``
- coerced to boolean).
+ Default is off: there is no CLI/TUI rollback UI, and snapshotting
+ the work tree (especially ``$HOME``) burns disk. Set
+ ``enable_snapshots: true`` to opt in. An explicit value always wins
+ (including string forms like ``\"false\"`` coerced to boolean).
"""
if OmegaConf.is_config(config):
raw = OmegaConf.select(
@@ -266,6 +277,7 @@ def __init__(
self.callbacks: List[Callback] = []
self.tool_manager: Optional[ToolManager] = None
self.task_manager: Optional[TaskManager] = None
+ self._tools_cleaned = False
self.memory_tools: List[Memory] = []
self.rag: Optional[RAG] = None
self.knowledge_search: Optional[SirchmunkSearch] = None
@@ -335,6 +347,12 @@ def __init__(
# session sidecar or the first build). Drift against this baseline
# fires a durable update notice on the next user turn.
self._prompt_surface: Optional[Dict[str, str]] = None
+ # TUI assigns this. Called after the user turn is in hand and before
+ # it is persisted, and once before the opening create_messages.
+ # None on WebUI and every other host.
+ self._on_user_turn = None
+ self._llm_fp: Optional[str] = None
+ self._mcp_fp: Optional[str] = None
async def prepare_skills(self):
"""Initialize the skill system from config.skills.
@@ -398,6 +416,16 @@ async def prepare_skills(self):
if getattr(self, '_plugin_runtime', None) is not None:
self._plugin_runtime.skill_runtime = self._skill_runtime
self._plugin_runtime._sync_skill_runtime(self.config)
+ self._publish_skill_runtime()
+
+ def _publish_skill_runtime(self) -> None:
+ """Slash commands see Runtime, the same way they see MCP.
+
+ Disk scan stays the fallback until a session has loaded skills.
+ """
+ session = getattr(self, 'runtime', None)
+ if session is not None and self._skill_runtime is not None:
+ session._skill_runtime = self._skill_runtime
# Wire /skill-name slash commands. Without this the SkillCommandBridge
# is never registered and `/skill-id args` falls through to the model as
@@ -992,6 +1020,7 @@ async def prepare_tools(self):
from ms_agent.plugins.runtime import PluginRuntime
from ms_agent.utils.workspace_context import resolve_workspace_root
+ self._tools_cleaned = False
self.task_manager = TaskManager()
safety_guard, permission_enforcer, perm_config = self._build_permission_objects(
@@ -1032,6 +1061,15 @@ async def prepare_tools(self):
hook_registries,
)
mcp_rt = self.mcp_runtime
+ if mcp_rt is not None and getattr(self, 'runtime', None) is not None:
+ # Slash commands only see this Runtime, not the agent. The failure
+ # record /mcp list and /mcp get read lives on the MCP runtime.
+ self.runtime._mcp_runtime = mcp_rt
+ # The TUI still has to show its prompt. Waiting here would put a
+ # cold npx in front of the first keystroke. That session waits on
+ # the first message instead. A one-shot run has no later message,
+ # so it waits now, and only for a few seconds.
+ wait_for_mcp = getattr(self, '_on_user_turn', None) is None
if mcp_rt is not None and plugin_mcp_servers:
from ms_agent.config.mcp_schema import ResolvedMCPConfig
merged_servers = {
@@ -1040,7 +1078,9 @@ async def prepare_tools(self):
}
merged_servers.update(plugin_mcp_servers)
await mcp_rt.apply_config(
- ResolvedMCPConfig(mcp_servers=merged_servers))
+ ResolvedMCPConfig(mcp_servers=merged_servers),
+ wait=wait_for_mcp,
+ )
self.tool_manager = ToolManager(
self.config,
@@ -1054,7 +1094,7 @@ async def prepare_tools(self):
permission_config=perm_config,
trust_remote_code=self.trust_remote_code,
mcp_callable_check=mcp_rt.is_callable if mcp_rt else None,
- mcp_failure_handler=mcp_rt.record_failure if mcp_rt else None,
+ mcp_failure_handler=self._on_mcp_failure if mcp_rt else None,
mcp_unavailable_detail=mcp_rt.unavailable_detail
if mcp_rt else None,
mcp_success_handler=mcp_rt.record_success if mcp_rt else None,
@@ -1071,8 +1111,10 @@ async def prepare_tools(self):
if not self.runtime.session_id:
self.runtime.session_id = hook_runtime.session_id
if mcp_rt is not None and not mcp_rt.is_started:
- await mcp_rt.start()
+ await mcp_rt.start(wait=wait_for_mcp)
await self.tool_manager.connect()
+ if self.runtime is not None:
+ self.runtime.tool_manager = self.tool_manager
if mcp_rt is not None:
mcp_rt.bind_tool_manager(self.tool_manager)
await mcp_rt.sync_tools()
@@ -1081,13 +1123,36 @@ async def prepare_tools(self):
tool.set_task_manager(self.task_manager)
async def cleanup_tools(self):
- """Cleanup resources used by the tool manager."""
+ """Best-effort teardown for MCP transports and extra tools.
+
+ ``streamablehttp_client`` (MCP SDK) holds an anyio cancel scope that
+ must be exited by the same task that entered it. Cancelling that
+ owner, or letting ``CancelledError`` leak out of jupyter kernel
+ shutdown, prints a crash-like traceback on TUI ``/quit``. Those
+ errors are teardown noise — swallow them here.
+ """
+ if self._tools_cleaned:
+ return
+ self._tools_cleaned = True
+
+ async def _quiet(awaitable, label: str) -> None:
+ try:
+ await awaitable
+ except asyncio.CancelledError:
+ logger.debug('%s interrupted during cleanup', label)
+ except Exception as exc: # noqa: BLE001 - never fail the session on teardown
+ logger.debug('%s failed during cleanup: %s', label, exc)
+
if self.task_manager is not None:
- self.task_manager.kill_all()
+ try:
+ self.task_manager.kill_all()
+ except Exception: # noqa: BLE001
+ logger.debug('task_manager.kill_all failed during cleanup',
+ exc_info=True)
if self.mcp_runtime is not None:
- await self.mcp_runtime.stop()
+ await _quiet(self.mcp_runtime.stop(), 'mcp_runtime.stop')
if self.tool_manager is not None:
- await self.tool_manager.cleanup()
+ await _quiet(self.tool_manager.cleanup(), 'tool_manager.cleanup')
# Drain scheduled memory ingestion so a teardown right after the last
# turn cannot lose its write. Flush only — memory instances are shared
# across agents of the same store (SharedMemoryManager), so CLOSING
@@ -1099,8 +1164,15 @@ async def cleanup_tools(self):
if flush is not None:
try:
await flush(timeout=15)
- except Exception as e: # noqa: BLE001 - cleanup is best-effort
- logger.warning(f'memory flush on cleanup failed: {e}')
+ except (asyncio.CancelledError, Exception) as e: # noqa: BLE001
+ logger.debug('memory flush on cleanup failed: %s', e)
+ note = getattr(tool, 'user_notice', None)
+ if note:
+ self._emit_user_notice(str(note))
+ try:
+ tool.user_notice = None
+ except Exception:
+ logger.debug('clear memory notice failed', exc_info=True)
@property
def stream(self):
@@ -2403,9 +2475,192 @@ def _next_chunk(_g=_gen):
yield messages
+ def _emit_user_notice(self, text: str) -> None:
+ """One line the user can act on. Not a log-level change."""
+ if self._event_sink is not None:
+ from ms_agent.ui.events import Notice
+ self._event_sink.emit(Notice(level='warning', text=text))
+ return
+ logger.error(text)
+
+ async def _on_mcp_failure(
+ self,
+ name: str,
+ phase: str,
+ message: str,
+ *,
+ tool_name: str | None = None,
+ exc: BaseException | None = None,
+ ) -> None:
+ """Count an MCP failure and, once it is unusable, reconnect later.
+
+ The chat stays quiet. ``/mcp list`` shows the status. Quit still
+ cleans up quietly.
+ """
+ runtime = self.mcp_runtime
+ if runtime is None:
+ return
+ degraded = await runtime.record_failure(
+ name, phase, message, tool_name=tool_name, exc=exc)
+ if not degraded:
+ return
+ # Config is unchanged, so the next turn would skip MCP apply.
+ # Clear the fingerprint and name the server so that turn reconnects.
+ self._mcp_fp = None
+ pending = getattr(self, '_mcp_reconnect_names', None)
+ if pending is None:
+ pending = set()
+ self._mcp_reconnect_names = pending
+ pending.add(name)
+
def prepare_llm(self):
"""Initialize the LLM model from the configuration."""
self.llm: LLM = LLM.from_config(self.config)
+ from ms_agent.llm.runtime_token import llm_runtime_token
+ self._llm_fp = llm_runtime_token(self.config)
+ # A client exists, so the next keyless episode may speak again.
+ reset_credential_notice()
+
+ def _stub_llm_for_setup(self) -> None:
+ """Placeholder so slash commands can run before a key exists."""
+ from types import SimpleNamespace
+ model = str(
+ OmegaConf.select(self.config, 'llm.model', default='') or '')
+ self.llm = SimpleNamespace(
+ config=self.config, model=model, _setup_stub=True)
+
+ def _emit_credential_setup(self, exc: BaseException) -> None:
+ # Startup and the first message of one keyless episode share this copy.
+ global _credential_notice_sent
+ if _credential_notice_sent:
+ return
+ _credential_notice_sent = True
+ from ms_agent.llm.credentials import missing_api_key_setup_text
+ service = str(
+ OmegaConf.select(self.config, 'llm.service', default='') or '')
+ text = missing_api_key_setup_text(exc, service)
+ if self._event_sink is not None:
+ from ms_agent.ui.events import Notice
+ self._event_sink.emit(Notice(level='warning', text=text))
+ else:
+ logger.warning(text)
+
+ def _emit_pending_turn_resume(self) -> None:
+ """The parked sentence is going out now that a client can be built."""
+ from ms_agent.llm.credentials import PENDING_TURN_RESUME
+ if self._event_sink is not None:
+ from ms_agent.ui.events import Notice
+ # Not ``info``: that level is drawn as a command panel. This is
+ # one status line, same weight as the other dim notices.
+ self._event_sink.emit(
+ Notice(level='status', text=PENDING_TURN_RESUME))
+ else:
+ logger.info(PENDING_TURN_RESUME)
+
+ def _llm_credentials_present(self) -> bool:
+ """True when a client can be built, so the waiting sentence can run."""
+ from ms_agent.llm.llm import LLM
+ try:
+ LLM.from_config(self.config)
+ except Exception:
+ return False
+ return True
+
+ def _bind_memory_llm(self) -> None:
+ """Point memory tools at the client that was just built."""
+ for orch in list(getattr(self, 'memory_tools', None) or []):
+ set_llm = getattr(orch, 'set_llm', None)
+ if set_llm is None or self.llm is None:
+ continue
+ try:
+ set_llm(self.llm)
+ except Exception:
+ logger.debug('memory llm rebind skipped', exc_info=True)
+
+ async def _ensure_llm_ready(self, messages):
+ """Build a real LLM after the first prompt, looping on missing keys."""
+ from ms_agent.llm.credentials import client_rejected_missing_key
+ if not getattr(self.llm, '_setup_stub', False) and self.llm is not None:
+ return messages
+ self._credential_reprompted = False
+ waited = False
+ while True:
+ try:
+ self.prepare_llm()
+ if self.runtime is not None:
+ self.runtime.llm = self.llm
+ self._bind_memory_llm()
+ if (waited and not self._credential_reprompted
+ and isinstance(messages, str) and messages.strip()):
+ self._emit_pending_turn_resume()
+ return messages
+ except Exception as e:
+ if not (self._interactive and client_rejected_missing_key(e)):
+ raise
+ waited = True
+ self._stub_llm_for_setup()
+ if self.runtime is not None:
+ self.runtime.llm = self.llm
+ self._emit_credential_setup(e)
+ from ms_agent.command.interactive import InteractiveSession
+ session = InteractiveSession(
+ self._get_command_router(),
+ source='tui'
+ if self._input_source is not None else 'cli',
+ input_source=self._input_source,
+ event_sink=self._event_sink)
+ turn = await session.run_turn(
+ messages=None,
+ runtime=self.runtime,
+ retry_when=self._llm_credentials_present)
+ if turn.action == 'quit':
+ self.runtime.should_stop = True
+ return None
+ if turn.text:
+ messages = turn.text
+ self._pending_attachments = turn.attachments
+ self._credential_reprompted = True
+
+ async def _hold_turn_until_llm_ready(self, messages, step_end_len) -> None:
+ """A cleared key waits the same way a session that never had one does.
+
+ The line that arrived with no client is not sent and not logged.
+ The next line the user submits is, once a client can be built.
+ Slash commands keep working. Quit ends the session. The turn is
+ not failed, and the yellow setup panel is not used.
+ """
+ if not getattr(self.llm, '_setup_stub', False):
+ return
+ pending = None
+ query = ''
+ if (len(messages) > step_end_len
+ and getattr(messages[-1], 'role', None) == 'user'):
+ pending = messages[step_end_len:]
+ content = pending[-1].content
+ query = content if isinstance(content, str) else ''
+ self._pending_attachments = getattr(
+ pending[-1], 'attachments', None)
+ del messages[step_end_len:]
+ try:
+ ready = await self._ensure_llm_ready(query)
+ except Exception:
+ if pending is not None:
+ messages.extend(pending)
+ raise
+ if self.runtime.should_stop or ready is None:
+ self.runtime.should_stop = True
+ return
+ if pending is not None and not getattr(
+ self, '_credential_reprompted', False):
+ messages.extend(pending)
+ return
+ if isinstance(ready, str) and ready:
+ messages.append(
+ Message(
+ role='user',
+ content=ready,
+ attachments=getattr(self, '_pending_attachments', None)
+ or []))
def prepare_runtime(self):
"""Initialize the runtime context."""
@@ -2431,10 +2686,16 @@ def read_history(self, messages: List[Message],
config, _messages = read_history(self.output_dir, self.tag)
if config is not None and _messages is not None:
+ # The yaml on disk has no keys. Fill them from settings / mcp.json
+ # so this legacy resume does not ask for the key again.
+ from ms_agent.utils.history_secrets import refill_history_config
+ refill_history_config(config, getattr(self, 'output_dir', None))
if hasattr(config, 'runtime'):
runtime = Runtime(llm=self.llm)
runtime.from_dict(config.runtime)
delattr(config, 'runtime')
+ if self.mcp_runtime is not None:
+ runtime._mcp_runtime = self.mcp_runtime
else:
runtime = self.runtime
if _messages[-1].role == 'tool':
@@ -2672,7 +2933,14 @@ async def run_loop(self, messages: Union[List[Message], str],
# prompt below and InputCallback registration just after.
self._interactive = self._resolve_interactive(messages)
self.register_callback_from_config()
- self.prepare_llm()
+ from ms_agent.llm.credentials import client_rejected_missing_key
+ try:
+ self.prepare_llm()
+ except Exception as e:
+ if not (self._interactive and client_rejected_missing_key(e)):
+ raise
+ self._stub_llm_for_setup()
+ self._emit_credential_setup(e)
self.prepare_runtime()
await self.prepare_tools()
await self.prepare_skills()
@@ -2715,6 +2983,8 @@ async def run_loop(self, messages: Union[List[Message], str],
await self.cleanup_tools()
return
messages = turn.text
+ if self._on_user_turn is not None:
+ await self._on_user_turn(None)
# create_messages() below builds the user Message from
# this string, so hand the turn's attachments over
# out-of-band rather than widening that signature.
@@ -2730,6 +3000,11 @@ async def run_loop(self, messages: Union[List[Message], str],
'stdin, or run in an interactive terminal.')
messages = piped
+ messages = await self._ensure_llm_ready(messages)
+ if self.runtime.should_stop:
+ await self.cleanup_tools()
+ return
+
# Load history and restore state
restored_from_log = False
if self.session_log is not None:
@@ -2814,6 +3089,13 @@ async def run_loop(self, messages: Union[List[Message], str],
self.log_output('[' + message.role + ']:')
self.log_output(message.content)
while not self.runtime.should_stop:
+ # A cleared key leaves a placeholder. Wait here, before
+ # step(), the same way a session that never had a key waits.
+ if getattr(self.llm, '_setup_stub', False):
+ await self._hold_turn_until_llm_ready(
+ messages, len(messages))
+ if self.runtime.should_stop:
+ break
# Rebuild context view from SessionLog (non-destructive
# compression). This is the canonical history for the round, so
# it must run before the per-round augmentations below.
@@ -2914,6 +3196,14 @@ async def run_loop(self, messages: Union[List[Message], str],
await self.loop_callback('after_tool_call', messages)
else:
await self.after_tool_call(messages)
+ if (self._on_user_turn is not None
+ and len(messages) > step_end_len):
+ await self._on_user_turn(messages)
+ # Drop happened on this line. Hold before the line is logged,
+ # so a missing key matches a session that never had one.
+ await self._hold_turn_until_llm_ready(messages, step_end_len)
+ if self.runtime.should_stop:
+ break
resume_waiting = False
# New user turn (interactive multi-turn): attach the durable
# augmentations BEFORE the slice below persists them — same
@@ -3004,11 +3294,22 @@ async def run_loop(self, messages: Union[List[Message], str],
self.session_log.set_metadata_field('status', 'error')
except Exception:
pass
- if hasattr(self.config, 'help'):
+ # TUI/WebUI already rendered ErrorRaised. The yaml `help` blurb
+ # ("A commonly use config…") is for headless CLI, not a second
+ # crash dump in an interactive session.
+ if self._event_sink is None and hasattr(self.config, 'help'):
logger.error(
f'[{self.tag}] Runtime error, please follow the instructions:\n\n {self.config.help}'
)
raise e
+ finally:
+ # CancelledError / GeneratorExit skip the Exception handler and
+ # used to leave streamable_http owner tasks for the event-loop
+ # shutdown to cancel — that is what dumps the MCP SDK traceback.
+ try:
+ await self.cleanup_tools()
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
+ logger.debug('run_loop cleanup_tools failed', exc_info=True)
async def run(
self, messages: Union[List[Message], str], **kwargs
diff --git a/ms_agent/agent_hub/_secrets.py b/ms_agent/agent_hub/_secrets.py
index abca6d1c3..1963d5dc9 100644
--- a/ms_agent/agent_hub/_secrets.py
+++ b/ms_agent/agent_hub/_secrets.py
@@ -67,6 +67,11 @@ class Finding(NamedTuple):
_VENDOR_TOKEN_RE = re.compile(
r'(?``. A leading word stays a subcommand, so a
+ typo still prints help instead of starting the UI.
+ """
+ rest = list(argv[1:])
+ if not rest or rest[0].startswith('-'):
+ return [argv[0], 'tui', *rest]
+ return list(argv)
+
+
def run_cmd():
"""This is the entrance of the all the cli commands.
@@ -19,7 +33,7 @@ def run_cmd():
"""
parser = argparse.ArgumentParser(
'ModelScope-agent Command Line tool',
- usage='ms-agent []')
+ usage='ms-agent [] [] (no command starts tui)')
subparsers = parser.add_subparsers(
help='ModelScope-agent commands helpers')
@@ -38,7 +52,8 @@ def run_cmd():
PluginCMD.define_args(subparsers)
# unknown args will be handled in config.py
- args, unknown = parser.parse_known_args()
+ argv = _with_default_tui(sys.argv)
+ args, unknown = parser.parse_known_args(argv[1:])
if not hasattr(args, 'func'):
parser.print_help()
diff --git a/ms_agent/cli/tui.py b/ms_agent/cli/tui.py
index e23bc7c8e..60f979bf7 100644
--- a/ms_agent/cli/tui.py
+++ b/ms_agent/cli/tui.py
@@ -78,6 +78,7 @@ def execute(self):
import importlib.resources as importlib_resources
config = self.args.config
+ explicit_config = bool(config)
if not config:
# Fall back to the packaged default agent.yaml.
default_config = importlib_resources.files('ms_agent').joinpath(
@@ -94,4 +95,5 @@ def execute(self):
work_dir=self.args.work_dir,
emit_events=self.args.emit_events,
mcp_server_file=self.args.mcp_server_file,
+ explicit_config=explicit_config,
)
diff --git a/ms_agent/command/builtin/__init__.py b/ms_agent/command/builtin/__init__.py
index 1e6733a38..7d8d0996d 100644
--- a/ms_agent/command/builtin/__init__.py
+++ b/ms_agent/command/builtin/__init__.py
@@ -1,6 +1,11 @@
from ms_agent.command.builtin.config_cmds import register_config_commands
from ms_agent.command.builtin.context_cmds import register_context_commands
from ms_agent.command.builtin.info_cmds import register_info_commands
+from ms_agent.command.builtin.instruction_cmds import (
+ register_instruction_commands)
+from ms_agent.command.builtin.memory_cmds import register_memory_commands
+from ms_agent.command.builtin.resource_cmds import register_resource_commands
+from ms_agent.command.builtin.search_cmds import register_search_commands
from ms_agent.command.builtin.session_cmds import register_session_commands
from ms_agent.command.router import CommandRouter
@@ -10,3 +15,7 @@ def register_builtin_commands(router: CommandRouter) -> None:
register_info_commands(router)
register_config_commands(router)
register_context_commands(router)
+ register_resource_commands(router)
+ register_search_commands(router)
+ register_instruction_commands(router)
+ register_memory_commands(router)
diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py
index 353e1fee6..e4f69119c 100644
--- a/ms_agent/command/builtin/config_cmds.py
+++ b/ms_agent/command/builtin/config_cmds.py
@@ -3,10 +3,12 @@
from ms_agent.command.router import CommandRouter
from ms_agent.command.types import (CommandContext, CommandDef, CommandResult,
CommandResultType)
+from ms_agent.command.usage import (arg_error, contains_secret, ledger_file,
+ status_then_usage)
CMD_MODEL = CommandDef(
name='model',
- description='Show or switch the current model',
+ description='Show, switch, or manage model providers',
category='config',
)
@@ -18,16 +20,47 @@
)
+def _model_usage() -> str:
+ return (
+ 'usage:\n'
+ ' /model show current provider + model\n'
+ ' /model list saved providers\n'
+ ' /model list live [provider] fetch chat model ids; preview only\n'
+ ' /model switch model, keep current provider\n'
+ ' /model switch provider and model\n'
+ ' /model provider add [key=] [url=] [protocol=openai|anthropic] [name=]\n'
+ ' add a custom provider, or override a builtin\n'
+ ' /model provider set [key=] [url=] [protocol=] [name=]\n'
+ ' patch only the fields you pass\n'
+ ' /model provider key |clear\n'
+ ' set or clear the API key\n'
+ ' /model provider url |clear\n'
+ ' set or clear the base URL\n'
+ ' /model provider remove \n'
+ ' delete a custom provider (builtins stay)\n'
+ ' /model catalog add \n'
+ ' pin a model id on that provider list\n'
+ ' /model catalog remove \n'
+ ' drop that pinned model id (drop is an alias)\n'
+ ' /model catalog remove same, on the current provider\n'
+ ' first column of /model list, e.g. dashscope\n'
+ ' model id on that provider. A "/" is part of the id,\n'
+ ' e.g. qwen3.8-flash or Qwen/Qwen3.8-Flash-Next\n'
+ f'Saved in {ledger_file("settings.json")}.\n'
+ 'Example: /model dashscope qwen3.8-flash')
+
+
+_CLEAR = frozenset({'clear', '-', 'none'})
+
+
def _persist_model_to_config(config, new_model: str, service=None):
- """Persist the model (and optional service) change to the project patch.
-
- Writes ``llm.model`` (and ``llm.service`` when a provider switch happened)
- into ``/.ms_agent/config.yaml`` rather than mutating the
- version-controlled source YAML. Anchored to the **work dir** (``output_dir``)
- — the project — not the config file's directory, so running a shared or
- packaged template config (e.g. ``demos/``) never scatters a ``.ms_agent/``
- next to it. The work-dir patch is merged back (patch wins) on the next run.
- Returns the patch path on success, or None if no dir is known / write fails.
+ """Write a work-dir ``.ms_agent/config.yaml`` pin (not used by ``/model``).
+
+ ``/model`` does not use this. It writes the shared ``default_model`` (what
+ the next new chat starts on) and the current session's own model. A folder
+ pin would force every conversation in the work dir onto one model. This
+ helper remains for callers that explicitly want that pin; the patch still
+ outranks the global default when present.
"""
from omegaconf import OmegaConf
@@ -57,75 +90,1004 @@ def _persist_model_to_config(config, new_model: str, service=None):
return None
+def _mgr():
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ return ModelSettingsManager(global_home())
+
+
+def _builtin_ids() -> set[str]:
+ from ms_agent.llm.spec import get_registry
+ return {spec.name for spec in get_registry().list_providers()}
+
+
+def _known_provider_ids() -> list[str]:
+ ids = sorted(_builtin_ids())
+ try:
+ for pid in _mgr().list_custom_providers():
+ name = str(pid)
+ if name not in ids:
+ ids.append(name)
+ except Exception:
+ pass
+ return ids
+
+
+def _model_arg_parts(arg: str) -> list[str]:
+ import shlex
+ text = (arg or '').strip()
+ if not text:
+ return []
+ try:
+ return shlex.split(text)
+ except ValueError:
+ return text.split()
+
+
+def _unknown_provider_word(arg: str) -> str | None:
+ """First word of ``/model `` when it is not a provider.
+
+ ``/model Qwen/Qwen3.8-Flash-Next`` is one model id. The slash is not a
+ separator, because many model ids contain one.
+ """
+ parts = _model_arg_parts(arg)
+ if len(parts) < 2:
+ return None
+ if _canonical_provider(parts[0]):
+ return None
+ return parts[0]
+
+
+def _canonical_provider(head: str) -> str | None:
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(head)
+ if spec is not None:
+ return spec.name
+ try:
+ for pid in _mgr().list_custom_providers():
+ if str(pid).lower() == head.lower():
+ return str(pid)
+ except Exception:
+ pass
+ return None
+
+
+def _parse_model_arg(arg: str) -> tuple[str | None, str]:
+ """Parse a switch argument into ``(provider or None, model)``.
+
+ * ``/model Qwen/Qwen3.8-Flash-Next`` — model id on the current provider.
+ A slash never selects the provider.
+ * ``/model dashscope Qwen/Qwen3.8-Flash-Next`` — first word is a known
+ provider, the rest is the model id.
+ """
+ text = (arg or '').strip()
+ parts = _model_arg_parts(text)
+ if len(parts) >= 2:
+ canon = _canonical_provider(parts[0])
+ if canon:
+ return canon, text[len(parts[0]):].strip()
+ return None, text
+
+
+def _strip_provider_prefix(service: str, model: str) -> str:
+ from ms_agent.config.model_settings import strip_provider_model_prefix
+ return strip_provider_model_prefix(service, model)
+
+
+def _repair_runtime_model(config, llm=None):
+ """Un-glue ``minimax MiniMax-M2.1`` in the live config and settings.json.
+
+ Returns ``(service, display_model)``. Display prefers the runtime
+ ``llm.model`` label when set, matching ``/model`` with no args.
+ """
+ from omegaconf import OmegaConf
+ service = str(OmegaConf.select(config, 'llm.service', default='') or '')
+ cfg_model = str(OmegaConf.select(config, 'llm.model', default='') or '')
+ live_model = str(getattr(llm, 'model', '')
+ or '') if llm is not None else ''
+ cfg_cleaned = _strip_provider_prefix(service, cfg_model)
+ live_cleaned = _strip_provider_prefix(service, live_model)
+ if cfg_model and cfg_cleaned != cfg_model:
+ OmegaConf.update(config, 'llm.model', cfg_cleaned, merge=True)
+ if llm is not None and live_model and live_cleaned != live_model:
+ try:
+ llm.model = live_cleaned
+ except Exception:
+ pass
+ try:
+ stored = _mgr().get_default_model() or ''
+ if '/' in stored:
+ pid, mid = stored.split('/', 1)
+ mid2 = _strip_provider_prefix(pid, mid)
+ if mid2 != mid:
+ _mgr().set_default_model(mid2, provider=pid)
+ except Exception:
+ pass
+ return service, live_cleaned or cfg_cleaned, cfg_cleaned or live_cleaned
+
+
+def _switch_cmd(service, model) -> str:
+ """Copyable ``/model`` line. Always ``/model ``.
+
+ A slash belongs to the model id (``Qwen/Qwen3.8-Flash-Next``). It is not
+ a provider separator, so a hint must not print ``provider/model``.
+ """
+ service = str(service or '').strip()
+ model = _strip_provider_prefix(service, model)
+ if not service:
+ return f'/model {model}'
+ return f'/model {service} {model}'
+
+
+def _default_switch_line(stored: str) -> str:
+ """``/model list`` default, as a command the user can paste back."""
+ text = str(stored or '').strip()
+ if not text:
+ return ''
+ if '/' not in text:
+ return f'/model {text}'
+ service, model = text.split('/', 1)
+ return _switch_cmd(service, model)
+
+
+def _format_pair(service, model) -> str:
+ service = str(service or '').strip() or '(none)'
+ model = _strip_provider_prefix(service, model) or '(none)'
+ return f'Provider: {service}\nModel: {model}'
+
+
+def _first_keyed_provider(exclude: str | None = None) -> str | None:
+ try:
+ mgr = _mgr()
+ except Exception:
+ return None
+ skip = (exclude or '').lower()
+ for pid, _provider, override in _iter_provider_rows(mgr):
+ if skip and pid.lower() == skip:
+ continue
+ if _effective_api_key(pid, override):
+ return pid
+ return None
+
+
+def _live_identity(llm) -> tuple[str, str, str]:
+ spec = getattr(llm, 'spec', None)
+ transport = getattr(llm, 'transport', None)
+ live_provider = str(getattr(spec, 'name', '') or '')
+ live_model = str(getattr(llm, 'model', '') or '')
+ live_url = str(getattr(transport, 'base_url', '') or '')
+ return live_provider, live_model, live_url
+
+
+def _current_model_text(service, model, llm=None) -> str:
+ raw_service = str(service or '').strip()
+ service = raw_service or '(none)'
+ model = _strip_provider_prefix(raw_service, model) or '(none)'
+ lines = [
+ f'Provider: {service}',
+ f'Model: {model}',
+ ]
+ if raw_service and model != '(none)':
+ lines.append(f'Switch: {_switch_cmd(raw_service, model)}')
+ try:
+ override = _mgr().list_custom_providers().get(raw_service) or {}
+ lines.append(
+ f'Key: {_mask_key(_effective_api_key(raw_service, override))}'
+ )
+ except Exception:
+ pass
+ live_p, live_m, live_url = _live_identity(llm)
+ if live_p or live_url:
+ lines.append(f'Live: {live_p or "?"} {live_m or model}')
+ if live_url:
+ lines.append(f'Endpoint: {live_url}')
+ if live_p and live_p.lower() != service.lower():
+ realign = _switch_cmd(live_p, model)
+ lines.append(
+ f'WARNING: the running client is {live_p}, not {service}. '
+ 'A previous /model switch updated the label but kept the old '
+ 'client (usually the new provider has no API key). '
+ f'Set a key with /model provider key {service} , or '
+ f'realign with {realign}.')
+ return '\n'.join(lines) + '\n'
+
+
+def _switch_fail_text(target_service, target_model, stay_service,
+ stay_model) -> str:
+ target_model = _strip_provider_prefix(target_service or '', target_model)
+ stay_model = _strip_provider_prefix(stay_service, stay_model)
+ same = bool(target_service and stay_service
+ and target_service.lower() == stay_service.lower())
+ lines: list[str] = []
+ if same:
+ lines.append(f'Cannot use this model: no usable API key for provider '
+ f'{target_service}.')
+ lines.append('Already on:')
+ else:
+ lines.append('Cannot switch to:')
+ lines.append(_format_pair(target_service, target_model))
+ lines.append('No usable API key for that provider.')
+ lines.append('Still on:')
+ lines.append(_format_pair(stay_service, stay_model))
+ if target_service:
+ lines.append(f'Set a key: /model provider key {target_service} ')
+ alt = _first_keyed_provider(exclude=target_service or stay_service)
+ if alt:
+ lines.append(f'Or switch to a keyed provider ({alt}):\n'
+ f' /model {alt} ')
+ elif (stay_service and target_service
+ and stay_service.lower() != target_service.lower()):
+ lines.append(
+ 'If this id is from another gateway, keep that provider:\n'
+ f' {_switch_cmd(stay_service, target_model)}')
+ return '\n'.join(lines)
+
+
+def _mask_key(value) -> str:
+ """Same redaction as the WebUI provider list.
+
+ No key stays ``missing``. Eight characters or fewer become ``****``.
+ Longer keys keep the first four and last four characters.
+ """
+ text = str(value or '').strip()
+ if not text:
+ return 'missing'
+ if len(text) <= 8:
+ return '****'
+ return f'{text[:4]}****{text[-4:]}'
+
+
+def _iter_provider_rows(mgr):
+ custom = mgr.list_custom_providers()
+ order: list[str] = []
+ rows: dict = {}
+ for provider in mgr.list_providers():
+ pid = provider['id']
+ if pid not in rows:
+ order.append(pid)
+ if pid not in rows or provider.get('overrides_builtin'):
+ rows[pid] = provider
+ for pid in order:
+ yield pid, rows[pid], custom.get(pid) or {}
+
+
+def _provider_kind(provider, override) -> str:
+ if provider.get('builtin') or provider.get('overrides_builtin'):
+ kind = 'builtin'
+ if override:
+ kind += '+override'
+ return kind
+ return 'custom'
+
+
+def _effective_api_key(pid: str, override: dict) -> str:
+ key = str(override.get('api_key') or '').strip()
+ if key:
+ return key
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(pid)
+ if spec is None:
+ return ''
+ for env_var in spec.api_key_env:
+ value = os.environ.get(env_var)
+ if value:
+ return value
+ return ''
+
+
+def _runtime_api_key(config, pid: str) -> str:
+ """Key already on the live agent config, when settings and env have none."""
+ from ms_agent.llm.credentials import CredentialResolver
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(pid)
+ if spec is None or getattr(config, 'llm', None) is None:
+ return ''
+ try:
+ return str(CredentialResolver.resolve_api_key(spec, config)
+ or '').strip()
+ except Exception:
+ return ''
+
+
+def _effective_base_url(pid: str, override: dict) -> str:
+ url = str(override.get('base_url') or '').strip()
+ if url:
+ return url
+ from omegaconf import OmegaConf
+
+ from ms_agent.llm.credentials import CredentialResolver
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(pid)
+ if spec is None:
+ return ''
+ return CredentialResolver.resolve_base_url(spec, OmegaConf.create({}))
+
+
+def _effective_protocol(pid: str, provider: dict, override: dict) -> str:
+ proto = override.get('protocol') or provider.get('protocol') or ''
+ if proto:
+ return str(proto)
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(pid)
+ return spec.transport if spec is not None else 'openai'
+
+
+def _live_fetch_note(pid: str, override: dict, provider: dict):
+ """Fetch chat ids. Returns (ids, dropped, skip_reason)."""
+ from ms_agent.llm.model_discovery import (fetch_model_ids,
+ filter_chat_model_ids)
+ key = _effective_api_key(pid, override)
+ if not key:
+ return [], 0, 'skipped (no API key)'
+ url = _effective_base_url(pid, override)
+ if not url:
+ return [], 0, 'skipped (no base url)'
+ proto = _effective_protocol(pid, provider, override)
+ raw = fetch_model_ids(url, proto, key)
+ if not raw:
+ return [], 0, 'live: (empty — /models failed or none)'
+ kept, dropped = filter_chat_model_ids(raw)
+ return kept, dropped, ''
+
+
+def _provider_status_lines(mgr,
+ *,
+ live: bool = False,
+ only: str | None = None) -> list[str]:
+ from ms_agent.llm.model_discovery import (format_id_list,
+ format_live_model_lines)
+
+ if live:
+ lines = ['Providers (live /models; preview only):']
+ else:
+ lines = ['Providers:']
+ default = mgr.get_default_model()
+ if default:
+ lines.append(f'Default: {_default_switch_line(default)}')
+ only_id = (only or '').strip().lower() or None
+ for pid, provider, override in _iter_provider_rows(mgr):
+ if only_id and pid.lower() != only_id:
+ continue
+ catalog = list(override.get('models') or provider.get('models') or [])
+ kind = _provider_kind(provider, override)
+ key = _mask_key(
+ _effective_api_key(pid, override) or override.get('api_key'))
+ url = override.get('base_url') or '(default)'
+ proto = _effective_protocol(pid, provider, override)
+ if live:
+ live_ids, _dropped, skip = _live_fetch_note(
+ pid, override, provider)
+ lines.append(f' {pid} ({kind}):')
+ lines.append(
+ f' catalog: {format_id_list(catalog) if catalog else "(none listed)"}'
+ )
+ if skip:
+ lines.append(f' {skip}')
+ else:
+ lines.append(f' live: {len(live_ids)} chat')
+ lines.extend(
+ format_live_model_lines(live_ids, indent=' '))
+ lines.append(f' protocol={proto} key={key} url={url}')
+ else:
+ model_txt = ', '.join(catalog) or '(none listed)'
+ lines.append(f' {pid} ({kind}): {model_txt}')
+ lines.append(f' protocol={proto} key={key} url={url}')
+ if only_id and len(lines) <= (2 if default else 1):
+ lines.append(f' (no provider named {only})')
+ from ms_agent.llm.model_discovery import LIST_FOOTER
+ lines.append(LIST_FOOTER)
+ return lines
+
+
+def _install_rebuilt_llm(target, rebuilt) -> bool:
+ """Copy ``rebuilt`` onto ``target`` in place. False if the type is frozen."""
+ try:
+ if type(rebuilt) is not type(target):
+ target.__class__ = rebuilt.__class__
+ target.__dict__ = rebuilt.__dict__
+ return True
+ except TypeError:
+ return False
+
+
+def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str:
+ if not ctx.runtime or not ctx.runtime.llm:
+ return ''
+ from omegaconf import OmegaConf
+
+ from ms_agent.tui.app import TuiApp
+ config = ctx.runtime.llm.config
+ service = str(OmegaConf.select(config, 'llm.service', default='') or '')
+ if service != provider_id:
+ return (f' Switch with /model {provider_id} to use this '
+ 'provider live.')
+ TuiApp._apply_provider_credentials(config, overwrite=True)
+ return ''
+
+
+def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]:
+ rest: list[str] = []
+ fields: dict[str, str] = {}
+ for tok in tokens:
+ if '=' in tok:
+ key, val = tok.split('=', 1)
+ fields[key] = val
+ else:
+ rest.append(tok)
+ return rest, fields
+
+
+def _kv_alias(fields: dict[str, str]) -> dict[str, str]:
+ out = dict(fields)
+ if 'url' in out and 'base_url' not in out:
+ out['base_url'] = out.pop('url')
+ elif 'url' in out:
+ out.pop('url')
+ if 'key' in out and 'api_key' not in out:
+ out['api_key'] = out.pop('key')
+ elif 'key' in out:
+ out.pop('key')
+ return out
+
+
async def cmd_model(ctx: CommandContext) -> CommandResult:
if not ctx.runtime or not ctx.runtime.llm:
return CommandResult(
type=CommandResultType.MESSAGE, content='No active agent.')
if not ctx.args:
- model = ctx.runtime.llm.model
- service = getattr(ctx.runtime.llm.config.llm, 'service', 'unknown')
+ service, model, _cfg_model = _repair_runtime_model(
+ ctx.runtime.llm.config, ctx.runtime.llm)
+ if not service:
+ service = getattr(ctx.runtime.llm.config.llm, 'service', 'unknown')
+ if not model:
+ model = ctx.runtime.llm.model
return CommandResult(
type=CommandResultType.MESSAGE,
- content=(
- f'Model: {model}\nService: {service}\n'
- 'Switch with: /model or /model /'),
+ content=status_then_usage(
+ _current_model_text(service, model, ctx.runtime.llm),
+ _model_usage()),
)
- # Accept both "/model " and "/model /".
arg = ctx.args.strip()
- service_override = None
- new_model = arg
- if '/' in arg:
- service_override, new_model = arg.split('/', 1)
- service_override = service_override.strip()
- new_model = new_model.strip()
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ head = parts[0].lower()
+ if head == 'help' or arg in ('-h', '--help'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_model_usage())
+ if head == 'list':
+ rest = parts[1:]
+ live = bool(rest) and rest[0].lower() in ('live', '--live', 'fetch')
+ if rest and not live:
+ return arg_error(
+ '/model list live [provider]',
+ reason='Unknown list option',
+ ctx=ctx,
+ )
+ only = rest[1] if live and len(rest) > 1 else None
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='\n'.join(
+ _provider_status_lines(_mgr(), live=live, only=only)),
+ )
+ if head == 'provider':
+ return _cmd_model_provider(ctx, parts[1:])
+ if head == 'catalog':
+ return _cmd_model_catalog(ctx, parts[1:])
+ return _cmd_model_switch(ctx, arg)
+
+
+def _secret_model_error(ctx: CommandContext) -> CommandResult:
+ return arg_error(
+ '/model ',
+ reason='That looks like an API key, not a model id',
+ note='Set a key with /model provider key ',
+ ctx=ctx,
+ )
+
+
+_NO_CATALOG = '已保存。这个地址没有返回模型列表,下一次对话才会确认模型名。'
+_UNREACHABLE = '这个地址现在连不上,密钥还没向供应商确认。'
+
+
+def _runtime_base_url(config, pid: str) -> str:
+ """URL already on the live agent config, when settings and env have none."""
+ llm = getattr(config, 'llm', None)
+ if llm is None:
+ return ''
+ for attr in ('base_url', f'{pid}_base_url'):
+ value = str(getattr(llm, attr, '') or '').strip()
+ if value:
+ return value
+ return ''
+
+
+def _probe_saved_provider(pid: str, config=None):
+ """Catalog probe for the provider as currently saved. None without key+url.
+
+ A key or URL that exists only on the running agent still counts, so a
+ fake model name is not saved with a plain success line.
+ """
+ from ms_agent.llm.model_discovery import probe_model_catalog
+ override = _mgr().list_custom_providers().get(pid) or {}
+ key = _effective_api_key(pid, override)
+ url = _effective_base_url(pid, override)
+ if config is not None:
+ if not key:
+ key = _runtime_api_key(config, pid)
+ if not url:
+ url = _runtime_base_url(config, pid)
+ if not key or not url:
+ return None
+ proto = _effective_protocol(pid, {}, override)
+ return probe_model_catalog(url, proto, key)
+
+
+def _provider_snapshot(pid: str) -> dict:
+ entry = _mgr().list_custom_providers().get(pid) or {}
+ return {
+ 'api_key': entry.get('api_key'),
+ 'base_url': entry.get('base_url'),
+ }
+
+
+def _restore_provider_fields(pid: str, before: dict, fields: set[str]) -> None:
+ mgr = _mgr()
+ if 'api_key' in fields:
+ if before.get('api_key'):
+ mgr.patch_provider(pid, api_key=before['api_key'])
+ else:
+ mgr.patch_provider(pid, clear_api_key=True)
+ if 'base_url' in fields:
+ if before.get('base_url'):
+ mgr.patch_provider(pid, base_url=before['base_url'])
+ else:
+ mgr.patch_provider(pid, clear_base_url=True)
+
+
+def _probe_reject_text(pid: str, outcome: str, fields: set[str],
+ before: dict) -> str:
+ """User-facing rollback copy. A filled command is a copy aid only."""
+ changed_key = 'api_key' in fields
+ had_key = bool(before.get('api_key'))
+ if outcome == 'auth':
+ if changed_key and had_key:
+ line = '这把密钥用不了,没有换成它。当前仍是原来的密钥。'
+ elif changed_key:
+ line = '这把密钥被供应商拒绝了,没有保存。'
+ else:
+ line = '这把密钥用不了,没有换过去。当前仍是原来的地址。'
+ aid = f'/model provider key {pid} '
+ else:
+ if changed_key and not ('base_url' in fields):
+ if had_key:
+ line = '这个地址连不上,没有换成它。当前仍是原来的密钥。'
+ else:
+ line = '这个地址连不上,没有保存。'
+ aid = f'/model provider key {pid} '
+ else:
+ line = '这个地址连不上,没有换过去。当前仍是原来的地址。'
+ aid = f'/model provider url {pid} '
+ return f'{line}\n可复制:{aid}'
+
+
+def _gate_provider_write(pid: str, before: dict,
+ fields: set[str]) -> tuple[str | None, str]:
+ """Probe after a new key is written. A URL-only write makes no request.
+
+ Returns ``(reject_text, saved_note)``. ``reject_text`` is set only when
+ the write was rolled back. 401/403 rolls back. A connection failure keeps
+ the write and returns the unreachable note.
+ """
+ if not fields or 'api_key' not in fields:
+ return None, ''
+ probe = _probe_saved_provider(pid)
+ if probe is None or probe.outcome == 'ok':
+ return None, ''
+ if probe.outcome == 'no_catalog':
+ return None, _NO_CATALOG
+ if probe.outcome == 'unreachable':
+ return None, _UNREACHABLE
+ _restore_provider_fields(pid, before, fields)
+ return _probe_reject_text(pid, probe.outcome, fields, before), ''
+
+
+def _finish_credential_write(ctx: CommandContext, pid: str, before: dict,
+ fields: set[str], saved: str) -> CommandResult:
+ reject, note = _gate_provider_write(pid, before, fields)
+ if reject:
+ return CommandResult(type=CommandResultType.MESSAGE, content=reject)
+ live = _push_provider_to_runtime(ctx, pid)
+ extra = f' {note}' if note else ''
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'{saved}{live}{extra}',
+ )
+
+
+def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult:
+ unknown = _unknown_provider_word(arg)
+ if unknown:
+ known = ', '.join(_known_provider_ids())
+ return arg_error(
+ '/model ',
+ reason=f'No provider named {unknown}',
+ note=f'Known: {known}'
+ if known else 'Add one with /model provider add ',
+ ctx=ctx,
+ )
+ service_override, new_model = _parse_model_arg(arg)
+ if service_override:
+ service_override = _canonical_provider(
+ service_override) or service_override
from omegaconf import OmegaConf
+ from ms_agent.tui.app import TuiApp
+
target = ctx.runtime.llm
config = target.config
+ old_service, _display_model, old_model = _repair_runtime_model(
+ config, target)
+ new_model = _strip_provider_prefix(service_override or old_service,
+ new_model)
+ if contains_secret(new_model):
+ return _secret_model_error(ctx)
+
+ def _revert_switch() -> None:
+ OmegaConf.update(config, 'llm.model', old_model, merge=True)
+ if old_service:
+ OmegaConf.update(config, 'llm.service', old_service, merge=True)
+ TuiApp._apply_provider_credentials(config, overwrite=True)
+ target.model = old_model
+
OmegaConf.update(config, 'llm.model', new_model, merge=True)
if service_override:
OmegaConf.update(config, 'llm.service', service_override, merge=True)
+ TuiApp._apply_provider_credentials(config, overwrite=True)
- # Always apply the cheap in-place update: legacy LLM classes read
- # ``self.model`` at generate time, so this alone switches the model for
- # them (and keeps behavior unchanged when nothing else is possible).
- target.model = new_model
+ settings_provider = service_override or str(
+ getattr(getattr(config, 'llm', None), 'service', '') or '') or None
+ pid = settings_provider or ''
+ override = _mgr().list_custom_providers().get(pid) or {}
+ # Settings/env keys are probed. A key that already lives on this agent
+ # config is enough to keep the switch; the next message builds the client.
+ if not (_effective_api_key(pid, override)
+ or _runtime_api_key(config, pid)):
+ _revert_switch()
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=_switch_fail_text(service_override, new_model, old_service,
+ old_model),
+ )
- # Best-effort: rebuild the LLM so the switch also reaches a provider-router
- # transport, which caches model/base_url/api_key at build time (setting
- # ``.model`` would not reach it). Adopt the rebuilt state into the existing
- # object so every holder of the reference sees it (agent.llm and
- # runtime.llm are the same object). If the rebuild can't complete (missing
- # credentials, test doubles, ...), keep the in-place update above.
- from ms_agent.llm import LLM
+ from ms_agent.llm.model_discovery import nearby_model_ids
+ probe = _probe_saved_provider(pid, config)
+ if probe is not None and probe.outcome in ('auth', 'unreachable'):
+ _revert_switch()
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=_probe_reject_text(
+ pid, probe.outcome,
+ {'api_key'} if probe.outcome == 'auth' else {'base_url'}, {
+ 'api_key': override.get('api_key'),
+ 'base_url': override.get('base_url')
+ }),
+ )
+ if probe is not None and probe.outcome == 'ok' and new_model not in probe.ids:
+ _revert_switch()
+ near = ', '.join(nearby_model_ids(new_model, probe.ids)) or '(none)'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=(f'列表里没有 {new_model}。相近的有:{near}。'
+ '没有改默认模型。'),
+ )
+ target.model = new_model
+ _mgr().set_default_model(new_model, provider=settings_provider)
+ # The default is what the next new chat starts on. This chat keeps the
+ # same choice on its own session record, so coming back later does not
+ # depend on the default still pointing here.
+ from ms_agent.project.session import persist_logged_session_model
try:
- rebuilt = LLM.from_config(config)
- except Exception: # noqa: BLE001 - best-effort; in-place update stands
- rebuilt = None
- if rebuilt is not None and type(rebuilt) is type(target):
- target.__dict__ = rebuilt.__dict__
- elif rebuilt is not None:
- target.__class__ = rebuilt.__class__
- target.__dict__ = rebuilt.__dict__
-
- saved_path = _persist_model_to_config(config, new_model, service_override)
- switched = (f'{service_override}/{new_model}'
- if service_override else new_model)
- content = f'Switched to: {switched}'
- if saved_path:
- content += f'\nSaved to: {saved_path}'
- else:
- content += '\n(in-memory only; no project directory to persist to)'
+ persist_logged_session_model(config, new_model, settings_provider)
+ except Exception:
+ import logging
+ logging.getLogger(__name__).debug(
+ 'session model persist failed', exc_info=True)
+ content = 'Switched to:\n' + _current_model_text(
+ settings_provider, new_model, ctx.runtime.llm)
+ if probe is not None and probe.outcome == 'no_catalog':
+ content += '\n' + _NO_CATALOG
return CommandResult(
type=CommandResultType.MUTATE_STATE,
content=content,
)
+def _cmd_model_provider(ctx: CommandContext,
+ tokens: list[str]) -> CommandResult:
+ mgr = _mgr()
+ if not tokens:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='\n'.join(_provider_status_lines(mgr)),
+ )
+ action = tokens[0].lower()
+ rest = tokens[1:]
+ if action in ('list', 'ls'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='\n'.join(_provider_status_lines(mgr)),
+ )
+ if action == 'add':
+ names, fields = _split_kv(rest)
+ if not names:
+ return arg_error(
+ '/model provider add [key=] [url=] [protocol=] [name=]',
+ ctx=ctx,
+ )
+ pid = names[0]
+ fields = _kv_alias(fields)
+ before = _provider_snapshot(pid)
+ mgr.add_provider(
+ pid,
+ name=fields.get('name') or pid,
+ protocol=fields.get('protocol') or 'openai',
+ api_key=fields.get('api_key'),
+ base_url=fields.get('base_url'),
+ )
+ touched = {k for k in ('api_key', 'base_url') if fields.get(k)}
+ return _finish_credential_write(ctx, pid, before, touched,
+ f'Provider {pid} saved.')
+ if action in ('set', 'update'):
+ names, fields = _split_kv(rest)
+ if not names:
+ return arg_error(
+ '/model provider set [key=] [url=] [protocol=] [name=]',
+ ctx=ctx,
+ )
+ pid = names[0]
+ fields = _kv_alias(fields)
+ if not fields:
+ return arg_error(
+ '/model provider set [key=] [url=] [protocol=] [name=]',
+ reason='No fields to patch',
+ ctx=ctx,
+ )
+ before = _provider_snapshot(pid)
+ mgr.patch_provider(
+ pid,
+ name=fields.get('name'),
+ protocol=fields.get('protocol'),
+ api_key=fields.get('api_key'),
+ base_url=fields.get('base_url'),
+ clear_api_key=fields.get('api_key', None) == '',
+ clear_base_url=fields.get('base_url', None) == '',
+ )
+ touched = {k for k in ('api_key', 'base_url') if k in fields}
+ return _finish_credential_write(ctx, pid, before, touched,
+ f'Updated provider {pid}.')
+ if action == 'key':
+ if len(rest) < 2:
+ return arg_error(
+ '/model provider key |clear',
+ ctx=ctx,
+ )
+ pid, value = rest[0], ' '.join(rest[1:])
+ if value.lower() in _CLEAR:
+ mgr.patch_provider(pid, clear_api_key=True)
+ note = _push_provider_to_runtime(ctx, pid)
+ from ms_agent.agent.llm_agent import reset_credential_notice
+ reset_credential_notice()
+ shown = _canonical_provider(pid) or pid
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'已清除 {shown} 的密钥。{note}',
+ )
+ before = _provider_snapshot(pid)
+ mgr.patch_provider(pid, api_key=value)
+ return _finish_credential_write(ctx, pid, before, {'api_key'},
+ f'Saved API key for {pid}.')
+ if action in ('url', 'base_url'):
+ if len(rest) < 2:
+ return arg_error(
+ '/model provider url |clear',
+ ctx=ctx,
+ )
+ pid, value = rest[0], rest[1]
+ if value.lower() in _CLEAR:
+ mgr.patch_provider(pid, clear_base_url=True)
+ note = _push_provider_to_runtime(ctx, pid)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Cleared base URL for {pid}.{note}',
+ )
+ before = _provider_snapshot(pid)
+ mgr.patch_provider(pid, base_url=value)
+ return _finish_credential_write(ctx, pid, before, {'base_url'},
+ f'Saved base URL for {pid}.')
+ if action == 'remove':
+ if not rest:
+ return arg_error(
+ '/model provider remove ',
+ ctx=ctx,
+ )
+ pid = rest[0]
+ custom = mgr.list_custom_providers()
+ if pid in _builtin_ids() and pid not in custom:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=(f'Cannot remove builtin provider {pid}. '
+ 'Clear its override with /model provider key '
+ f'{pid} clear'),
+ )
+ mgr.remove_provider(pid)
+ extra = ''
+ if pid in _builtin_ids():
+ extra = ' Builtin catalog remains; credential override cleared.'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Removed provider {pid}.{extra}',
+ )
+ return arg_error(
+ '/model provider add|set|key|url|remove ...',
+ reason=f'Unknown provider action {action!r}',
+ note='Type /model for all /model commands',
+ ctx=ctx,
+ )
+
+
+def _current_provider(ctx: CommandContext) -> str:
+ if not ctx.runtime or not getattr(ctx.runtime, 'llm', None):
+ return ''
+ cfg = getattr(ctx.runtime.llm, 'config', None)
+ llm = getattr(cfg, 'llm', None)
+ return str(getattr(llm, 'service', '') or '')
+
+
+def _pinned_models(mgr, pid: str) -> list[str]:
+ entry = mgr.list_custom_providers().get(pid) or {}
+ return list(entry.get('models') or [])
+
+
+def _pinned_catalog_lines(mgr) -> list[str]:
+ lines = ['Pinned catalogs:']
+ found = False
+ for pid, _provider, override in _iter_provider_rows(mgr):
+ models = list(override.get('models') or [])
+ if not models:
+ continue
+ found = True
+ lines.append(f' {pid}: {", ".join(models)}')
+ if not found:
+ lines.append(' (none)')
+ return lines
+
+
+def _cmd_model_catalog(ctx: CommandContext,
+ tokens: list[str]) -> CommandResult:
+ mgr = _mgr()
+ syntax = '/model catalog add|remove '
+ note = 'drop = remove; omit to use the current one'
+ if not tokens:
+ lines = _pinned_catalog_lines(mgr)
+ lines.append(f'Need: {syntax}')
+ lines.append(note)
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content='\n'.join(lines))
+ action = tokens[0].lower()
+ if action == 'drop':
+ action = 'remove'
+ if action not in ('add', 'remove'):
+ return arg_error(
+ syntax,
+ reason=f'Unknown catalog action {action!r}',
+ note=note,
+ ctx=ctx,
+ )
+ rest = tokens[1:]
+ inferred = False
+ if len(rest) >= 2:
+ pid, model = rest[0], ' '.join(rest[1:])
+ elif len(rest) == 1:
+ pid = _current_provider(ctx)
+ model = rest[0]
+ inferred = True
+ if not pid:
+ return arg_error(
+ f'/model catalog {action} ',
+ reason='No current provider to default to',
+ note=note,
+ ctx=ctx,
+ )
+ else:
+ return arg_error(
+ f'/model catalog {action} ',
+ note=note,
+ ctx=ctx,
+ )
+ where = f'{pid} catalog'
+ if inferred:
+ where += ' (current provider)'
+ if action == 'add':
+ if contains_secret(model):
+ return arg_error(
+ '/model catalog add ',
+ reason='That looks like an API key, not a model id',
+ note='Set a key with /model provider key ',
+ ctx=ctx,
+ )
+ mgr.add_model(pid, model)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Added {model} to {where}.',
+ )
+ if pid not in mgr.list_custom_providers():
+ return arg_error(
+ '/model catalog remove ',
+ reason=f'No catalog override for {pid}',
+ note='Pin a model first with /model catalog add',
+ ctx=ctx,
+ )
+ if not mgr.remove_model(pid, model):
+ pinned = _pinned_models(mgr, pid)
+ pinned_txt = ', '.join(pinned) if pinned else '(none)'
+ return arg_error(
+ '/model catalog remove ',
+ reason=f'{model} is not in the {where}',
+ note=f'Pinned: {pinned_txt}',
+ ctx=ctx,
+ )
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Removed {model} from {where}.',
+ )
+
+
+def _mask_config_secrets(node) -> None:
+ """Blank secret-shaped keys, and secret values inside ``headers`` / ``env``.
+
+ Non-secrets in those bags stay visible (``Content-Type``, ``PATH``).
+ A secret embedded in an ordinary string, such as ``sk-…`` in a URL, is
+ replaced in place and the rest of the value stays. Upload scrubbing still
+ blanks the whole bag; this is display only.
+ """
+ from ms_agent.agent_hub._secrets import redact_text
+ from ms_agent.agent_hub._workspace import SECRET_BAG_KEYS, is_secret_key
+ from ms_agent.command.usage import contains_secret
+
+ if isinstance(node, dict):
+ for key, val in list(node.items()):
+ if key in SECRET_BAG_KEYS and isinstance(val, dict):
+ masked = {}
+ for inner, inner_val in val.items():
+ if inner_val in (None, ''):
+ masked[inner] = inner_val
+ elif is_secret_key(str(inner)) or contains_secret(
+ str(inner_val)):
+ masked[inner] = '***'
+ else:
+ masked[inner] = inner_val
+ node[key] = masked
+ elif is_secret_key(str(key)) and not isinstance(val, (dict, list)):
+ if val not in (None, ''):
+ node[key] = '***'
+ elif isinstance(val, str) and val:
+ cleaned, hits = redact_text(val)
+ if hits:
+ node[key] = cleaned
+ else:
+ _mask_config_secrets(val)
+ elif isinstance(node, list):
+ for item in node:
+ _mask_config_secrets(item)
+
+
async def cmd_config(ctx: CommandContext) -> CommandResult:
if not ctx.runtime or not ctx.runtime.llm:
return CommandResult(
@@ -134,11 +1096,8 @@ async def cmd_config(ctx: CommandContext) -> CommandResult:
config = ctx.runtime.llm.config
from omegaconf import OmegaConf
- # mask sensitive info
safe = OmegaConf.to_container(config, resolve=True)
- for key in list(safe.get('llm', {}).keys()):
- if 'key' in key.lower():
- safe['llm'][key] = '***'
+ _mask_config_secrets(safe)
import yaml
diff --git a/ms_agent/command/builtin/context_cmds.py b/ms_agent/command/builtin/context_cmds.py
index e77bc66a8..927d4f5be 100644
--- a/ms_agent/command/builtin/context_cmds.py
+++ b/ms_agent/command/builtin/context_cmds.py
@@ -93,9 +93,16 @@ async def cmd_tools(ctx: CommandContext) -> CommandResult:
return CommandResult(
type=CommandResultType.MESSAGE, content='No tools configured.')
+ failures = getattr(
+ getattr(ctx.runtime, 'tool_manager', None), 'connect_failures',
+ None) or {}
lines = [f'Configured tools ({len(tool_names)}):']
for name in sorted(tool_names):
- lines.append(f' • {name}')
+ reason = failures.get(name) if isinstance(failures, dict) else None
+ if reason:
+ lines.append(f' • {name} {reason}')
+ else:
+ lines.append(f' • {name}')
return CommandResult(
type=CommandResultType.MESSAGE, content='\n'.join(lines))
diff --git a/ms_agent/command/builtin/instruction_cmds.py b/ms_agent/command/builtin/instruction_cmds.py
new file mode 100644
index 000000000..16be1c7eb
--- /dev/null
+++ b/ms_agent/command/builtin/instruction_cmds.py
@@ -0,0 +1,216 @@
+"""Slash commands for AGENTS.md / PROFILE.md (same files WebUI settings write)."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.scope import work_dir_of
+from ms_agent.command.types import (CommandContext, CommandDef, CommandResult,
+ CommandResultType)
+from ms_agent.command.usage import arg_error, ledger_file, status_then_usage
+
+CMD_INSTRUCTION = CommandDef(
+ name='instruction',
+ description='Show or set AGENTS.md instructions',
+ category='config',
+ aliases=('ins', ),
+)
+
+CMD_PROFILE = CommandDef(
+ name='profile',
+ description='Show or set PROFILE.md',
+ category='config',
+)
+
+
+def _ins_usage() -> str:
+ return (
+ 'usage:\n'
+ ' /instruction\n'
+ ' /instruction global|project\n'
+ ' /instruction global|project \n'
+ ' /instruction global|project clear\n'
+ f'Global → {ledger_file("AGENTS.md")} '
+ '(user region under the seeded header).\n'
+ 'Project → /.ms_agent/AGENTS.md (never the repo-root AGENTS.md).\n'
+ 'Takes effect on the next turn (files are read live).'
+ )
+
+
+def _profile_usage() -> str:
+ return (
+ 'usage:\n'
+ ' /profile\n'
+ ' /profile callme \n'
+ ' /profile callme clear\n'
+ ' /profile about \n'
+ ' /profile about clear\n'
+ f'Writes {ledger_file("PROFILE.md")} (Call me line + free region). '
+ 'Takes effect on the next turn.'
+ )
+
+
+_CLEAR = frozenset({'clear', '-', 'none'})
+
+
+def _work_dir(ctx: CommandContext) -> str | None:
+ return work_dir_of(ctx)
+
+
+def _preview(text: str, empty: str = '(empty)') -> str:
+ body = (text or '').strip()
+ if not body:
+ return empty
+ if len(body) > 1200:
+ return body[:1200] + '\n... (truncated)'
+ return body
+
+
+def _show_instructions(work_dir: str | None) -> str:
+ from ms_agent.prompting import workspace_files as wf
+ lines = [f'Global ({ledger_file("AGENTS.md")}):', _preview(wf.read_global_instruction())]
+ if work_dir:
+ lines.extend([
+ '',
+ f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"}):',
+ _preview(wf.read_project_instruction(work_dir)),
+ ])
+ root = Path(work_dir) / 'AGENTS.md'
+ try:
+ root_text = root.read_text(encoding='utf-8', errors='replace')
+ except OSError:
+ root_text = ''
+ if root_text.strip():
+ lines.extend([
+ '',
+ '(Repo-root AGENTS.md also exists and is injected, but '
+ '/instruction never writes it.)',
+ ])
+ else:
+ lines.extend(['', 'Project: (no work dir — start TUI with --work-dir)'])
+ return status_then_usage('\n'.join(lines), _ins_usage())
+
+
+async def cmd_instruction(ctx: CommandContext) -> CommandResult:
+ from ms_agent.prompting import workspace_files as wf
+
+ arg = (ctx.args or '').strip()
+ work_dir = _work_dir(ctx)
+ if not arg:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_show_instructions(work_dir))
+
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ scope = parts[0].lower()
+ rest = parts[1:]
+ if scope not in ('global', 'project', 'help', '-h', '--help'):
+ return arg_error(
+ '/instruction global|project [text|clear]',
+ reason=f'Unknown instruction scope {scope!r}',
+ ctx=ctx,
+ )
+ if scope in ('help', '-h', '--help'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_ins_usage())
+
+ if scope == 'project' and not work_dir:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='No work dir (output_dir). Start TUI with --work-dir.',
+ )
+
+ if not rest:
+ if scope == 'global':
+ body = wf.read_global_instruction()
+ label = f'Global ({ledger_file("AGENTS.md")})'
+ else:
+ body = wf.read_project_instruction(work_dir)
+ label = f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"})'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'{label}:\n{_preview(body)}',
+ )
+
+ text = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest)
+ if scope == 'global':
+ wf.write_global_instruction(text)
+ dest = ledger_file('AGENTS.md')
+ else:
+ wf.write_project_instruction(work_dir, text)
+ dest = str(Path(work_dir) / '.ms_agent' / 'AGENTS.md')
+ verb = 'cleared' if not text else 'saved'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'{verb.capitalize()} {scope} instruction → {dest}. Next turn uses it.',
+ )
+
+
+def _show_profile() -> str:
+ from ms_agent.prompting import workspace_files as wf
+ call_me, about = wf.read_profile()
+ lines = [
+ f'Call me: {call_me or "(unset)"}',
+ 'About:',
+ _preview(about),
+ ]
+ return status_then_usage('\n'.join(lines), _profile_usage())
+
+
+async def cmd_profile(ctx: CommandContext) -> CommandResult:
+ from ms_agent.prompting import workspace_files as wf
+
+ arg = (ctx.args or '').strip()
+ if not arg or arg in ('help', '-h', '--help'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_show_profile())
+
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ action = parts[0].lower()
+ rest = parts[1:]
+ if action in ('callme', 'call', 'name'):
+ if not rest:
+ call_me, _ = wf.read_profile()
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Call me: {call_me or "(unset)"}',
+ )
+ value = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest)
+ wf.write_profile(call_me=value)
+ verb = 'cleared' if not value else f'set to {value}'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Call me {verb}. Next turn uses {ledger_file("PROFILE.md")}.',
+ )
+ if action in ('about', 'desc', 'description', 'set'):
+ if not rest:
+ _, about = wf.read_profile()
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'About:\n{_preview(about)}',
+ )
+ value = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest)
+ wf.write_profile(description=value)
+ verb = 'cleared' if not value else 'saved'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Profile about {verb}. Next turn uses {ledger_file("PROFILE.md")}.',
+ )
+ return arg_error(
+ '/profile callme |clear or /profile about |clear',
+ reason=f'Unknown profile action {action!r}',
+ note='Type /profile for all commands',
+ ctx=ctx,
+ )
+
+
+def register_instruction_commands(router: CommandRouter) -> None:
+ router.register(CMD_INSTRUCTION, cmd_instruction)
+ router.register(CMD_PROFILE, cmd_profile)
diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py
new file mode 100644
index 000000000..ae7f8fecf
--- /dev/null
+++ b/ms_agent/command/builtin/memory_cmds.py
@@ -0,0 +1,241 @@
+"""Slash command for memory on/off (same files WebUI personalization / project)."""
+from __future__ import annotations
+
+from dataclasses import replace
+
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.scope import work_dir_of
+from ms_agent.command.types import (CommandContext, CommandDef, CommandResult,
+ CommandResultType)
+from ms_agent.command.usage import arg_error, status_then_usage
+
+CMD_MEMORY = CommandDef(
+ name='memory',
+ description='Turn memory on or off',
+ category='config',
+)
+
+_USAGE = (
+ 'usage:\n'
+ ' /memory\n'
+ ' /memory on|off (this project)\n'
+ ' /memory project on|off\n'
+ ' /memory global on|off (default for newly opened folders)\n'
+ ' /memory backend file|vector (global default for new folders)\n'
+ ' /memory global backend file|vector\n'
+ ' /memory project backend file|vector\n'
+ 'Project on/off turns memory on for this folder. '
+ 'Global on/off is the default for newly opened folders. '
+ 'file writes MEMORY.md under /.ms_agent/memory/. '
+ 'vector is saved but not started in this session.'
+)
+
+_ON = frozenset({'on', 'true', '1', 'enable', 'enabled'})
+_OFF = frozenset({'off', 'false', '0', 'disable', 'disabled'})
+
+
+def _work_dir(ctx: CommandContext) -> str | None:
+ return work_dir_of(ctx)
+
+
+def _pm():
+ from ms_agent.project import ProjectManager
+ from ms_agent.project.paths import global_home
+ return ProjectManager(base_dir=str(global_home()))
+
+
+def _project(ctx: CommandContext):
+ work = _work_dir(ctx)
+ if not work:
+ return None
+ return _pm().find_by_path(work)
+
+
+def _status_text(ctx: CommandContext) -> str:
+ from ms_agent.personalization.settings import PersonalizationSettings
+ loaded = PersonalizationSettings().load()
+ g_on = 'on' if loaded.memory_enabled else 'off'
+ g_be = loaded.memory_backend or 'file'
+ lines = [
+ f'Global default: {g_on} backend={g_be}',
+ '(applies when a new folder is opened)',
+ ]
+ project = _project(ctx)
+ if project is None:
+ lines.append('Project: (no work dir)')
+ else:
+ p_on = 'on' if project.memory_enabled else 'off'
+ p_be = project.memory_backend or g_be
+ lines.append(f'Project: {p_on} backend={p_be} id={project.id}')
+ return status_then_usage('\n'.join(lines), _USAGE)
+
+
+def _backend_saved(text: str, backend: str) -> str:
+ if backend == 'vector':
+ return text + ' 当前会话不会启动 vector。'
+ return text
+
+
+def _parse_bool(token: str) -> bool | None:
+ low = token.lower()
+ if low in _ON:
+ return True
+ if low in _OFF:
+ return False
+ return None
+
+
+def _arm_memory(ctx: CommandContext) -> None:
+ """The next user message should try to start file memory."""
+ runtime = getattr(ctx, 'runtime', None)
+ if runtime is not None:
+ runtime._memory_pending = True
+
+
+async def _apply_live(ctx: CommandContext, project) -> str:
+ """Arm the next user message. The command does not patch ``Runtime``.
+
+ ``ctx.runtime`` is the session ``Runtime``, which has no config. The
+ ledger is already saved; ``live_config`` writes ``agent.config`` and
+ starts or drops the memory tools.
+ """
+ from omegaconf import OmegaConf
+
+ from ms_agent.personalization.memory_apply import apply_project_memory
+ _arm_memory(ctx)
+ kind = apply_project_memory(OmegaConf.create({}), project)
+ if kind == 'vector-unavailable':
+ return (
+ '已保存 vector。当前会话不会启动它。'
+ '要用文件记忆:/memory project backend file')
+ return ''
+
+
+async def cmd_memory(ctx: CommandContext) -> CommandResult:
+ arg = (ctx.args or '').strip()
+ if not arg or arg in ('help', '-h', '--help', 'status'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_status_text(ctx))
+
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ action = parts[0].lower()
+ rest = parts[1:]
+
+ if action in ('global', 'project'):
+ if rest and rest[0].lower() == 'backend':
+ return _cmd_backend(ctx, rest[1:], scope=action)
+ return await _cmd_toggle(ctx, action, rest)
+ if action in _ON or action in _OFF:
+ return await _cmd_toggle(ctx, action, rest)
+ if action == 'backend':
+ return _cmd_backend(ctx, rest)
+ return arg_error(
+ '/memory on|off | /memory global|project on|off | '
+ '/memory backend file|vector | /memory project backend file|vector',
+ reason=f'Unknown memory action {action!r}',
+ note='Type /memory for status and all commands',
+ ctx=ctx,
+ )
+
+
+async def _cmd_toggle(ctx: CommandContext, action: str,
+ rest: list[str]) -> CommandResult:
+ from ms_agent.personalization.settings import PersonalizationSettings
+
+ scope = 'project'
+ token = action
+ if action in ('global', 'project'):
+ scope = action
+ if not rest:
+ return arg_error(
+ f'/memory {scope} on|off', ctx=ctx)
+ token = rest[0]
+ enabled = _parse_bool(token)
+ if enabled is None:
+ return arg_error('/memory on|off', ctx=ctx)
+
+ if scope == 'global':
+ settings = PersonalizationSettings()
+ loaded = settings.load()
+ settings.save(replace(loaded, memory_enabled=enabled))
+ state = 'on' if enabled else 'off'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=(
+ f'Global memory default → {state}. '
+ 'New folders inherit this; this project is unchanged.'),
+ )
+
+ project = _project(ctx)
+ if project is None:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Project memory needs a work dir (TUI --work-dir).',
+ )
+ updated = _pm().update(project.id, memory_enabled=enabled)
+ note = await _apply_live(ctx, updated)
+ state = 'on' if enabled else 'off'
+ content = f'Project memory → {state}.'
+ if note:
+ content = f'{content} {note}'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=content,
+ )
+
+
+def _cmd_backend(
+ ctx: CommandContext,
+ rest: list[str],
+ scope: str | None = None,
+) -> CommandResult:
+ from ms_agent.personalization.settings import PersonalizationSettings
+
+ tokens = list(rest)
+ if scope is None:
+ if tokens and tokens[0].lower() in ('global', 'project'):
+ scope = tokens.pop(0).lower()
+ elif tokens and tokens[-1].lower() in ('global', 'project'):
+ scope = tokens.pop(-1).lower()
+ else:
+ scope = 'global'
+ if not tokens or tokens[0].lower() not in ('file', 'vector'):
+ return arg_error(
+ '/memory backend file|vector | /memory project backend file|vector',
+ ctx=ctx)
+ backend = tokens[0].lower()
+
+ if scope == 'project':
+ project = _project(ctx)
+ if project is None:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Project backend needs a work dir (TUI --work-dir).',
+ )
+ _pm().update(project.id, memory_backend=backend)
+ _arm_memory(ctx)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=_backend_saved(
+ f'Project memory backend → {backend}.',
+ backend),
+ )
+
+ settings = PersonalizationSettings()
+ loaded = settings.load()
+ settings.save(replace(loaded, memory_backend=backend))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=_backend_saved(
+ f'Global memory backend default → {backend}. '
+ 'New folders inherit this; this project is unchanged.',
+ backend),
+ )
+
+
+def register_memory_commands(router: CommandRouter) -> None:
+ router.register(CMD_MEMORY, cmd_memory)
diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py
new file mode 100644
index 000000000..5d107e0a2
--- /dev/null
+++ b/ms_agent/command/builtin/resource_cmds.py
@@ -0,0 +1,770 @@
+"""Slash commands for managed MCP servers and skills (same files as WebUI)."""
+from __future__ import annotations
+
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.scope import (
+ SCOPE_CHOICES, parse_optional_scope, pick_scope, work_dir_of)
+from ms_agent.command.types import (CommandContext, CommandDef, CommandResult,
+ CommandResultType)
+from ms_agent.command.usage import (arg_error, ledger_dir, ledger_file,
+ status_then_usage)
+from ms_agent.utils import get_logger
+
+logger = get_logger()
+
+CMD_MCP = CommandDef(
+ name='mcp',
+ description='List, add, or enable MCP servers',
+ category='config',
+)
+
+CMD_SKILL_MANAGE = CommandDef(
+ name='skills',
+ description='List, add, or enable skills',
+ category='config',
+ aliases=('skill-mgr', ),
+)
+
+
+def _mcp_usage() -> str:
+ return (
+ 'usage:\n'
+ ' /mcp list [global|project]\n'
+ ' /mcp get [global|project]\n'
+ ' /mcp retry \n'
+ ' /mcp add [global|project] command=\n'
+ ' /mcp add [global|project] url=\n'
+ ' /mcp update [global|project] command=|url=\n'
+ ' /mcp json \n'
+ ' /mcp enable|disable|remove [global|project]\n'
+ 'Omitting scope on add writes this folder (project). '
+ '`global` is every folder on this machine.\n'
+ f'This machine: {ledger_file("mcp.json")}. '
+ 'This folder: /.ms_agent/mcp.json.'
+ )
+
+
+def _skill_usage() -> str:
+ return (
+ 'usage:\n'
+ ' /skills list\n'
+ ' /skills add [global|project]\n'
+ ' /skills enable|disable [global|project]\n'
+ ' /skills remove [global|project]\n'
+ f'Directory drop-in: global → {ledger_dir()}/skills, '
+ 'project → /.ms_agent/skills. '
+ 'Omit scope on add: this machine (global). The TUI menu can also '
+ 'place it in this folder. '
+ 'Omit scope on enable, disable, or remove: the copy that already '
+ 'exists. If both exist, the TUI menu asks which one. '
+ 'remove only deletes a managed copy, not auto-discovered skills.'
+ )
+
+
+_PROJECT_MCP_NOT_ON_SETTINGS = (
+ 'Saved for this folder. Use global to save it for every folder.'
+)
+
+
+def _home_work(ctx: CommandContext) -> tuple[str, str | None]:
+ from ms_agent.project.paths import global_home
+ return str(global_home()), work_dir_of(ctx)
+
+
+def _parse_scope(tokens: list[str], default: str = 'project') -> tuple[str, list[str]]:
+ """Last token is the scope, even when it is the only token.
+
+ Used by ``/mcp list``, where there is no server name.
+ """
+ if tokens and tokens[-1] in ('global', 'project'):
+ return tokens[-1], tokens[:-1]
+ return default, list(tokens)
+
+
+def _parse_named_scope(
+ tokens: list[str],
+ default: str = 'project',
+) -> tuple[str, list[str], bool]:
+ """Scope only when a name precedes ``global`` or ``project``.
+
+ A lone ``project`` is the server name — same rule as ``/skills``.
+ Returns ``(scope, remaining tokens, explicit)``.
+ """
+ scope, rest = parse_optional_scope(tokens)
+ return (scope or default), rest, scope is not None
+
+
+def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]:
+ rest: list[str] = []
+ fields: dict[str, str] = {}
+ for tok in tokens:
+ if '=' in tok:
+ key, val = tok.split('=', 1)
+ fields[key] = val
+ else:
+ rest.append(tok)
+ return rest, fields
+
+
+def _stdio_fields(fields: dict[str, str]) -> dict:
+ """Match WebUI: command + args list (shlex-split the command line)."""
+ import shlex
+ out = dict(fields)
+ if 'command' in out:
+ parts = shlex.split(out['command'])
+ extra = shlex.split(out.pop('args', '')) if out.get('args') else []
+ if parts:
+ out['command'] = parts[0]
+ out['args'] = parts[1:] + extra
+ else:
+ out['args'] = extra
+ elif 'url' in out:
+ out.setdefault('type', 'streamable_http')
+ return out
+
+
+def _need_work(scope: str, work: str | None) -> CommandResult | None:
+ if scope == 'project' and not work:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Project scope needs a work dir (TUI --work-dir).',
+ )
+ return None
+
+
+def _mcp_runtime(ctx):
+ return getattr(getattr(ctx, 'runtime', None), '_mcp_runtime', None)
+
+
+def _server_state(ctx, name: str):
+ """Recorded runtime state. Does not open a connection."""
+ runtime = _mcp_runtime(ctx)
+ getter = getattr(runtime, 'get_server', None)
+ if getter is None:
+ return None
+ return getter(name)
+
+
+def _clip_reason(reason) -> str:
+ text = ' '.join(str(reason).split())
+ if len(text) > 80:
+ text = text[:79] + '…'
+ return text
+
+
+def _status_mark(state) -> str:
+ """Short status for /mcp list and /mcp get. Empty when unknown."""
+ if state is None:
+ return ''
+ status = getattr(state, 'status', None)
+ exited = bool(getattr(state, 'process_exited', False))
+ if status == 'connected':
+ return '已连接'
+ if status == 'connecting' and not exited:
+ return '启动中'
+ if exited:
+ return '进程已退出'
+ if status in ('error', 'degraded'):
+ return '未连接'
+ return ''
+
+
+def _mcp_list_text(mgr, scope: str, ctx=None) -> str:
+ rows = mgr.list(scope if scope != 'merged' else 'merged')
+ if not rows:
+ return 'No MCP servers.'
+ lines = [f'MCP servers ({scope}):']
+ for name, entry in rows.items():
+ on = 'on' if entry.get('enabled', True) is not False else 'off'
+ how = entry.get('command') or entry.get('url') or '?'
+ state = _server_state(ctx, name) if ctx is not None else None
+ mark = _status_mark(state)
+ reason = ''
+ if mark not in ('', '已连接', '启动中'):
+ raw = getattr(state, 'last_error', None)
+ if raw:
+ reason = _clip_reason(raw)
+ if reason == mark:
+ reason = ''
+ if mark and reason:
+ lines.append(f' [{on}] {name} {how} {mark} {reason}')
+ elif mark:
+ lines.append(f' [{on}] {name} {how} {mark}')
+ else:
+ lines.append(f' [{on}] {name} {how}')
+ return '\n'.join(lines)
+
+
+_MCP_GET_SKIP = frozenset({
+ 'enabled',
+ 'source',
+ 'meta',
+ 'session_kwargs',
+ 'httpx_client_factory',
+ 'encoding',
+ 'encoding_error_handler',
+ 'sse_read_timeout',
+ '_removed',
+})
+
+_MCP_GET_ORDER = (
+ 'type',
+ 'transport',
+ 'url',
+ 'command',
+ 'args',
+ 'timeout',
+ 'headers',
+ 'env',
+ 'include',
+ 'exclude',
+)
+
+
+def _mcp_display_scalar(key: str, value, *, bag: bool = False) -> str:
+ """Show a config value, masking a credential and leaving the rest.
+
+ A secret key name becomes ``***``. Inside ``headers`` / ``env``, a value
+ that itself looks like a credential does too. An ordinary field such as
+ ``url`` keeps the host and only replaces the credential inside it.
+ """
+ from ms_agent.agent_hub._secrets import redact_text
+ from ms_agent.agent_hub._workspace import is_secret_key
+ from ms_agent.command.usage import contains_secret
+
+ if value is None:
+ return ''
+ if isinstance(value, list):
+ text = ' '.join(str(item) for item in value)
+ else:
+ text = str(value)
+ if text == '':
+ return ''
+ if is_secret_key(key) or (bag and contains_secret(text)):
+ return '***'
+ cleaned, hits = redact_text(text)
+ return cleaned if hits else text
+
+
+def _mcp_get_lines(name: str, scope: str, entry: dict) -> list[str]:
+ on = 'on' if entry.get('enabled', True) is not False else 'off'
+ lines = [f'{name} ({scope}):', f' enabled: {on}']
+ keys = [key for key in _MCP_GET_ORDER if key in entry]
+ keys.extend(
+ key for key in entry if key not in _MCP_GET_SKIP and key not in keys)
+ for key in keys:
+ value = entry.get(key)
+ if isinstance(value, dict):
+ lines.append(f' {key}:')
+ for inner, inner_val in value.items():
+ shown = _mcp_display_scalar(str(inner), inner_val, bag=True)
+ lines.append(f' {inner}: {shown}')
+ continue
+ lines.append(f' {key}: {_mcp_display_scalar(key, value)}')
+ return lines
+
+
+async def _refresh_mcp_view(ctx) -> None:
+ """Read process state for /mcp. Does not wait out a cold startup."""
+ runtime = _mcp_runtime(ctx)
+ refresh = getattr(runtime, 'refresh_for_turn', None)
+ if refresh is None:
+ return
+ try:
+ await refresh(wait=False)
+ except Exception:
+ logger.debug('mcp view refresh failed', exc_info=True)
+
+
+def _mcp_runtime_note(state) -> list[str]:
+ """Tool count, last error, and whether a stdio child is alive."""
+ mark = _status_mark(state)
+ if not mark:
+ return []
+ lines = [f' status: {mark}']
+ if hasattr(state, 'tool_count'):
+ lines.append(f' tools: {getattr(state, "tool_count", 0)}')
+ err = getattr(state, 'last_error', None)
+ if err and mark != '已连接':
+ lines.append(f' last_error: {_clip_reason(err)}')
+ config = getattr(state, 'config', None) or {}
+ if config.get('command') or getattr(state, 'process_exited', False):
+ alive = (
+ '在' if getattr(state, 'status', None) == 'connected' else '不在')
+ lines.append(f' process: {alive}')
+ return lines
+
+
+async def _mcp_get(
+ ctx,
+ tokens: list[str],
+ mgr,
+ work: str | None,
+) -> CommandResult:
+ """One server's saved config. Both copies when the name is in each."""
+ await _refresh_mcp_view(ctx)
+ scope, rest = parse_optional_scope(tokens)
+ if not rest:
+ return arg_error('/mcp get [global|project]', ctx=ctx)
+ if scope == 'project':
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ name = ' '.join(rest)
+ scopes = (scope, ) if scope else ('global', 'project')
+ blocks: list[str] = []
+ for item_scope in scopes:
+ if item_scope == 'project' and not work:
+ continue
+ try:
+ rows = mgr.list(item_scope)
+ except Exception: # noqa: BLE001 - missing root is an empty list
+ rows = {}
+ entry = rows.get(name)
+ if entry:
+ blocks.append('\n'.join(_mcp_get_lines(name, item_scope, entry)))
+ if not blocks:
+ where = f' in {scope}' if scope else ''
+ return arg_error(
+ '/mcp get [global|project]',
+ reason=f'No MCP server named {name}{where}',
+ ctx=ctx,
+ )
+ content = '\n\n'.join(blocks)
+ note = _mcp_runtime_note(_server_state(ctx, name))
+ if note:
+ content = content + '\n' + '\n'.join(note)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=content,
+ )
+
+
+async def _mcp_retry(ctx, tokens: list[str], mgr) -> CommandResult:
+ """Connect one server again. A cold startup stays in the background."""
+ if not tokens:
+ return arg_error('/mcp retry ', ctx=ctx)
+ name = ' '.join(tokens)
+ try:
+ rows = mgr.list('merged')
+ except Exception: # noqa: BLE001 - missing root is an empty list
+ rows = {}
+ if name not in rows:
+ return arg_error(
+ '/mcp retry ',
+ reason=f'No MCP server named {name}',
+ ctx=ctx,
+ )
+ runtime = _mcp_runtime(ctx)
+ retry = getattr(runtime, 'retry_server', None)
+ if retry is None:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='当前没有会话。下一次运行会重新连接。',
+ )
+ try:
+ state = await retry(name)
+ except KeyError:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='当前会话还没有这台服务器。下一条消息会再试。',
+ )
+ except ValueError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ mark = _status_mark(state) or getattr(state, 'status', '')
+ line = f'{name}: {mark}'
+ err = getattr(state, 'last_error', None)
+ if err and mark not in ('已连接', '启动中'):
+ line = f'{line}\n{_clip_reason(err)}'
+ return CommandResult(type=CommandResultType.MESSAGE, content=line)
+
+
+async def cmd_mcp(ctx: CommandContext) -> CommandResult:
+ arg = (ctx.args or '').strip()
+ home, work = _home_work(ctx)
+ from ms_agent.config.mcp_manager import MCPConfigManager
+ mgr = MCPConfigManager(home, work)
+ if not arg:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=status_then_usage(
+ _mcp_list_text(mgr, 'merged', ctx), _mcp_usage()),
+ )
+ if arg in ('help', '-h', '--help'):
+ return CommandResult(type=CommandResultType.MESSAGE, content=_mcp_usage())
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ action = parts[0].lower()
+ tokens = parts[1:]
+
+ if action == 'list':
+ scope, rest = _parse_scope(tokens, default='merged')
+ if rest:
+ return arg_error(
+ '/mcp list [global|project]',
+ reason='Unknown list option',
+ ctx=ctx,
+ )
+ if scope not in ('global', 'project', 'merged'):
+ scope = 'merged'
+ note = ''
+ if scope == 'project':
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ if scope in ('global', 'project'):
+ other = 'global' if scope == 'project' else 'project'
+ try:
+ here = mgr.list(scope)
+ other_rows = mgr.list(other)
+ except Exception: # noqa: BLE001 - missing root is an empty list
+ here, other_rows = {}, {}
+ if scope not in here and scope in other_rows:
+ note = f'名叫 {scope} 的服务器不在这份清单里。\n'
+ await _refresh_mcp_view(ctx)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=note + _mcp_list_text(mgr, scope, ctx),
+ )
+
+ if action == 'get':
+ return await _mcp_get(ctx, tokens, mgr, work)
+
+ if action == 'retry':
+ return await _mcp_retry(ctx, tokens, mgr)
+
+ if action == 'json':
+ if not tokens:
+ return arg_error('/mcp json ', ctx=ctx)
+ n = mgr.import_cursor_format(tokens[0])
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Imported {n} server(s).',
+ )
+
+ if action in ('enable', 'disable', 'remove'):
+ if not tokens:
+ return arg_error(
+ f'/mcp {action} [global|project]', ctx=ctx)
+ scope, rest, _ = _parse_named_scope(tokens, default='project')
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ if not rest:
+ return arg_error(
+ f'/mcp {action} [global|project]', ctx=ctx)
+ name = rest[0]
+ try:
+ if action == 'remove':
+ mgr.remove(name, scope=scope)
+ else:
+ mgr.set_enabled(name, action == 'enable', scope=scope)
+ except KeyError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'{action} {name} ({scope}).',
+ )
+
+ if action == 'add':
+ rest, fields = _split_kv(tokens)
+ scope, rest, explicit = _parse_named_scope(rest, default='project')
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ if not rest:
+ return arg_error(
+ '/mcp add [global|project] command=|url=',
+ ctx=ctx,
+ )
+ name = ' '.join(rest)
+ if 'command' not in fields and 'url' not in fields:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Need command= or url=.',
+ )
+ mgr.add(name, _stdio_fields(fields), scope=scope)
+ lines = [f'Added {name} ({scope}).']
+ if scope == 'project' and not explicit:
+ lines.append(_PROJECT_MCP_NOT_ON_SETTINGS)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='\n'.join(lines),
+ )
+
+ if action == 'update':
+ rest, fields = _split_kv(tokens)
+ scope, rest, _ = _parse_named_scope(rest, default='project')
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ if not rest or not fields:
+ return arg_error(
+ '/mcp update [global|project] command=|url=',
+ ctx=ctx,
+ )
+ name = ' '.join(rest)
+ try:
+ mgr.update(name, _stdio_fields(fields), scope=scope)
+ except KeyError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Updated {name} ({scope}).',
+ )
+
+ return arg_error(
+ '/mcp list|get|retry|add|update|json|enable|disable|remove ...',
+ reason=f'Unknown mcp action {action!r}',
+ note='Type /mcp for all commands',
+ ctx=ctx,
+ )
+
+
+async def cmd_skills(ctx: CommandContext) -> CommandResult:
+ arg = (ctx.args or '').strip()
+ home, work = _home_work(ctx)
+ from ms_agent.config.skills_manager import SkillsConfigManager
+ mgr = SkillsConfigManager(global_dir=home)
+ runtime = getattr(ctx.runtime, '_skill_runtime', None)
+ if not arg:
+ listed = (
+ _format_skill_rows(runtime.list_all()) if runtime is not None else
+ _list_skills_from_disk(mgr, work))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=status_then_usage(listed.content, _skill_usage()),
+ )
+ if arg in ('help', '-h', '--help'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=_skill_usage())
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ action = parts[0].lower()
+ tokens = parts[1:]
+
+ if action == 'list':
+ if runtime is not None:
+ return _format_skill_rows(runtime.list_all())
+ return _list_skills_from_disk(mgr, work)
+
+ if action in ('enable', 'disable'):
+ resolved = await _resolve_skill_scope(
+ tokens,
+ work,
+ syntax=f'/skills {action} [global|project]',
+ ctx=ctx,
+ mgr=mgr,
+ action=action)
+ if isinstance(resolved, CommandResult):
+ return resolved
+ scope, rest = resolved
+ skill_id = ' '.join(rest)
+ mgr.set_skill_enabled(
+ skill_id, action == 'enable', scope=scope, project_path=work)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'{action} {skill_id} ({scope}).',
+ )
+
+ if action == 'add':
+ resolved = await _resolve_skill_scope(
+ tokens,
+ work,
+ syntax='/skills add [global|project]',
+ ctx=ctx,
+ action='add')
+ if isinstance(resolved, CommandResult):
+ return resolved
+ scope, rest = resolved
+ try:
+ names = mgr.import_from_path(
+ ' '.join(rest), scope=scope, project_path=work)
+ except FileNotFoundError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ if not names:
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='No SKILL.md found under that path.',
+ )
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Imported: ' + ', '.join(names) + f' ({scope}).',
+ )
+
+ if action == 'remove':
+ resolved = await _resolve_skill_scope(
+ tokens,
+ work,
+ syntax='/skills remove [global|project]',
+ ctx=ctx,
+ mgr=mgr,
+ action='remove')
+ if isinstance(resolved, CommandResult):
+ return resolved
+ scope, rest = resolved
+ skill_id = ' '.join(rest)
+ try:
+ dest = mgr.remove_imported(
+ skill_id, scope=scope, project_path=work)
+ except (FileNotFoundError, ValueError) as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Removed managed skill {skill_id} ({dest}).',
+ )
+
+ return arg_error(
+ '/skills list|add|enable|disable|remove ...',
+ reason=f'Unknown skills action {action!r}',
+ note='Type /skills for all commands',
+ ctx=ctx,
+ )
+
+
+def _managed_skill_scopes(mgr, skill_id: str, work: str | None) -> list[str]:
+ """Scopes whose live tree contains ``/SKILL.md``."""
+ found: list[str] = []
+ if (mgr.global_skills_tree() / skill_id / 'SKILL.md').is_file():
+ found.append('global')
+ if work and (mgr.project_skills_tree(work) / skill_id / 'SKILL.md').is_file():
+ found.append('project')
+ return found
+
+
+def _disabled_skill_scopes(mgr, skill_id: str, work: str | None) -> list[str]:
+ found: list[str] = []
+ if skill_id in set(mgr.load_global().get('disabled') or []):
+ found.append('global')
+ if work and skill_id in set(mgr.load_project(work).get('disabled') or []):
+ found.append('project')
+ return found
+
+
+def _pick_existing_skill_scope(mgr, skill_id: str, work: str | None,
+ action: str) -> str | None:
+ """The one scope that already has this skill, ``both``, or None.
+
+ Enable follows the disabled list when one is set, so a global-only
+ skill that was turned off globally is turned back on there. Disable
+ and remove follow the managed copy on disk.
+ """
+ files = _managed_skill_scopes(mgr, skill_id, work)
+ if action == 'enable':
+ disabled = _disabled_skill_scopes(mgr, skill_id, work)
+ if len(disabled) == 1:
+ return disabled[0]
+ if len(disabled) > 1:
+ return 'both'
+ if len(files) == 1:
+ return files[0]
+ if len(files) > 1:
+ return 'both'
+ return None
+
+
+async def _resolve_skill_scope(
+ tokens: list[str],
+ work: str | None,
+ *,
+ syntax: str,
+ ctx: CommandContext,
+ mgr=None,
+ action: str | None = None,
+) -> tuple[str, list[str]] | CommandResult:
+ if not tokens:
+ return arg_error(syntax, ctx=ctx)
+ scope, rest = parse_optional_scope(tokens)
+ if not rest:
+ return arg_error(syntax, ctx=ctx)
+ if scope is None and mgr is not None and action in (
+ 'enable', 'disable', 'remove'):
+ skill_id = ' '.join(rest)
+ picked = _pick_existing_skill_scope(mgr, skill_id, work, action)
+ if picked == 'both':
+ # Leave both copies where they are. The menu picks which one.
+ chosen = await pick_scope(
+ ctx,
+ syntax=syntax,
+ header=f'{skill_id} 在两处都有。改哪一处?',
+ reason=f'{skill_id} exists in both global and project',
+ note='Pass global or project.',
+ )
+ if isinstance(chosen, CommandResult):
+ return chosen
+ scope = chosen
+ elif picked:
+ scope = picked
+ if scope is None and action == 'add':
+ # A new slash-command skill lands on this machine unless the menu
+ # (or an explicit scope) places it in this folder.
+ choices = list(SCOPE_CHOICES) if work else (('global', '这台机器'), )
+ chosen = await pick_scope(
+ ctx,
+ syntax=syntax,
+ header='新技能放到哪里?',
+ choices=choices,
+ silent_default='global',
+ )
+ if isinstance(chosen, CommandResult):
+ return chosen
+ scope = chosen
+ if scope is None:
+ scope = 'global'
+ miss = _need_work(scope, work)
+ if miss:
+ return miss
+ return scope, rest
+
+
+def _format_skill_rows(rows) -> CommandResult:
+ if not rows:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content='No skills.')
+ lines = ['Skills:']
+ for row in rows:
+ on = 'on' if row.get('enabled') else 'off'
+ lines.append(
+ f' [{on}] {row["skill_id"]} {row.get("name") or ""}'.rstrip())
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content='\n'.join(lines))
+
+
+def _list_skills_from_disk(mgr, work: str | None) -> CommandResult:
+ """Scan live trees the same way WebUI does (presence = registered)."""
+ from ms_agent.skill.loader import SkillLoader
+ merged = mgr.load_merged(work)
+ sources = [
+ source for source in (merged.get('sources') or [])
+ if isinstance(source, str)
+ ]
+ try:
+ discovered = SkillLoader().discover_skills(sources)
+ except Exception: # noqa: BLE001 — listing must not crash the TUI
+ discovered = {}
+ disabled = set(merged.get('disabled') or [])
+ by_id: dict[str, dict] = {}
+ for desc in discovered.values():
+ by_id[desc.skill_id] = {
+ 'skill_id': desc.skill_id,
+ 'name': desc.name or '',
+ 'enabled': desc.skill_id not in disabled,
+ }
+ rows = [by_id[sid] for sid in sorted(by_id)]
+ return _format_skill_rows(rows)
+
+
+def register_resource_commands(router: CommandRouter) -> None:
+ router.register(CMD_MCP, cmd_mcp)
+ router.register(CMD_SKILL_MANAGE, cmd_skills)
diff --git a/ms_agent/command/builtin/search_cmds.py b/ms_agent/command/builtin/search_cmds.py
new file mode 100644
index 000000000..9ebfb0e9a
--- /dev/null
+++ b/ms_agent/command/builtin/search_cmds.py
@@ -0,0 +1,156 @@
+"""Slash command for web-search settings (same settings.json block as WebUI)."""
+from __future__ import annotations
+
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import (CommandContext, CommandDef, CommandResult,
+ CommandResultType)
+from ms_agent.command.usage import (arg_error, ledger_file, status_then_usage)
+
+CMD_SEARCH = CommandDef(
+ name='search',
+ description='Show or set the web-search engine',
+ category='config',
+)
+
+
+def _usage() -> str:
+ return (
+ 'usage:\n'
+ ' /search\n'
+ ' /search list\n'
+ ' /search engine \n'
+ ' /search key \n'
+ ' /search key clear\n'
+ ' /search enable|disable\n'
+ f'Saved in {ledger_file("settings.json")}.'
+ )
+
+
+def _mgr():
+ from ms_agent.config.search_settings import SearchSettingsManager
+ from ms_agent.project.paths import global_home
+ return SearchSettingsManager(global_home())
+
+
+def _engine_notice(engine: str, *, has_key: bool, requires_key: bool) -> str:
+ from ms_agent.tools.search.availability import unavailable_notice
+ return unavailable_notice(
+ engine, has_key=has_key, requires_key=requires_key) or ''
+
+
+def _status_text(mgr) -> str:
+ from ms_agent.config.search_settings import requires_key
+ cur = mgr.get()
+ on = 'on' if cur.enabled else 'off'
+ key = 'set' if cur.has_key else 'missing'
+ needed = requires_key(cur.engine)
+ if not needed:
+ key = 'not required'
+ elif cur.supports_keyless and not cur.has_key:
+ key = 'missing (keyless tier may still work)'
+ lines = [
+ f'Search: {on}',
+ f'Engine: {cur.engine}',
+ f'API key: {key}',
+ ]
+ notice = _engine_notice(
+ cur.engine, has_key=cur.has_key, requires_key=needed)
+ if notice:
+ lines.append(notice)
+ lines.extend([
+ 'Switch: /search engine Key: /search key ',
+ 'List engines: /search list',
+ ])
+ return '\n'.join(lines)
+
+
+async def cmd_search(ctx: CommandContext) -> CommandResult:
+ arg = (ctx.args or '').strip()
+ mgr = _mgr()
+ if not arg or arg in ('help', '-h', '--help'):
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=status_then_usage(_status_text(mgr), _usage()),
+ )
+
+ import shlex
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ parts = arg.split()
+ action = parts[0].lower()
+ rest = parts[1:]
+
+ if action == 'list':
+ lines = ['Search engines:']
+ current = mgr.get().engine
+ for row in mgr.list_engines():
+ mark = '*' if row['id'] == current else ' '
+ key = 'key=set' if row['has_key'] else (
+ 'key=n/a' if not row['requires_key'] else 'key=missing')
+ extra = ' keyless-ok' if row['supports_keyless'] else ''
+ notice = _engine_notice(
+ row['id'],
+ has_key=row['has_key'],
+ requires_key=row['requires_key'],
+ )
+ if notice:
+ extra = f'{extra} {notice}'
+ lines.append(
+ f' {mark} {row["id"]} {row["label"]} {key}{extra}')
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content='\n'.join(lines))
+
+ if action in ('enable', 'on'):
+ mgr.set_enabled(True)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Search enabled.',
+ )
+ if action in ('disable', 'off'):
+ mgr.set_enabled(False)
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content='Search disabled.',
+ )
+
+ if action == 'engine':
+ if not rest:
+ return arg_error(
+ '/search engine ', ctx=ctx)
+ try:
+ cur = mgr.set_engine(rest[0])
+ except ValueError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'Engine → {cur.engine}.',
+ )
+
+ if action == 'key':
+ if not rest:
+ return arg_error('/search key |clear', ctx=ctx)
+ raw = ' '.join(rest)
+ clear = raw.lower() in ('clear', 'none', '-')
+ try:
+ mgr.set_api_key(None if clear else raw)
+ except ValueError as exc:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content=str(exc))
+ verb = 'cleared' if clear else 'saved'
+ return CommandResult(
+ type=CommandResultType.MESSAGE,
+ content=f'API key {verb} for {mgr.get().engine}.',
+ )
+
+ return arg_error(
+ '/search engine|key|list|enable|disable ...',
+ reason=f'Unknown search action {action!r}',
+ note='Type /search for status and all commands',
+ ctx=ctx,
+ )
+
+
+def register_search_commands(router: CommandRouter) -> None:
+ router.register(CMD_SEARCH, cmd_search)
diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py
index 591bee092..fc0484e3d 100644
--- a/ms_agent/command/interactive.py
+++ b/ms_agent/command/interactive.py
@@ -79,6 +79,8 @@ async def run_turn(
self,
messages: Optional[List[Any]] = None,
runtime: Any = None,
+ *,
+ retry_when=None,
) -> InteractiveTurn:
"""Read input at ``>>>`` until a real prompt or a quit signal.
@@ -126,9 +128,17 @@ async def run_turn(
'persist_context': self._persist_context,
'router': self._router,
'messages': messages if messages is not None else [],
+ # TUI input_source only — never a blocking menu in
+ # CLI/tests that drive InteractiveSession via input().
+ 'interactive': self._input_source is not None,
},
)
- result = await self._router.dispatch(ctx)
+ result = None
+ try:
+ result = await self._router.dispatch(ctx)
+ except Exception as exc: # noqa: BLE001 — command bugs stay in-loop
+ self._emit(f'command failed: {type(exc).__name__}: {exc}')
+ continue
if result is None:
# Unrecognized command — treat it as a normal prompt.
return InteractiveTurn(action='submit', text=query)
@@ -139,8 +149,36 @@ async def run_turn(
if result.type == CommandResultType.SUBMIT_PROMPT:
return InteractiveTurn(action='submit', text=result.content)
# MESSAGE / MUTATE_STATE: show output and prompt again.
+ if result.type == CommandResultType.MUTATE_STATE:
+ self._sync_model_label(runtime)
if result.content:
self._emit(result.content)
+ # A key just saved can build a client. Hand the original sentence
+ # back instead of waiting for the user to type it again.
+ if retry_when is not None:
+ try:
+ ready = bool(retry_when())
+ except Exception: # noqa: BLE001 — a probe must not trap the prompt
+ ready = False
+ if ready:
+ return InteractiveTurn(action='submit', text='')
+
+ def _sync_model_label(self, runtime) -> None:
+ """Push the live model id into the TUI status bar, when one is attached."""
+ source = self._input_source
+ note = getattr(source, 'note_model', None)
+ if not callable(note) or runtime is None:
+ return
+ llm = getattr(runtime, 'llm', None)
+ model = str(getattr(llm, 'model', '') or '') if llm is not None else ''
+ if not model and llm is not None:
+ config = getattr(llm, 'config', None)
+ if config is not None:
+ from omegaconf import OmegaConf
+ model = str(
+ OmegaConf.select(config, 'llm.model', default='') or '')
+ if model:
+ note(model)
def _emit(self, text: str) -> None:
if self._event_sink is not None:
diff --git a/ms_agent/command/scope.py b/ms_agent/command/scope.py
new file mode 100644
index 000000000..c65eec709
--- /dev/null
+++ b/ms_agent/command/scope.py
@@ -0,0 +1,150 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Scope tokens for slash commands: a default when one exists, else a menu.
+
+Claude-style: only silent-default a scope when it cannot send the user looking
+in the wrong place. Otherwise pick interactively (TUI arrow menu) or print a
+focused ``Need:`` line so scripts/tests never block.
+"""
+from __future__ import annotations
+
+import inspect
+import os
+import sys
+from typing import Sequence
+
+from ms_agent.command.types import CommandContext, CommandResult, CommandResultType
+from ms_agent.command.usage import arg_error
+
+SCOPE_TOKENS = frozenset({'global', 'project'})
+
+# (scope, label). The TUI arrow menu shows these two rows.
+SCOPE_CHOICES: tuple[tuple[str, str], ...] = (
+ ('global', '这台机器'),
+ ('project', '这个文件夹'),
+)
+
+
+def parse_optional_scope(tokens: Sequence[str]) -> tuple[str | None, list[str]]:
+ """Pull a trailing global|project token when a name/path precedes it.
+
+ A lone ``project`` (or ``global``) is the id/path, not a scope — otherwise
+ ``/skills disable project`` could never target that skill.
+ """
+ rest = list(tokens)
+ if len(rest) >= 2 and rest[-1] in SCOPE_TOKENS:
+ return rest[-1], rest[:-1]
+ return None, rest
+
+
+def work_dir_of(ctx: CommandContext) -> str | None:
+ """Resolve ``--work-dir`` from whatever the dispatcher passed as runtime.
+
+ Live TUI/CLI slash commands receive ``ms_agent.agent.runtime.Runtime``
+ (``runtime.llm.config.output_dir``). Tests often pass a fake agent with
+ ``runtime.config.output_dir``. Accept either, plus ``extra['work_dir']``.
+ """
+ extra = (ctx.extra or {}).get('work_dir')
+ if extra:
+ return str(extra)
+ runtime = ctx.runtime
+ if runtime is None:
+ return None
+ for holder in (runtime, getattr(runtime, 'llm', None)):
+ if holder is None:
+ continue
+ config = getattr(holder, 'config', None)
+ work = getattr(config, 'output_dir', None) if config is not None else None
+ if work:
+ return str(work)
+ return None
+
+
+def can_pick_interactively(ctx: CommandContext) -> bool:
+ """True only for a live TUI prompt on a TTY.
+
+ Router-dispatched tests (and pipes) must not open a blocking menu.
+ ``InteractiveSession`` sets ``extra['interactive']`` when it has a TUI
+ input source. Pytest is never interactive even if stdin is a TTY.
+ """
+ extra = ctx.extra or {}
+ if extra.get('choose_scope'):
+ return False
+ if os.environ.get('PYTEST_CURRENT_TEST'):
+ return False
+ if not extra.get('interactive'):
+ return False
+ try:
+ return bool(sys.stdin.isatty())
+ except Exception: # noqa: BLE001
+ return False
+
+
+async def pick_scope(
+ ctx: CommandContext,
+ *,
+ syntax: str,
+ header: str,
+ choices: Sequence[tuple[str, str]] | None = None,
+ note: str = 'Pass global or project — there is no default.',
+ reason: str = '',
+ silent_default: str | None = None,
+) -> str | CommandResult:
+ """Return a scope, or a CommandResult (Need: / Cancelled).
+
+ One remaining choice is taken without asking. A live TUI uses the same
+ arrow menu as permissions (``select_async``). ``silent_default`` is used
+ only when nobody is there to answer, so a script still writes the new
+ skill onto this machine. Tests may inject ``extra['choose_scope']``.
+ """
+ rows = list(choices) if choices is not None else list(SCOPE_CHOICES)
+ if len(rows) == 1:
+ return rows[0][0]
+ if not rows:
+ return arg_error(syntax, reason='No global or project copy found', ctx=ctx)
+
+ labels = [label for _, label in rows]
+ picked = await _invoke_test_picker(ctx, labels, header)
+ if picked is None and can_pick_interactively(ctx):
+ from ms_agent.tui.select import select_async
+ # First row is 这台机器, so Enter keeps a new skill on this machine.
+ picked = await select_async(labels, header=header)
+ if picked is None:
+ return CommandResult(
+ type=CommandResultType.MESSAGE, content='Cancelled.')
+
+ scope = _coerce_pick(picked, rows)
+ if scope is not None:
+ return scope
+ if silent_default in ('global', 'project'):
+ return silent_default
+ return arg_error(syntax, reason=reason, note=note, ctx=ctx)
+
+
+async def _invoke_test_picker(
+ ctx: CommandContext,
+ labels: Sequence[str],
+ header: str,
+):
+ picker = (ctx.extra or {}).get('choose_scope')
+ if not callable(picker):
+ return None
+ try:
+ result = picker(labels, header)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ except Exception: # noqa: BLE001 — a test hook must not crash the TUI
+ return None
+
+
+def _coerce_pick(picked, rows: Sequence[tuple[str, str]]) -> str | None:
+ if picked is None:
+ return None
+ if isinstance(picked, int) and 0 <= picked < len(rows):
+ return rows[picked][0]
+ if isinstance(picked, str):
+ low = picked.strip().lower()
+ for scope, label in rows:
+ if low in (scope, label.lower()):
+ return scope
+ return None
diff --git a/ms_agent/command/skill_bridge.py b/ms_agent/command/skill_bridge.py
index f53b9a1f7..5a19c2a6f 100644
--- a/ms_agent/command/skill_bridge.py
+++ b/ms_agent/command/skill_bridge.py
@@ -25,6 +25,7 @@
from ms_agent.command.router import CommandRouter
from ms_agent.command.types import (CommandContext, CommandResult,
CommandResultType)
+from ms_agent.skill.harness import fill_harness_placeholders
if TYPE_CHECKING:
from ms_agent.skill.catalog import SkillCatalog
@@ -64,6 +65,11 @@ def expand_skill(catalog: 'SkillCatalog', name_or_id: str,
return None
body = _strip_frontmatter(skill.content)
+ body = fill_harness_placeholders(
+ body,
+ getattr(catalog, '_agent_config', None)
+ or getattr(catalog, '_config', None),
+ skill.skill_id)
body = body.replace('$ARGUMENTS', args)
tail = (
diff --git a/ms_agent/command/usage.py b/ms_agent/command/usage.py
new file mode 100644
index 000000000..b7b7e8335
--- /dev/null
+++ b/ms_agent/command/usage.py
@@ -0,0 +1,83 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Focused usage errors for slash commands.
+
+Bare ``/cmd`` (or ``/cmd help``) still shows the full sheet. A recognized
+subcommand invoked with the wrong shape should say what was missing and
+print that one syntax line — not the entire help dump.
+"""
+from __future__ import annotations
+
+from ms_agent.command.types import CommandContext, CommandResult, CommandResultType
+
+
+def ledger_dir() -> str:
+ """Effective global home for this process (honors MS_AGENT_HOME)."""
+ from ms_agent.project.paths import global_home
+ return str(global_home())
+
+
+def ledger_file(rel: str) -> str:
+ """``{global_home}/{rel}`` at call time (not import time)."""
+ return f'{ledger_dir()}/{rel}'
+
+
+def status_then_usage(status: str, usage: str) -> str:
+ """Bare ``/cmd``: a short identity card, then the usage sheet.
+
+ Status stays a handful of lines (or a truncated preview). The sheet is
+ below so the current value is what the user sees first.
+ """
+ body = (status or '').rstrip()
+ sheet = (usage or '').strip()
+ if not body:
+ return sheet
+ if not sheet:
+ return body
+ return f'{body}\n\n{sheet}'
+
+
+def mask_secrets(text: str) -> str:
+ """Replace high-confidence secret values, using the convert-upload filter."""
+ if not text:
+ return text
+ from ms_agent.agent_hub._secrets import redact_text
+ cleaned, hits = redact_text(text)
+ return cleaned if hits else text
+
+
+def contains_secret(text: str) -> bool:
+ """True when ``text`` itself looks like a credential (``sk-…``, and the same)."""
+ if not text:
+ return False
+ from ms_agent.agent_hub._secrets import redact_text
+ _cleaned, hits = redact_text(text)
+ return bool(hits)
+
+
+def arg_error(
+ syntax: str,
+ *,
+ reason: str = '',
+ note: str = '',
+ got: str = '',
+ ctx: CommandContext | None = None,
+) -> CommandResult:
+ """Build a short parse/usage error for a known command.
+
+ ``syntax`` is the one line the user should type, e.g.
+ ``/model catalog remove ``.
+ """
+ if ctx is not None and not got:
+ got = str(getattr(ctx, 'raw_input', '') or '').strip()
+ lines: list[str] = []
+ if reason:
+ text = reason.rstrip()
+ if text[-1:] not in '.!?':
+ text += '.'
+ lines.append(text)
+ lines.append(f'Need: {syntax}')
+ if got:
+ lines.append(f'Got: {mask_secrets(got)}')
+ if note:
+ lines.append(note)
+ return CommandResult(type=CommandResultType.MESSAGE, content='\n'.join(lines))
diff --git a/ms_agent/config/mcp_manager.py b/ms_agent/config/mcp_manager.py
index 721796a4f..d4112f3f0 100644
--- a/ms_agent/config/mcp_manager.py
+++ b/ms_agent/config/mcp_manager.py
@@ -125,8 +125,10 @@ def list(self, scope: MCPScope = 'merged') -> Dict[str, Dict[str, Any]]:
self._load_scope_raw('project'), source='project')
global_servers = self._normalize_scope(
self._load_scope_raw('global'), source='global')
- project_servers = self._normalize_scope(
- self._load_scope_raw('project'), source='project')
+ project_servers: Dict[str, Dict[str, Any]] = {}
+ if self.project_root is not None:
+ project_servers = self._normalize_scope(
+ self._load_scope_raw('project'), source='project')
from ms_agent.config.mcp_schema import merge_mcp_layers
return merge_mcp_layers(global_servers, project_servers)
diff --git a/ms_agent/config/model_settings.py b/ms_agent/config/model_settings.py
index dccc7591e..4b8bc0a0f 100644
--- a/ms_agent/config/model_settings.py
+++ b/ms_agent/config/model_settings.py
@@ -42,6 +42,21 @@ def resolve_model_settings(data: dict, provider: str, model: str) -> dict:
return result
+def strip_provider_model_prefix(provider: str | None, model: str | None) -> str:
+ """Drop a duplicated `` `` prefix from a stored model id.
+
+ A failed space-form persist can leave ``default_model`` as
+ ``minimax/minimax MiniMax-M2.1``. First-slash split then yields
+ service=minimax and model=``minimax MiniMax-M2.1``, which is not a
+ vendor id.
+ """
+ provider = str(provider or '').strip()
+ model = str(model or '').strip()
+ if provider and model.lower().startswith(provider.lower() + ' '):
+ return model[len(provider):].strip()
+ return model
+
+
class ModelSettingsManager:
"""CRUD for custom providers/models + default model in settings.json."""
@@ -120,6 +135,46 @@ def remove_provider(self, provider_id: str) -> None:
data.pop('default_model', None)
self._save_raw(data)
+ @locked(lambda self, *args, **kwargs: self._path)
+ def patch_provider(
+ self,
+ provider_id: str,
+ *,
+ name: Optional[str] = None,
+ protocol: Optional[str] = None,
+ api_key: Optional[str] = None,
+ base_url: Optional[str] = None,
+ clear_api_key: bool = False,
+ clear_base_url: bool = False,
+ ) -> Dict[str, Any]:
+ """Partial update of a providers. entry (creates it if missing).
+
+ ``api_key=''`` / ``base_url=''`` also clear. Used by TUI ``/model
+ provider set|key|url`` so a key-only change does not reset protocol.
+ """
+ data = self._load_raw()
+ providers = data.setdefault('providers', {})
+ entry = dict(providers.get(provider_id) or {
+ 'name': provider_id,
+ 'protocol': 'openai',
+ 'models': [],
+ })
+ if name:
+ entry['name'] = name
+ if protocol:
+ entry['protocol'] = protocol
+ if clear_api_key or api_key == '':
+ entry.pop('api_key', None)
+ elif api_key is not None:
+ entry['api_key'] = api_key
+ if clear_base_url or base_url == '':
+ entry.pop('base_url', None)
+ elif base_url is not None:
+ entry['base_url'] = base_url
+ providers[provider_id] = entry
+ self._save_raw(data)
+ return entry
+
@locked(lambda self, *args, **kwargs: self._path)
def add_model(self, provider_id: str, model: str) -> None:
data = self._load_raw()
@@ -131,20 +186,24 @@ def add_model(self, provider_id: str, model: str) -> None:
self._save_raw(data)
@locked(lambda self, *args, **kwargs: self._path)
- def remove_model(self, provider_id: str, model: str) -> None:
+ def remove_model(self, provider_id: str, model: str) -> bool:
+ """Remove ``model`` from the provider catalog. True if it was present."""
data = self._load_raw()
entry = data.get('providers', {}).get(provider_id)
- if entry and model in entry.get('models', []):
- entry['models'].remove(model)
- llm = data.get('llm') or {}
- if (llm.get('provider'), llm.get('model')) == (provider_id, model):
- for key in ('api_key', 'base_url', 'protocol'):
- if key in llm and key not in entry:
- entry[key] = llm[key]
- data['llm'] = {}
- if data.get('default_model') in (f'{provider_id}/{model}', model):
- data.pop('default_model', None)
- self._save_raw(data)
+ models = (entry or {}).get('models') or []
+ if entry is None or model not in models:
+ return False
+ models.remove(model)
+ llm = data.get('llm') or {}
+ if (llm.get('provider'), llm.get('model')) == (provider_id, model):
+ for key in ('api_key', 'base_url', 'protocol'):
+ if key in llm and key not in entry:
+ entry[key] = llm[key]
+ data['llm'] = {}
+ if data.get('default_model') in (f'{provider_id}/{model}', model):
+ data.pop('default_model', None)
+ self._save_raw(data)
+ return True
# -- default model --
@@ -156,6 +215,17 @@ def get_default_model(self) -> Optional[str]:
def set_default_model(self,
model: str,
provider: Optional[str] = None) -> None:
+ model = strip_provider_model_prefix(provider, model)
data = self._load_raw()
data['default_model'] = f'{provider}/{model}' if provider else model
+ # Same shape WebUI writes: llm.provider + llm.model, so the next
+ # ConfigResolver pass (TUI or WebUI) sees the switch without a
+ # project patch.
+ if provider:
+ llm = data.get('llm')
+ if not isinstance(llm, dict):
+ llm = {}
+ llm['provider'] = provider
+ llm['model'] = model
+ data['llm'] = llm
self._save_raw(data)
diff --git a/ms_agent/config/resolver.py b/ms_agent/config/resolver.py
index ef7912643..589683193 100644
--- a/ms_agent/config/resolver.py
+++ b/ms_agent/config/resolver.py
@@ -430,7 +430,10 @@ def _settings_to_agent_config(settings: Dict[str, Any]) -> DictConfig:
agent_llm = agent_fields.setdefault('llm', {})
if not agent_llm.get('model'):
if '/' in default_model:
+ from ms_agent.config.model_settings import (
+ strip_provider_model_prefix)
prov, mdl = default_model.split('/', 1)
+ mdl = strip_provider_model_prefix(prov, mdl)
agent_llm.setdefault('service', prov)
agent_llm['model'] = mdl
else:
@@ -454,8 +457,34 @@ def _settings_to_agent_config(settings: Dict[str, Any]) -> DictConfig:
# (honored in ToolManager), which lets a higher layer turn a tool off.
if 'tools' in settings:
agent_fields['tools'] = settings['tools']
+ ConfigResolver._fill_provider_catalog(agent_fields, settings)
return OmegaConf.create(agent_fields)
+ @staticmethod
+ def _fill_provider_catalog(agent_fields: Dict[str, Any],
+ settings: Dict[str, Any]) -> None:
+ """Copy providers. credentials onto llm when the llm block only
+ names a provider (WebUI stores keys under ``providers``, not always
+ under ``llm.api_key``). Never overwrites an explicit llm value."""
+ providers = settings.get('providers')
+ if not isinstance(providers, dict):
+ return
+ agent_llm = agent_fields.get('llm')
+ if not isinstance(agent_llm, dict):
+ return
+ pid = str(agent_llm.get('service') or '')
+ entry = providers.get(pid)
+ if not pid or not isinstance(entry, dict):
+ return
+ if entry.get('api_key'):
+ agent_llm.setdefault(f'{pid}_api_key', entry['api_key'])
+ agent_llm.setdefault('api_key', entry['api_key'])
+ if entry.get('base_url'):
+ agent_llm.setdefault(f'{pid}_base_url', entry['base_url'])
+ agent_llm.setdefault('base_url', entry['base_url'])
+ if entry.get('protocol'):
+ agent_llm.setdefault('protocol', entry['protocol'])
+
@staticmethod
def _load_json_safe(path: Path) -> Dict[str, Any]:
if not path.exists():
diff --git a/ms_agent/config/search_settings.py b/ms_agent/config/search_settings.py
new file mode 100644
index 000000000..72c7e027d
--- /dev/null
+++ b/ms_agent/config/search_settings.py
@@ -0,0 +1,208 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""CRUD for ``settings.json`` ``tools.web_search`` (same block WebUI writes).
+
+Keys are per-engine (``tavily_api_key``, ``exa_api_key``, …) because that is
+what ``WebSearchTool`` reads. Switching engine must not drop another engine's
+key. Environment variables are ignored here: this object describes only what
+the settings file owns, matching the WebUI search page.
+"""
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+_PROVIDER_META: Dict[str, tuple[str, Optional[str]]] = {
+ 'tavily': ('Tavily Search', 'tavily_api_key'),
+ 'exa': ('Exa Search', 'exa_api_key'),
+ 'serpapi': ('SerpAPI', 'serpapi_api_key'),
+ 'arxiv': ('arXiv', None),
+}
+_KEYLESS = frozenset({'tavily', 'arxiv'})
+_LEGACY_EXA_FIELDS = ('exa_api_keys', 'api_key')
+_FALLBACK_ORDER = ('tavily', 'exa', 'serpapi', 'arxiv')
+
+# Tavily is the only web-wide engine that works with no key (keyless tier).
+# WebUI persists this block; TUI injects the same one at resolve time so a
+# fresh install can search before anyone opens settings.
+DEFAULT_WEB_SEARCH_BLOCK = {
+ 'mcp': False,
+ 'engine': 'tavily',
+ 'enabled': True,
+}
+
+
+def supported_engine_ids() -> List[str]:
+ try:
+ from ms_agent.tools.search.websearch_tool import WebSearchTool
+ ids = [str(e).lower() for e in WebSearchTool.SUPPORTED_ENGINES]
+ except Exception:
+ ids = list(_FALLBACK_ORDER)
+ known = [i for i in _FALLBACK_ORDER if i in ids]
+ extra = sorted(i for i in ids if i not in _FALLBACK_ORDER)
+ return known + extra
+
+
+def default_engine() -> str:
+ ids = supported_engine_ids()
+ if 'tavily' in ids:
+ return 'tavily'
+ return ids[0] if ids else 'tavily'
+
+
+def key_field_for(engine: str) -> Optional[str]:
+ return _PROVIDER_META.get(engine, (engine, f'{engine}_api_key'))[1]
+
+
+def key_fields_for(engine: str) -> tuple[str, ...]:
+ field = key_field_for(engine)
+ if field is None:
+ return ()
+ if engine == 'exa':
+ return (field, *_LEGACY_EXA_FIELDS)
+ return (field,)
+
+
+def requires_key(engine: str) -> bool:
+ return key_field_for(engine) is not None
+
+
+def supports_keyless(engine: str) -> bool:
+ return engine in _KEYLESS
+
+
+@dataclass(frozen=True)
+class SearchSettings:
+ enabled: bool
+ engine: str
+ has_key: bool
+ supports_keyless: bool
+
+
+class SearchSettingsManager:
+ """Read/write ``tools.web_search`` in settings.json."""
+
+ def __init__(self, global_dir: str | Path | None = None) -> None:
+ if global_dir is None:
+ from ms_agent.project.paths import global_home
+ self._dir = global_home()
+ else:
+ self._dir = Path(os.path.expanduser(str(global_dir)))
+ self._path = self._dir / 'settings.json'
+
+ def _load_raw(self) -> Dict[str, Any]:
+ if not self._path.is_file():
+ return {}
+ try:
+ with open(self._path, encoding='utf-8') as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except (json.JSONDecodeError, OSError):
+ return {}
+
+ def _save_raw(self, data: Dict[str, Any]) -> None:
+ self._dir.mkdir(parents=True, exist_ok=True)
+ tmp = self._path.with_suffix('.json.tmp')
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, self._path)
+
+ def _block(self, data: Dict[str, Any]) -> Dict[str, Any]:
+ tools = data.get('tools')
+ if not isinstance(tools, dict):
+ return {}
+ block = tools.get('web_search')
+ return dict(block) if isinstance(block, dict) else {}
+
+ def get(self) -> SearchSettings:
+ block = self._block(self._load_raw())
+ engine = str(block.get('engine') or '').lower() or default_engine()
+ if engine not in supported_engine_ids():
+ engine = default_engine()
+ return SearchSettings(
+ enabled=bool(block.get('enabled', True)),
+ engine=engine,
+ has_key=self._has_key(block, engine),
+ supports_keyless=supports_keyless(engine),
+ )
+
+ def list_engines(self) -> List[Dict[str, Any]]:
+ block = self._block(self._load_raw())
+ rows = []
+ for engine in supported_engine_ids():
+ label, _ = _PROVIDER_META.get(engine, (engine, None))
+ rows.append({
+ 'id': engine,
+ 'label': label,
+ 'requires_key': requires_key(engine),
+ 'supports_keyless': supports_keyless(engine),
+ 'has_key': self._has_key(block, engine),
+ })
+ return rows
+
+ def set_engine(self, engine: str) -> SearchSettings:
+ engine = engine.strip().lower()
+ if engine not in supported_engine_ids():
+ raise ValueError(
+ f'Unknown search engine: {engine}. '
+ f'Supported: {", ".join(supported_engine_ids())}')
+ data = self._load_raw()
+ block = self._ensure_block(data)
+ block['engine'] = engine
+ self._save_raw(data)
+ return self.get()
+
+ def set_enabled(self, enabled: bool) -> SearchSettings:
+ data = self._load_raw()
+ block = self._ensure_block(data)
+ block['enabled'] = bool(enabled)
+ self._save_raw(data)
+ return self.get()
+
+ def set_api_key(self, key: str | None, engine: str | None = None) -> SearchSettings:
+ current = self.get()
+ engine = (engine or current.engine).strip().lower()
+ field = key_field_for(engine)
+ if field is None:
+ raise ValueError(f'{engine} does not use an API key.')
+ data = self._load_raw()
+ block = self._ensure_block(data)
+ value = (key or '').strip()
+ if value:
+ block[field] = value
+ else:
+ block.pop(field, None)
+ if engine == 'exa':
+ for alias in _LEGACY_EXA_FIELDS:
+ block.pop(alias, None)
+ self._save_raw(data)
+ return self.get()
+
+ def raw_block(self) -> Dict[str, Any]:
+ return self._block(self._load_raw())
+
+ def _ensure_block(self, data: Dict[str, Any]) -> Dict[str, Any]:
+ tools = data.get('tools')
+ if not isinstance(tools, dict):
+ tools = {}
+ data['tools'] = tools
+ block = tools.get('web_search')
+ if not isinstance(block, dict):
+ # Same discriminator WebUI bootstrap writes.
+ block = {'mcp': False}
+ tools['web_search'] = block
+ else:
+ tools['web_search'] = block
+ if not block.get('engine'):
+ block['engine'] = default_engine()
+ if 'enabled' not in block:
+ block['enabled'] = True
+ return block
+
+ @staticmethod
+ def _has_key(block: Dict[str, Any], engine: str) -> bool:
+ return any(
+ str(block.get(field) or '').strip()
+ for field in key_fields_for(engine))
diff --git a/ms_agent/config/skills_manager.py b/ms_agent/config/skills_manager.py
index abe7db635..199070b10 100644
--- a/ms_agent/config/skills_manager.py
+++ b/ms_agent/config/skills_manager.py
@@ -185,6 +185,98 @@ def set_skill_enabled(
data['disabled'] = sorted(disabled)
self._write(path, data)
+ # -- live-tree import (same destination WebUI copies into) --
+
+ def import_from_path(
+ self,
+ source: str,
+ scope: str = 'global',
+ project_path: Optional[str] = None,
+ ) -> List[str]:
+ """Copy Skill directories into this scope's managed live tree.
+
+ A directory with ``SKILL.md`` is one skill; otherwise each immediate
+ child that has ``SKILL.md`` is imported. Existence in the live tree is
+ what the catalog discovers — no ``skills.json`` source row is added.
+ """
+ import shutil
+
+ root = Path(os.path.expanduser(str(source))).resolve()
+ if not root.is_dir():
+ raise FileNotFoundError(f'Not a directory: {root}')
+ dest_root = (
+ self.global_skills_tree() if scope != 'project' else
+ self.project_skills_tree(project_path or ''))
+ dest_root.mkdir(parents=True, exist_ok=True)
+
+ def _is_skill(path: Path) -> bool:
+ return path.is_dir() and (path / 'SKILL.md').is_file()
+
+ dirs = [root] if _is_skill(root) else [
+ child for child in sorted(root.iterdir()) if _is_skill(child)
+ ]
+ imported: List[str] = []
+ for src_dir in dirs:
+ dest = dest_root / src_dir.name
+ if dest.resolve() == src_dir.resolve():
+ imported.append(src_dir.name)
+ continue
+ if dest.exists():
+ shutil.rmtree(dest)
+ shutil.copytree(src_dir, dest)
+ imported.append(src_dir.name)
+ return imported
+
+ def remove_imported(
+ self,
+ skill_id: str,
+ scope: str = 'global',
+ project_path: Optional[str] = None,
+ ) -> Path:
+ """Delete a managed live-tree skill (same as WebUI managed origin).
+
+ Only removes ``//``. Auto-discovered
+ ``.agents/skills`` trees and other explicit sources are left alone —
+ disable those instead. Also drops this id from ``disabled`` and any
+ ``skills.json`` source that pointed at the deleted directory.
+ """
+ import shutil
+
+ skill_id = (skill_id or '').strip()
+ if (not skill_id or skill_id in ('.', '..') or '/' in skill_id
+ or '\\' in skill_id):
+ raise ValueError(f'Invalid skill id: {skill_id!r}')
+ dest_root = (
+ self.global_skills_tree() if scope != 'project' else
+ self.project_skills_tree(project_path or ''))
+ dest = (dest_root / skill_id).resolve()
+ root = dest_root.resolve()
+ try:
+ dest.relative_to(root)
+ except ValueError as exc:
+ raise ValueError(f'Invalid skill id: {skill_id!r}') from exc
+ if dest == root:
+ raise ValueError(f'Invalid skill id: {skill_id!r}')
+ if not dest.is_dir() or not (dest / 'SKILL.md').is_file():
+ raise FileNotFoundError(
+ f'{skill_id} is not a managed skill in {scope} scope; '
+ 'disable it instead.')
+ shutil.rmtree(dest)
+ data = self._read(self._resolve_path(scope, project_path))
+ if skill_id in (data.get('disabled') or []):
+ self.set_skill_enabled(
+ skill_id, True, scope=scope, project_path=project_path)
+ dest_str = str(dest)
+ for source in list(self.list_explicit_sources(
+ scope, project_path=project_path)):
+ try:
+ if str(Path(source).expanduser().resolve()) == dest_str:
+ self.remove_source(
+ source, scope=scope, project_path=project_path)
+ except OSError:
+ continue
+ return dest
+
# -- sources --
def add_source(
diff --git a/ms_agent/llm/credentials.py b/ms_agent/llm/credentials.py
index 76a0856dc..6cbb39628 100644
--- a/ms_agent/llm/credentials.py
+++ b/ms_agent/llm/credentials.py
@@ -12,6 +12,7 @@
from __future__ import annotations
import os
+import re
from omegaconf import DictConfig
from typing import Optional
@@ -30,6 +31,56 @@ def _cfg_get(config: DictConfig, field: str) -> Optional[str]:
return value or None
+def is_missing_api_key_error(exc: BaseException) -> bool:
+ """True when provider routing failed because no key is configured."""
+ return isinstance(exc, ValueError) and 'No API key found' in str(exc)
+
+
+def client_rejected_missing_key(exc: BaseException) -> bool:
+ """True when a client cannot be built because no key is configured.
+
+ The provider router raises ``ValueError``. The legacy ModelScope and
+ OpenAI constructors reach ``openai.OpenAI`` with an empty key, which
+ raises ``OpenAIError`` instead.
+ """
+ if is_missing_api_key_error(exc):
+ return True
+ return type(exc).__name__ == 'OpenAIError' and 'api_key' in str(exc).lower()
+
+
+def missing_api_key_error(config) -> ValueError:
+ """The error a turn raises when the live client was dropped for no key."""
+ from omegaconf import OmegaConf
+ service = str(OmegaConf.select(config, 'llm.service', default='') or '')
+ if not service:
+ return ValueError(
+ 'No API key found for provider. Set config.llm.api_key.')
+ return ValueError(
+ f'No API key found for provider "{service}". '
+ f'Set config.llm.{service}_api_key.')
+
+
+_PROVIDER_IN_MISSING_KEY = re.compile(r'provider "([^"]+)"')
+
+#: Shown when a sentence parked for lack of a client is sent after a key exists.
+PENDING_TURN_RESUME = '继续发送未完成的消息。'
+
+
+def missing_api_key_setup_text(exc: BaseException, service: str = '') -> str:
+ """One command for the provider that is missing a key.
+
+ ``service`` is the live ``llm.service``. The exception text is only a
+ fallback when that name is not on the config yet.
+ """
+ name = (service or '').strip()
+ if not name:
+ match = _PROVIDER_IN_MISSING_KEY.search(str(exc))
+ name = match.group(1) if match else ''
+ if not name:
+ return '还没有密钥。\n\n /model provider key <密钥>'
+ return f'还没有 {name} 的密钥。\n\n /model provider key {name} <密钥>'
+
+
class CredentialResolver:
@staticmethod
diff --git a/ms_agent/llm/model_discovery.py b/ms_agent/llm/model_discovery.py
new file mode 100644
index 000000000..3dd480a53
--- /dev/null
+++ b/ms_agent/llm/model_discovery.py
@@ -0,0 +1,263 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Discover chat model ids from a provider's standard /models endpoint.
+
+Best-effort: missing key, network error, non-2xx, or a non-standard payload
+all degrade to ``[]``. Image/video/embedding/audio ids are filtered out for
+TUI listing — the endpoint itself usually returns every product on the key.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Iterable, List, Optional, Sequence, Tuple
+
+import httpx
+
+# Name heuristics only: OpenAI-compatible /models rarely carries a modality
+# field. Vision-understanding ids (qwen-vl, gpt-4o, glm-4v) are kept.
+_NON_CHAT_RE = re.compile(
+ r'(embedding|rerank|moderation|whisper|transcribe|'
+ r'(?:^|[-_./])(?:tts|asr|video|speech)(?:$|[-_./])|'
+ r'wanx|wan[-_.]?2|(?:^|[-_./])wan\d|'
+ r't2i|t2v|i2v|i2i|ti2v|'
+ r'dall-?e|dalle|gpt-image|flux|stable-?diff|imagen|'
+ r'image-gen|qwen-image|'
+ r'kling|sora|veo[-_.]|cogview|cogvideox|'
+ r'sambert|paraformer|cosyvoice|qwen-audio|qwen2-audio|'
+ r'realtime)',
+ re.IGNORECASE,
+)
+
+
+def parse_model_ids(payload: object) -> List[str]:
+ """Extract ids from a standard OpenAI/Anthropic ``{"data":[{"id":...}]}``."""
+ ids: List[str] = []
+ if isinstance(payload, dict):
+ data = payload.get('data')
+ if isinstance(data, list):
+ for item in data:
+ if isinstance(item, dict):
+ mid = item.get('id')
+ if isinstance(mid, str) and mid:
+ ids.append(mid)
+ return sorted(set(ids))
+
+
+def is_non_chat_model(model_id: str) -> bool:
+ return bool(model_id) and bool(_NON_CHAT_RE.search(model_id))
+
+
+def filter_chat_model_ids(ids: Sequence[str]) -> Tuple[List[str], int]:
+ """Return (chat-oriented ids, number dropped)."""
+ kept: List[str] = []
+ dropped = 0
+ for mid in ids:
+ if is_non_chat_model(mid):
+ dropped += 1
+ else:
+ kept.append(mid)
+ return kept, dropped
+
+
+def wire_protocol(protocol_or_transport: str) -> str:
+ p = (protocol_or_transport or '').lower()
+ if p in ('anthropic', 'anthropic_messages'):
+ return 'anthropic'
+ return 'openai'
+
+
+#: Shown under a model picker. Non-chat ids may remain; the line says so.
+LIST_FOOTER = (
+ '此列表供选择对话模型。其中可能包含生图、视频、语音或向量模型,'
+ '这些模型不支持当前对话。')
+
+#: Shown when a turn fails because the selected model cannot chat.
+CHAT_UNSUPPORTED = '该模型不支持对话。请重新选择对话模型。'
+
+_CHAT_UNSUPPORTED_MARKERS = (
+ 'does not support chat',
+ 'not a chat model',
+ 'unsupported for chat',
+ 'only supports embeddings',
+ 'image generation model',
+)
+
+#: Shown when this address rejects the model id. A typo and a non-chat id
+#: look the same here, so this is not the "cannot chat" sentence.
+MODEL_ID_REJECTED = '这个模型名在当前地址上不可用。请换一个对话模型。'
+
+_MODEL_ID_REJECTED_MARKERS = (
+ 'invalid model id',
+ 'model_not_supported',
+ 'has no provider supported',
+)
+
+# "Model does not exist" and "The model `x` does not exist", not a bare
+# "does not exist".
+_MODEL_GONE_RE = re.compile(
+ r'model\b.{0,60}does not exist',
+ re.IGNORECASE | re.DOTALL,
+)
+
+
+@dataclass
+class CatalogProbe:
+ """Result of GET {base}/models. ``status`` is None when nothing answered.
+
+ ``outcome`` is ``ok`` (ids parsed), ``auth`` (401/403), ``unreachable``
+ (connection or timeout), or ``no_catalog`` (404, empty, or unknown body).
+ """
+
+ outcome: str
+ status: Optional[int]
+ ids: List[str]
+
+
+def probe_model_catalog(base_url: str, protocol: str,
+ api_key: str) -> CatalogProbe:
+ """GET the provider model list and keep the HTTP outcome.
+
+ A missing catalog is not "the model id is absent". Callers that only
+ need ids should use :func:`fetch_model_ids`.
+ """
+ if not base_url:
+ return CatalogProbe('no_catalog', None, [])
+ base = base_url.rstrip('/')
+ if wire_protocol(protocol) == 'anthropic':
+ if not base.endswith('/v1'):
+ base = f'{base}/v1'
+ url = f'{base}/models'
+ headers = {'anthropic-version': '2023-06-01'}
+ if api_key:
+ headers['x-api-key'] = api_key
+ else:
+ url = f'{base}/models'
+ headers = {}
+ if api_key:
+ headers['Authorization'] = f'Bearer {api_key}'
+ try:
+ with httpx.Client(timeout=8) as client:
+ resp = client.get(url, headers=headers)
+ except (httpx.TimeoutException, httpx.TransportError):
+ return CatalogProbe('unreachable', None, [])
+ except Exception:
+ return CatalogProbe('unreachable', None, [])
+ code = resp.status_code
+ if code in (401, 403):
+ return CatalogProbe('auth', code, [])
+ if code // 100 != 2:
+ return CatalogProbe('no_catalog', code, [])
+ try:
+ ids = parse_model_ids(resp.json())
+ except Exception:
+ return CatalogProbe('no_catalog', code, [])
+ if not ids:
+ return CatalogProbe('no_catalog', code, [])
+ return CatalogProbe('ok', code, ids)
+
+
+def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> List[str]:
+ """Return available model ids, or [] when the catalog cannot be read."""
+ probe = probe_model_catalog(base_url, protocol, api_key)
+ if probe.outcome != 'ok':
+ return []
+ return probe.ids
+
+
+def nearby_model_ids(wanted: str, ids: Sequence[str], n: int = 5) -> List[str]:
+ """A few catalog names close to ``wanted``, else the first ``n`` ids."""
+ import difflib
+ pool = list(ids)
+ hits = difflib.get_close_matches(wanted, pool, n=n, cutoff=0.4)
+ if hits:
+ return hits
+ return pool[:n]
+
+
+def chat_failed_because_unsupported(model_id: str, error: str = '') -> bool:
+ """Whether a failed turn should use :data:`CHAT_UNSUPPORTED`."""
+ if is_non_chat_model(model_id or ''):
+ return True
+ low = (error or '').lower()
+ return any(marker in low for marker in _CHAT_UNSUPPORTED_MARKERS)
+
+
+def model_id_rejected(error: str = '') -> bool:
+ """Whether this address refused the model id.
+
+ ``401`` / ``403`` stay out: those are credentials, not the model name.
+ """
+ low = (error or '').lower()
+ if any(marker in low for marker in _MODEL_ID_REJECTED_MARKERS):
+ return True
+ return bool(_MODEL_GONE_RE.search(error or ''))
+
+
+def format_id_list(ids: Iterable[str], *, limit: int = 40) -> str:
+ shown = list(ids)
+ extra = 0
+ if limit and len(shown) > limit:
+ extra = len(shown) - limit
+ shown = shown[:limit]
+ text = ', '.join(shown) if shown else '(none)'
+ if extra:
+ text += f' … +{extra} more'
+ return text
+
+
+def group_model_ids(
+ ids: Sequence[str]
+) -> Tuple[List[Tuple[str, List[str]]], List[str]]:
+ """Bucket ids the way the WebUI model picker does.
+
+ ``owner/rest`` ids become a named group. Prefixless ids that share a
+ leading letter run (qwen-plus, qwen3.8-flash) share a family group when
+ there are at least two. Leftover singletons stay ungrouped.
+ """
+ ungrouped: List[str] = []
+ slash_groups: dict[str, List[str]] = {}
+ for mid in ids:
+ cut = mid.find('/')
+ owner = mid[:cut] if cut > 0 else ''
+ rest = mid[cut + 1:] if cut > 0 else ''
+ if owner and rest:
+ slash_groups.setdefault(owner, []).append(mid)
+ else:
+ ungrouped.append(mid)
+
+ families: dict[str, List[str]] = {}
+ singles: List[str] = []
+ by_family: dict[str, List[str]] = {}
+ for mid in ungrouped:
+ match = re.match(r'[A-Za-z]+', mid)
+ key = match.group(0) if match else mid
+ by_family.setdefault(key, []).append(mid)
+ for key, members in by_family.items():
+ if len(members) >= 2:
+ families[key] = members
+ else:
+ singles.extend(members)
+
+ grouped: List[Tuple[str, List[str]]] = []
+ for key in sorted(families):
+ grouped.append((key, families[key]))
+ for owner in slash_groups:
+ grouped.append((f'{owner}/', slash_groups[owner]))
+ return grouped, singles
+
+
+def format_live_model_lines(ids: Sequence[str],
+ *,
+ indent: str = ' ') -> List[str]:
+ """One model id per line; family / owner headers for scannable TUI output."""
+ if not ids:
+ return [f'{indent}(none)']
+ grouped, singles = group_model_ids(ids)
+ lines: List[str] = []
+ for title, members in grouped:
+ lines.append(f'{indent}{title} ({len(members)})')
+ for mid in members:
+ lines.append(f'{indent} {mid}')
+ for mid in singles:
+ lines.append(f'{indent}{mid}')
+ return lines
diff --git a/ms_agent/llm/runtime_token.py b/ms_agent/llm/runtime_token.py
new file mode 100644
index 000000000..121fbd7bb
--- /dev/null
+++ b/ms_agent/llm/runtime_token.py
@@ -0,0 +1,24 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Fingerprint of the LLM settings a live client was built from."""
+from __future__ import annotations
+
+import json
+
+from omegaconf import OmegaConf
+
+
+def llm_runtime_token(config) -> str:
+ """Stable token for ``llm`` plus ``generation_config``.
+
+ A change means the next turn should rebuild the client in place. The
+ token stays in memory; it is not written to disk.
+ """
+ llm = OmegaConf.select(config, 'llm', default=None)
+ gen = OmegaConf.select(config, 'generation_config', default=None)
+ payload = {
+ 'llm':
+ OmegaConf.to_container(llm, resolve=True) if llm is not None else None,
+ 'generation':
+ OmegaConf.to_container(gen, resolve=True) if gen is not None else None,
+ }
+ return json.dumps(payload, sort_keys=True, default=str)
diff --git a/ms_agent/mcp/runtime.py b/ms_agent/mcp/runtime.py
index b1aabfc3b..21cbe3ccf 100644
--- a/ms_agent/mcp/runtime.py
+++ b/ms_agent/mcp/runtime.py
@@ -11,8 +11,12 @@
from ms_agent.config.mcp_schema import (ResolvedMCPConfig,
connection_params_for_client)
-from ms_agent.tools.mcp_client import MCPClient
-from ms_agent.utils import enhance_error, get_logger
+from ms_agent.tools.mcp_client import (
+ DEFAULT_SERVER_STARTUP_TIMEOUT,
+ MCP_PROBE_TIMEOUT,
+ MCPClient,
+)
+from ms_agent.utils import get_logger
if TYPE_CHECKING:
from ms_agent.tools.tool_manager import ToolManager
@@ -25,6 +29,10 @@
FAILURE_HISTORY_LIMIT = 20
# Transient failures (timeout / 5xx) must reach this count before degraded.
DEGRADED_FAILURE_THRESHOLD = 3
+# How long a turn waits for a server that is already starting. A warm
+# stdio process answers in about two seconds and joins this turn. Anything
+# slower keeps going in the background; the next message picks it up.
+MCP_TURN_WAIT = 3
MCPFailureKind = Literal['none', 'transient', 'hard']
@@ -54,12 +62,25 @@ class MCPServerState:
tool_count: int = 0
cached_tools: list[dict] = field(default_factory=list)
connected_at: str | None = None
+ # The stdio child was observed gone. Stays set until a connect succeeds
+ # so /mcp can say the process exited while a reconnect is in flight.
+ process_exited: bool = False
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+def _exception_group_members(exc: BaseException):
+ """Nested exceptions of an ExceptionGroup, on 3.11 and the 3.10 backport."""
+ if type(exc).__name__ not in ('ExceptionGroup', 'BaseExceptionGroup'):
+ return None
+ members = getattr(exc, 'exceptions', None)
+ if isinstance(members, tuple):
+ return members
+ return None
+
+
def classify_mcp_failure(exc: BaseException) -> MCPFailureKind:
"""Classify transport failures for degraded policy.
@@ -68,12 +89,23 @@ def classify_mcp_failure(exc: BaseException) -> MCPFailureKind:
after ``DEGRADED_FAILURE_THRESHOLD`` consecutive failures.
- ``none``: business / argument errors — do not change ``status``.
"""
+ members = _exception_group_members(exc)
+ if members is not None:
+ kinds = [classify_mcp_failure(item) for item in members]
+ if 'hard' in kinds:
+ return 'hard'
+ if 'transient' in kinds:
+ return 'transient'
+ return 'none'
if isinstance(exc, asyncio.TimeoutError):
return 'transient'
if isinstance(exc, (BrokenPipeError, ConnectionResetError)):
return 'hard'
if isinstance(exc, ConnectionError):
return 'hard'
+ type_name = type(exc).__name__.lower()
+ if type_name in ('closedresourceerror', 'brokenresourceerror'):
+ return 'hard'
msg = str(exc).lower()
hard_markers = (
'connection closed',
@@ -105,6 +137,38 @@ def is_connection_error(exc: BaseException) -> bool:
return classify_mcp_failure(exc) in ('transient', 'hard')
+def short_connect_reason(exc: BaseException, timeout: float) -> str:
+ """One short clause for a connect that did not succeed."""
+ if isinstance(exc, TimeoutError):
+ label = str(int(timeout)) if timeout >= 1 else f'{timeout:g}'
+ return f'{label}秒内没有连上'
+ text = ' '.join(str(exc).split())
+ if not text:
+ text = type(exc).__name__
+ if len(text) > 80:
+ text = text[:79] + '…'
+ return text
+
+
+def mcp_unreachable_notice(name: str, reason: str) -> str:
+ """The one line shown when a server is left out of this turn."""
+ return f'MCP 服务器 {name} 连不上({reason}),本轮先不用。'
+
+
+def degraded_user_notice(
+ server_name: str,
+ exc: BaseException | None = None,
+ message: str = '',
+) -> str:
+ """One sentence when a server has just become unusable.
+
+ A second failure uses a different sentence, sent from the live-config
+ sync, not from here.
+ """
+ del exc, message
+ return f'MCP 服务器 {server_name} 连接断开,本轮对话无法使用。自动尝试重连中。'
+
+
class MCPRuntime:
"""Configuration-driven MCP lifecycle and ToolManager sync."""
@@ -122,6 +186,7 @@ def __init__(
self._tool_manager: ToolManager | None = None
self._sync_lock = asyncio.Lock()
self._started = False
+ self._probe_tasks: Dict[str, asyncio.Task] = {}
if mcp_client is not None:
self._client = mcp_client
@@ -156,11 +221,19 @@ def _register_from_config(self, config: ResolvedMCPConfig) -> None:
status=status,
)
- async def start(self) -> None:
- """Connect all enabled servers (idempotent)."""
+ async def start(self, *, wait: bool = True) -> None:
+ """Connect enabled servers.
+
+ ``wait`` holds this call for ``MCP_TURN_WAIT`` so a server that is
+ already up can join. The TUI passes ``False``: the prompt must not
+ sit behind a cold ``npx``. The first message waits instead.
+
+ A remote handshake still gives up after ``MCP_PROBE_TIMEOUT``. A
+ stdio process keeps the real startup budget in the background.
+ """
async with self._sync_lock:
self._started = True
- failures: list[BaseException] = []
+ names: list[str] = []
for name, state in self._states.items():
if not state.enabled:
state.status = 'disabled'
@@ -169,17 +242,12 @@ async def start(self) -> None:
if state.status != 'connected':
state.status = 'connected'
continue
- try:
- await self._connect_server(name, state)
- except Exception as exc:
- if self._connect_policy == 'fail_fast':
- raise
- failures.append(exc)
- if failures and self._connect_policy == 'fail_fast':
- raise failures[0]
+ names.append(name)
+ await self._probe_servers(names, wait=wait)
async def stop(self) -> None:
"""Disconnect all servers when this runtime owns the client."""
+ await self._cancel_probes()
async with self._sync_lock:
self._started = False
if self._owns_client:
@@ -193,22 +261,216 @@ async def stop(self) -> None:
state.tool_count = 0
state.connected_at = None
- async def _connect_server(self, name: str, state: MCPServerState) -> None:
+ def _connect_budget(self, state: MCPServerState) -> float:
+ """Remote handshake stays short. stdio may spend the real startup."""
+ config = state.config or {}
+ if config.get('command') and not config.get('url'):
+ return float(DEFAULT_SERVER_STARTUP_TIMEOUT)
+ return float(MCP_PROBE_TIMEOUT)
+
+ def _probe_running(self, name: str) -> bool:
+ task = self._probe_tasks.get(name)
+ return task is not None and not task.done()
+
+ def _stdio_dead(self, name: str, state: MCPServerState) -> bool:
+ """A connected stdio server whose child is already gone."""
+ if state.status != 'connected':
+ return False
+ config = state.config or {}
+ if not config.get('command') or config.get('url'):
+ return False
+ exited = getattr(self._client, 'stdio_process_exited', None)
+ if exited is not None:
+ try:
+ if exited(name):
+ return True
+ except Exception:
+ logger.debug('stdio liveness check failed for %s',
+ name,
+ exc_info=True)
+ tasks = getattr(self._client, '_server_tasks', None)
+ if isinstance(tasks, dict):
+ task = tasks.get(name)
+ if task is not None and task.done():
+ return True
+ return not self._client.is_connected(name)
+
+ async def _mark_dead_stdio(self) -> list[str]:
+ """Drop a dead child from the tools and name it for one reconnect."""
+ names: list[str] = []
+ for name, state in self._states.items():
+ if not self._stdio_dead(name, state):
+ continue
+ state.process_exited = True
+ state.last_error = '进程已退出'
+ state.cached_tools.clear()
+ state.tool_count = 0
+ state.status = 'registered'
+ if self._client.is_connected(name):
+ await self._drop_unfinished_connect(name)
+ names.append(name)
+ return names
+
+ def _schedule_probes(self, names: list[str]) -> None:
+ for name in names:
+ state = self._states.get(name)
+ if state is None or not state.enabled:
+ continue
+ if self._probe_running(name):
+ continue
+ timeout = self._connect_budget(state)
+ task = asyncio.create_task(
+ self._finish_connect(name, state, timeout),
+ name=f'mcp-probe:{name}',
+ )
+ self._probe_tasks[name] = task
+ task.add_done_callback(self._forget_probe)
+
+ def _forget_probe(self, task: 'asyncio.Task') -> None:
+ for name, current in list(self._probe_tasks.items()):
+ if current is task:
+ self._probe_tasks.pop(name, None)
+ break
+ if task.cancelled():
+ return
+ exc = task.exception()
+ if exc is not None and self._connect_policy != 'fail_fast':
+ logger.debug('MCP probe failed: %s', exc)
+
+ async def _cancel_probes(self) -> None:
+ tasks = list(self._probe_tasks.values())
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ async def _wait_probes(self) -> None:
+ """Give in-flight connects a short chance to join this turn."""
+ pending = [
+ task for task in self._probe_tasks.values() if not task.done()
+ ]
+ if pending and MCP_TURN_WAIT > 0:
+ await asyncio.wait(pending, timeout=MCP_TURN_WAIT)
+ if self._connect_policy != 'fail_fast':
+ return
+ for task in list(self._probe_tasks.values()):
+ if task.done() and not task.cancelled():
+ exc = task.exception()
+ if exc is not None:
+ raise exc
+
+ async def _probe_servers(
+ self,
+ names: list[str],
+ *,
+ wait: bool = True,
+ ) -> None:
+ """Start connects together. The turn waits only ``MCP_TURN_WAIT``.
+
+ One slow server must not hold the others, and must not hold the turn
+ for its whole startup budget.
+ """
+ self._schedule_probes(names)
+ if wait:
+ await self._wait_probes()
+ else:
+ await asyncio.sleep(0)
+
+ async def _finish_connect(
+ self,
+ name: str,
+ state: MCPServerState,
+ timeout: float,
+ ) -> None:
+ if not state.enabled:
+ return
state.status = 'connecting'
- state.last_error = None
try:
- await self._client.connect_single_server(
- name, connection_params_for_client(state.config))
- state.status = 'connected'
- state.connected_at = _utc_now()
- state.last_success_at = state.connected_at
- state.consecutive_failures = 0
- await self._refresh_cached_tools(name, state)
+ await asyncio.wait_for(
+ self._client.connect_single_server(
+ name,
+ connection_params_for_client(state.config),
+ startup_timeout=timeout,
+ ),
+ timeout=timeout,
+ )
+ except asyncio.CancelledError:
+ raise
except Exception as exc:
- new_exc = enhance_error(exc, f'Connect `{name}` failed, details:')
- await self._record_connect_failure(name, str(new_exc))
+ reason = short_connect_reason(exc, timeout)
+ logger.warning('MCP server %s unreachable: %s', name, exc)
+ await self._record_connect_failure(name, reason)
+ await self._drop_unfinished_connect(name)
if self._connect_policy == 'fail_fast':
- raise new_exc from exc
+ raise
+ return
+ state.status = 'connected'
+ state.process_exited = False
+ state.last_error = None
+ state.connected_at = _utc_now()
+ state.last_success_at = state.connected_at
+ state.consecutive_failures = 0
+ await self._refresh_cached_tools(name, state)
+
+ async def _connect_server(self, name: str, state: MCPServerState) -> None:
+ """Finish one connect. Enable and reconnect keep the short cap.
+
+ The turn's background probe is the path that may spend the stdio
+ startup budget. This one still gives up after ``MCP_PROBE_TIMEOUT``.
+ """
+ running = self._probe_tasks.get(name)
+ if running is not None and not running.done():
+ running.cancel()
+ await asyncio.gather(running, return_exceptions=True)
+ await self._finish_connect(name, state, float(MCP_PROBE_TIMEOUT))
+
+ async def refresh_for_turn(self, *, wait: bool = True) -> None:
+ """Adopt finished connects and reconnect a stdio child that died.
+
+ A server already marked failed stays failed. ``retry_server`` or a
+ config edit is what tries it again. ``wait=False`` only reads the
+ current processes, for ``/mcp list`` and ``/mcp get``.
+ """
+ async with self._sync_lock:
+ names = await self._mark_dead_stdio()
+ await self._probe_servers(names, wait=wait)
+ await self._sync_tools_unlocked()
+
+ async def retry_server(self, name: str) -> MCPServerState:
+ """Try one failed server again. Does not block on a cold startup."""
+ async with self._sync_lock:
+ state = self._require_state(name)
+ if not state.enabled:
+ raise ValueError(f'Server {name} is disabled')
+ running = self._probe_tasks.get(name)
+ if running is not None and not running.done():
+ running.cancel()
+ await asyncio.gather(running, return_exceptions=True)
+ if self._client.is_connected(name):
+ await self._drop_unfinished_connect(name)
+ state.status = 'registered'
+ state.cached_tools.clear()
+ state.tool_count = 0
+ self._schedule_probes([name])
+ await self._wait_probes()
+ await self._sync_tools_unlocked()
+ return state
+
+ async def _drop_unfinished_connect(self, name: str) -> None:
+ """Stop a dial that the 6s cap abandoned, without the graceful wait."""
+ disconnect = getattr(self._client, 'disconnect_server', None)
+ if disconnect is None:
+ return
+ try:
+ await disconnect(name, graceful=False)
+ except TypeError:
+ try:
+ await disconnect(name)
+ except Exception:
+ logger.debug(
+ 'stop unfinished MCP connect %s', name, exc_info=True)
+ except Exception:
+ logger.debug('stop unfinished MCP connect %s', name, exc_info=True)
async def _refresh_cached_tools(
self,
@@ -221,7 +483,9 @@ async def _refresh_cached_tools(
state.tool_count = len(state.cached_tools)
state.last_success_at = _utc_now()
except Exception as exc:
- await self.record_failure(name, 'list_tools', str(exc), exc=exc)
+ # Already inside the connect lock. record_failure would take it
+ # again and deadlock when several servers list tools together.
+ self._apply_failure_state(name, 'list_tools', str(exc), exc=exc)
# ── enable / disable ───────────────────────────────────────────────
@@ -282,8 +546,12 @@ async def reconnect_server(self, name: str) -> MCPServerState:
# ── config hot update ──────────────────────────────────────────────
- async def apply_config(self,
- config: ResolvedMCPConfig) -> list[MCPServerState]:
+ async def apply_config(
+ self,
+ config: ResolvedMCPConfig,
+ *,
+ wait: bool = True,
+ ) -> list[MCPServerState]:
async with self._sync_lock:
self._config = config
old_names = set(self._states)
@@ -331,17 +599,29 @@ async def apply_config(self,
await self._client.disconnect_server(name)
state.status = 'registered'
+ await self._mark_dead_stdio()
+ to_probe: list[str] = []
for name, state in self._states.items():
if not state.enabled:
continue
+ # A degraded server may still hold a dead session. Drop it
+ # so this apply reconnects instead of keeping the stale one.
+ if (state.status == 'degraded'
+ and self._client.is_connected(name)):
+ await self._client.disconnect_server(name)
+ state.status = 'registered'
+ if self._probe_running(name):
+ continue
if not self._client.is_connected(name):
- try:
- await self._connect_server(name, state)
- except Exception:
- if self._connect_policy == 'fail_fast':
- raise
+ # Probed already for this config. The next message must
+ # not pay the timeout again; a config edit clears it.
+ if (state.status == 'error' and name not in added
+ and name not in changed):
+ continue
+ to_probe.append(name)
elif name in changed:
await self._refresh_cached_tools(name, state)
+ await self._probe_servers(to_probe, wait=wait)
await self._sync_tools_unlocked()
return list(self._states.values())
@@ -392,12 +672,14 @@ async def record_failure(
*,
tool_name: str | None = None,
exc: BaseException | None = None,
- ) -> None:
+ ) -> bool:
+ """Record one failure. Return True only when status just became degraded."""
async with self._sync_lock:
degraded = self._apply_failure_state(
name, phase, message, tool_name=tool_name, exc=exc)
if degraded:
await self._sync_tools_unlocked()
+ return degraded
def _apply_failure_state(
self,
diff --git a/ms_agent/memory/default_memory.py b/ms_agent/memory/default_memory.py
index d32b02ad8..64ef21b4a 100644
--- a/ms_agent/memory/default_memory.py
+++ b/ms_agent/memory/default_memory.py
@@ -741,7 +741,12 @@ def sanitize_database_name(ori_name: str,
mem0_config['embedder'] = embedder
if llm:
mem0_config['llm'] = llm
- logger.info(f'Memory config: {mem0_config}')
+ logger.info(
+ 'Memory config ready: vector=%s embedder=%s llm=%s',
+ vector_store.get('provider') if vector_store else None,
+ embedder.get('provider') if isinstance(embedder, dict) else None,
+ llm.get('provider') if isinstance(llm, dict) else None,
+ )
# Prompt content is too long, default logging reduces readability
custom_fact_extraction_prompt = getattr(
self.config.memory.default_memory, 'fact_retrieval_prompt',
diff --git a/ms_agent/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py
index 81f7d2fa3..22a18fc59 100644
--- a/ms_agent/memory/unified/backends/file_based.py
+++ b/ms_agent/memory/unified/backends/file_based.py
@@ -250,16 +250,27 @@ async def handle_tool_call(
safe, reason = scan_content(content)
if not safe:
return f'添加失败(安全检查): {reason}'
- ok = self._file_storage._add_entry(content)
- result = '已记住' if ok else '添加失败(可能超出字符预算)'
+ try:
+ ok = self._file_storage._add_entry(content)
+ except OSError:
+ return self._file_storage.user_notice or ''
+ notice = self._file_storage.user_notice
+ result = '已记住' if ok else (notice or '添加失败')
elif action == 'replace':
if not new_content:
result = 'replace 操作需要 new_content 参数'
else:
- ok = self._file_storage.replace_entry(content, new_content)
- result = '已更新' if ok else '更新失败(未找到旧内容或超出字符预算)'
+ try:
+ ok = self._file_storage.replace_entry(content, new_content)
+ except OSError:
+ return self._file_storage.user_notice or ''
+ notice = self._file_storage.user_notice
+ result = '已更新' if ok else (notice or '更新失败')
elif action == 'remove':
- ok = self._file_storage.remove_entry(content)
+ try:
+ ok = self._file_storage.remove_entry(content)
+ except OSError:
+ return self._file_storage.user_notice or ''
result = '已删除' if ok else '删除失败'
else:
result = f'未知操作: {action}'
diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py
index a5c47aa16..78df13053 100644
--- a/ms_agent/memory/unified/orchestrator.py
+++ b/ms_agent/memory/unified/orchestrator.py
@@ -143,6 +143,7 @@ def __init__(self, config: Any) -> None:
self._closed = False
# Background ingestion bookkeeping (see module docstring).
self._pending: Set[asyncio.Task] = set()
+ self.user_notice: Optional[str] = None
# Hashes a scheduled ingest has claimed but not yet written. The ledger
# may not advance past them from anywhere else (see mark_ingested).
self._inflight: Set[str] = set()
@@ -293,6 +294,7 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]],
delta = self._ledger_delta(msg_dicts)
if not delta:
self._set_status('ok', count=0)
+ self.user_notice = None
return 0
self._set_status('running')
result = await backend.on_messages(delta, **kwargs)
@@ -302,13 +304,23 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]],
self._ledger_mark(delta)
count = result if isinstance(result, int) else len(delta)
self._set_status('ok', count=count)
+ self.user_notice = None
return count
except asyncio.CancelledError:
self._set_status('error', error='cancelled')
raise
+ except OSError as exc:
+ from ms_agent.memory.unified.user_notice import disk_notice
+ self.user_notice = disk_notice(exc)
+ logger.error(self.user_notice)
+ self._set_status('error', error='disk')
+ return 0
except Exception as e: # noqa: BLE001 - reported via status
- logger.error(f'[orchestrator] memory ingest failed, nothing was '
- f'persisted for this turn: {type(e).__name__}: {e}')
+ # Background extract/backend failures retry next turn. The user
+ # has nothing to do with the file, so this stays off the screen.
+ logger.error(
+ '[orchestrator] memory ingest failed, nothing was persisted '
+ 'for this turn: %s: %s', type(e).__name__, e)
self._set_status('error', error=f'{type(e).__name__}: {e}')
return 0
finally:
@@ -339,9 +351,11 @@ async def flush_pending(self, timeout: float = 15.0) -> None:
return
done, still = await asyncio.wait(pending, timeout=timeout)
if still:
- logger.warning(
- f'[orchestrator] {len(still)} memory ingest(s) still running '
- f'after {timeout}s flush timeout')
+ from ms_agent.memory.unified.user_notice import QUIT_WHILE_WRITING
+ self.user_notice = QUIT_WHILE_WRITING
+ logger.debug(
+ '[orchestrator] %s memory ingest(s) still running after %ss '
+ 'flush timeout', len(still), timeout)
@property
def ingest_status(self) -> Dict[str, Any]:
diff --git a/ms_agent/memory/unified/storage/file_storage.py b/ms_agent/memory/unified/storage/file_storage.py
index 3cad23dad..b80ddb892 100644
--- a/ms_agent/memory/unified/storage/file_storage.py
+++ b/ms_agent/memory/unified/storage/file_storage.py
@@ -35,6 +35,7 @@ def __init__(self, config: MemoryConfig):
self.memory_path = self.base_dir / config.memory_path
self.char_limit = config.char_limit
self.security_scan = config.security_scan
+ self.user_notice: Optional[str] = None
self._content_cache: Optional[str] = None
# (mtime_ns, size) of the file the cache was read from. External
# writers exist (the WebUI memory editor, hand edits) and MEMORY.md
@@ -95,19 +96,23 @@ async def clear(self) -> bool:
# ------------------------------------------------------------------
def _add_entry(self, content: str) -> bool:
+ self.user_notice = None
current = self._read()
lines = [line for line in current.splitlines() if line.strip()]
deduped = list(dict.fromkeys(lines + [content.strip()]))
new_content = '\n'.join(deduped) + '\n'
if len(new_content) > self.char_limit:
+ from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE
+ self.user_notice = CHAR_LIMIT_NOTICE
logger.warning(
- f'[file_storage] MEMORY.md would exceed char limit '
- f'({len(new_content)} > {self.char_limit}), skipping add')
+ '[file_storage] MEMORY.md would exceed char limit '
+ '(%s > %s), skipping add', len(new_content), self.char_limit)
return False
self._write(new_content)
return True
def replace_entry(self, old_content: str, new_content: str) -> bool:
+ self.user_notice = None
if self.security_scan:
safe, reason = scan_content(new_content)
if not safe:
@@ -119,12 +124,15 @@ def replace_entry(self, old_content: str, new_content: str) -> bool:
return False
updated = current.replace(old_content.strip(), new_content.strip(), 1)
if len(updated) > self.char_limit:
+ from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE
+ self.user_notice = CHAR_LIMIT_NOTICE
logger.warning('[file_storage] Replace would exceed char limit')
return False
self._write(updated)
return True
def remove_entry(self, content: str) -> bool:
+ self.user_notice = None
current = self._read()
target = content.strip()
lines = current.splitlines()
@@ -144,8 +152,10 @@ def full_replace(self, content: str) -> bool:
f'[file_storage] Full replace blocked: {reason}')
return False
if len(content) > self.char_limit:
+ from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE
content = content[:self.char_limit]
- logger.warning('[file_storage] Truncated to char limit')
+ self.user_notice = CHAR_LIMIT_NOTICE
+ logger.error(CHAR_LIMIT_NOTICE)
self._write(content)
return True
@@ -187,7 +197,14 @@ def _read(self) -> str:
return content
def _write(self, content: str) -> None:
- atomic_write_text(self.memory_path, content)
+ try:
+ atomic_write_text(self.memory_path, content)
+ except OSError as exc:
+ from ms_agent.memory.unified.user_notice import disk_notice
+ self.user_notice = disk_notice(exc)
+ raise
+ else:
+ self.user_notice = None
self._content_cache = content
try:
st = self.memory_path.stat()
diff --git a/ms_agent/memory/unified/user_notice.py b/ms_agent/memory/unified/user_notice.py
new file mode 100644
index 000000000..1d5b21876
--- /dev/null
+++ b/ms_agent/memory/unified/user_notice.py
@@ -0,0 +1,26 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""One-line notices for memory writes the user can act on.
+
+TUI's default log level is ERROR, so a warning in the log is invisible.
+These sentences are what the session shows instead.
+"""
+import errno
+
+CHAR_LIMIT_NOTICE = '记忆写不进去:已超过字数上限。请删短 MEMORY.md 后再试。'
+DISK_NOTICE = '记忆写不进去:磁盘或权限不允许写入。请检查记忆目录的权限和剩余空间。'
+MISSING_DIR_NOTICE = '记忆目录不在了。请确认目录还在,再试一次。'
+PERMISSION_NOTICE = '记忆目录没有写入权限。'
+NO_SPACE_NOTICE = '磁盘空间不足,记忆写不进去。'
+QUIT_WHILE_WRITING = '记忆还在写入,这次退出可能没保存完。请重新打开会话确认 MEMORY.md。'
+
+
+def disk_notice(exc: BaseException) -> str:
+ """Pick the sentence that matches why the write was rejected."""
+ err = getattr(exc, 'errno', None)
+ if isinstance(exc, FileNotFoundError) or err == errno.ENOENT:
+ return MISSING_DIR_NOTICE
+ if isinstance(exc, PermissionError) or err in (errno.EACCES, errno.EPERM):
+ return PERMISSION_NOTICE
+ if err == errno.ENOSPC:
+ return NO_SPACE_NOTICE
+ return DISK_NOTICE
diff --git a/ms_agent/permission/memory.py b/ms_agent/permission/memory.py
index 8024bd346..e589cb947 100644
--- a/ms_agent/permission/memory.py
+++ b/ms_agent/permission/memory.py
@@ -1,8 +1,8 @@
"""PermissionMemory: persist user ``allow_always`` decisions across sessions.
Two storage scopes:
- - Project: ``.ms_agent/permission_memory.json``
- - Global: ``~/.ms_agent/permission_memory.json``
+ - Project: ``/.ms_agent/permission_memory.json``
+ - Global: ``/permission_memory.json`` (default ``~/.ms_agent``)
Session-level memory (``allow_session``) lives only in-process.
"""
@@ -45,8 +45,8 @@ def __init__(
if global_path is not None:
self._global_file = Path(global_path)
else:
- self._global_file = Path.home(
- ) / '.ms_agent' / 'permission_memory.json'
+ from ms_agent.project.paths import global_home
+ self._global_file = global_home() / 'permission_memory.json'
self._project_entries: list[MemoryEntry] = []
self._global_entries: list[MemoryEntry] = []
diff --git a/ms_agent/personalization/__init__.py b/ms_agent/personalization/__init__.py
index f13fdaa4e..88148c7d5 100644
--- a/ms_agent/personalization/__init__.py
+++ b/ms_agent/personalization/__init__.py
@@ -1,4 +1,5 @@
from ms_agent.personalization.injector import PersonalizationInjector
+from ms_agent.personalization.memory_apply import apply_project_memory
from ms_agent.personalization.profile import ProfileManager
from ms_agent.personalization.settings import PersonalizationSettings
from ms_agent.personalization.types import PersonalizationConfig
@@ -8,4 +9,5 @@
'PersonalizationInjector',
'PersonalizationSettings',
'ProfileManager',
+ 'apply_project_memory',
]
diff --git a/ms_agent/personalization/memory_apply.py b/ms_agent/personalization/memory_apply.py
new file mode 100644
index 000000000..d89afd949
--- /dev/null
+++ b/ms_agent/personalization/memory_apply.py
@@ -0,0 +1,51 @@
+"""Wire a project's memory toggle to ``memory.unified_memory``.
+
+Same shape GitLab WebUI ``_apply_webui_memory`` writes so TUI and WebUI share
+MEMORY.md under ``/.ms_agent/memory/``. Vector/mem0 stays WebUI-owned:
+TUI persists the backend flag but does not silently fall back to file.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from omegaconf import OmegaConf, open_dict
+
+
+def _drop_memory(config) -> None:
+ if OmegaConf.select(config, 'memory', default=None) is None:
+ return
+ with open_dict(config):
+ if 'memory' in config:
+ del config.memory
+
+
+def apply_project_memory(config, project: Any) -> str:
+ """Enable or strip unified_memory from *config* from *project* flags.
+
+ Returns ``off``, ``file``, or ``vector-unavailable``.
+ """
+ if not getattr(project, 'memory_enabled', False):
+ _drop_memory(config)
+ return 'off'
+
+ backend = getattr(project, 'memory_backend', None) or 'file'
+ if backend == 'vector':
+ # Match WebUI: never write a file MEMORY.md the vector UI would hide.
+ _drop_memory(config)
+ return 'vector-unavailable'
+ if backend not in ('file', None, ''):
+ backend = 'file'
+
+ pid = getattr(project, 'id', None) or 'default'
+ node = {
+ 'storage': {'backend': 'file'},
+ 'namespace': {'user_id': pid},
+ 'user_id': pid,
+ 'add_after_step': {'user_id': pid},
+ }
+ OmegaConf.update(config, 'memory.unified_memory', node, merge=True)
+ mem_node = OmegaConf.select(config, 'memory', default=None)
+ if mem_node is not None:
+ for key in [k for k in mem_node if k != 'unified_memory']:
+ del mem_node[key]
+ return 'file'
diff --git a/ms_agent/project/manager.py b/ms_agent/project/manager.py
index 03c91ef7b..1b0267573 100644
--- a/ms_agent/project/manager.py
+++ b/ms_agent/project/manager.py
@@ -156,8 +156,10 @@ def open_folder(
) -> Project:
"""Open an existing directory as a project (Codex "use an existing folder").
- Unlike :meth:`create`, the project's identity **is the folder**:
- ``id = project_key(path)``. Consequences:
+ Unlike :meth:`create`, a brand-new mount uses ``id = project_key(path)``.
+ If this folder is already a registered project — including one created
+ with a random id — that record is returned so TUI and WebUI share one
+ session tree.
- **Dedup by path** — reopening the same folder returns the same project
(no duplicate), so history is continuous across reopens.
@@ -169,10 +171,10 @@ def open_folder(
from ms_agent.project.paths import project_key
work_dir = str(Path(os.path.expanduser(path)).resolve())
- project_id = project_key(work_dir)
- existing = self.get(project_id)
+ existing = self.find_by_path(work_dir)
if existing is not None:
return existing
+ project_id = project_key(work_dir)
project = Project(
id=project_id,
name=name or Path(work_dir).name or project_id,
@@ -192,6 +194,25 @@ def get(self, project_id: str) -> Project | None:
data = store.read()
return Project(**{k: v for k, v in data.items() if k in Project.__dataclass_fields__}) if data else None
+ def find_by_path(self, path: str) -> Project | None:
+ """Return the registered project whose ``path`` is this directory.
+
+ Used so a WebUI ``create()`` project (random id, path = that folder)
+ and a later TUI/WebUI ``open_folder`` of the same directory stay one
+ project. Comparison is on resolved absolute paths.
+ """
+ try:
+ work_dir = str(Path(os.path.expanduser(path)).resolve())
+ except OSError:
+ return None
+ for project in self.list():
+ try:
+ if str(Path(project.path).expanduser().resolve()) == work_dir:
+ return project
+ except OSError:
+ continue
+ return None
+
def list(self) -> list[Project]:
projects: list[Project] = []
try:
diff --git a/ms_agent/project/session.py b/ms_agent/project/session.py
index 36661a3da..1b22bf6ba 100644
--- a/ms_agent/project/session.py
+++ b/ms_agent/project/session.py
@@ -204,3 +204,36 @@ def _save_meta(self, session: Session) -> None:
data.update(asdict(session))
data['status'] = session.status.value
store.write(data)
+
+
+def persist_logged_session_model(config, model: str,
+ provider: str | None) -> bool:
+ """Write this conversation's model onto its ``session.json``.
+
+ The global default (``settings.json`` ``default_model``) is a separate
+ write. New chats copy that default; reopening this chat reads the fields
+ stored here and does not change the default. Returns False when ``config``
+ is not pointed at a SessionManager directory.
+ """
+ from omegaconf import OmegaConf
+
+ raw = OmegaConf.select(config, 'session_log.dir', default='') or ''
+ session_dir = Path(str(raw)).expanduser()
+ if (not raw or session_dir.parent.name != 'sessions'
+ or session_dir.parent.parent.parent.name != 'projects'):
+ return False
+ project_id = session_dir.parent.parent.name
+ home = session_dir.parent.parent.parent.parent
+ from ms_agent.project.manager import ProjectManager
+ project = ProjectManager(str(home), auto_initialize=False).get(project_id)
+ if project is None:
+ return False
+ manager = SessionManager(project, base_dir=home, auto_initialize=False)
+ if manager.get(session_dir.name) is None:
+ return False
+ manager.update(
+ session_dir.name,
+ model=model or None,
+ model_provider=provider or None,
+ )
+ return True
diff --git a/ms_agent/prompting/workspace_files.py b/ms_agent/prompting/workspace_files.py
index d07e56b95..210508a0a 100644
--- a/ms_agent/prompting/workspace_files.py
+++ b/ms_agent/prompting/workspace_files.py
@@ -419,6 +419,146 @@ def write_home_file(name: str, text: str) -> None:
split_regions = split_profile_regions
+# ── UI write API (TUI slash commands / WebUI settings pages) ─────────────────
+#
+# Global instructions: edit only the user region under the seeded AGENTS.md
+# header. Project instructions: the private slot /.ms_agent/AGENTS.md —
+# never the repo-root AGENTS.md (AI-native repos commit that file themselves).
+# PROFILE.md: managed ``- Call me:`` line + free region.
+
+_AGENTS = 'AGENTS.md'
+_PROFILE = 'PROFILE.md'
+
+
+def read_global_instruction() -> str:
+ """User region of ``~/.ms_agent/AGENTS.md`` (legacy settings migrated)."""
+ return _migrate_legacy_global_instruction().strip()
+
+
+def write_global_instruction(content: str) -> None:
+ """Replace the user region; keep the seeded header/comments."""
+ _migrate_legacy_global_instruction()
+ text = read_home_file(_AGENTS)
+ body = content.strip()
+ write_home_file(_AGENTS, set_free_region(text, body + '\n' if body else ''))
+
+
+def project_instruction_path(work_dir: str) -> Path:
+ return local_internal_dir(work_dir) / _AGENTS
+
+
+def read_project_instruction(work_dir: str) -> str:
+ """Private slot ``/.ms_agent/AGENTS.md`` (legacy project.instruction
+ migrated). Does not read or write the repo-root ``AGENTS.md``."""
+ path = project_instruction_path(work_dir)
+ try:
+ content = path.read_text(encoding='utf-8')
+ except OSError:
+ content = ''
+ if content.strip():
+ return content
+ return _migrate_legacy_project_instruction(work_dir)
+
+
+def write_project_instruction(work_dir: str, content: str) -> None:
+ """Write the private slot only; never touch ``/AGENTS.md``."""
+ _migrate_legacy_project_instruction(work_dir)
+ body = content.strip()
+ atomic_write_text(project_instruction_path(work_dir),
+ body + '\n' if body else '')
+ # Always drop the leftover JSON field so an empty private file cannot be
+ # re-filled from a stale project.instruction on the next read.
+ _clear_project_instruction_field(work_dir)
+
+
+def read_profile() -> Tuple[str, str]:
+ """``(call_me, free_region)`` from ``~/.ms_agent/PROFILE.md``."""
+ text = read_home_file(_PROFILE)
+ return get_call_me(text), get_free_region(text).strip()
+
+
+def write_profile(*,
+ call_me: Optional[str] = None,
+ description: Optional[str] = None) -> Tuple[str, str]:
+ """Update PROFILE.md regions. ``None`` leaves that region unchanged."""
+ text = read_home_file(_PROFILE)
+ if call_me is not None:
+ text = set_call_me(text, call_me)
+ if description is not None:
+ body = description.strip()
+ text = set_free_region(text, body + '\n' if body else '')
+ write_home_file(_PROFILE, text)
+ return get_call_me(text), get_free_region(text).strip()
+
+
+def _migrate_legacy_global_instruction() -> str:
+ """Move settings.json personalization.global_instruction into AGENTS.md."""
+ text = read_home_file(_AGENTS)
+ user_region = get_free_region(text)
+ if user_region.strip():
+ return user_region
+ try:
+ from ms_agent.personalization.settings import PersonalizationSettings
+ from ms_agent.personalization.types import PersonalizationConfig
+ ps = PersonalizationSettings()
+ cur = ps.load()
+ legacy = (cur.global_instruction or '').strip()
+ if not legacy:
+ return user_region
+ text = set_free_region(text, legacy + '\n')
+ write_home_file(_AGENTS, text)
+ ps.save(
+ PersonalizationConfig(
+ global_instruction='',
+ memory_enabled=cur.memory_enabled,
+ memory_backend=cur.memory_backend,
+ ))
+ return get_free_region(text)
+ except Exception:
+ logger.debug(
+ 'legacy global_instruction migration skipped', exc_info=True)
+ return user_region
+
+
+def _migrate_legacy_project_instruction(work_dir: str) -> str:
+ """Move Project.instruction into ``.ms_agent/AGENTS.md`` once, then clear it."""
+ path = project_instruction_path(work_dir)
+ try:
+ existing = path.read_text(encoding='utf-8')
+ except OSError:
+ existing = ''
+ if existing.strip():
+ return existing
+ try:
+ from ms_agent.project.manager import ProjectManager
+ pm = ProjectManager(base_dir=str(global_home()))
+ project = pm.find_by_path(work_dir)
+ if project is None:
+ return existing
+ legacy = (project.instruction or '').strip()
+ if not legacy:
+ return existing
+ atomic_write_text(path, legacy + '\n')
+ _clear_project_instruction_field(work_dir)
+ return legacy + '\n'
+ except Exception:
+ logger.debug(
+ 'legacy project.instruction migration skipped', exc_info=True)
+ return existing
+
+
+def _clear_project_instruction_field(work_dir: str) -> None:
+ try:
+ from ms_agent.project.manager import ProjectManager
+ pm = ProjectManager(base_dir=str(global_home()))
+ project = pm.find_by_path(work_dir)
+ if project is not None and (project.instruction or '').strip():
+ pm.update(project.id, instruction='')
+ except Exception:
+ logger.debug(
+ 'clearing leftover project.instruction skipped', exc_info=True)
+
+
# ── injected block builders (consumed by LLMAgent) ───────────────────────────
diff --git a/ms_agent/session/strategies/summary_compactor.py b/ms_agent/session/strategies/summary_compactor.py
index ab9a61190..dea6787b7 100644
--- a/ms_agent/session/strategies/summary_compactor.py
+++ b/ms_agent/session/strategies/summary_compactor.py
@@ -25,7 +25,8 @@
- Relevant files: Files read, edited, or created
Preserve verbatim, never rewrite or drop: the LATEST
-skill-update notice, if any (it is the authoritative current skill list),
+skill-update notice, if any (the system prompt's skill list may be stale;
+skills_list is current),
and the LATEST notice about changed workspace/prompt
files, if any (it explains why earlier conversation may reference outdated
file contents). Other blocks (e.g. retrieved memories)
diff --git a/ms_agent/skill/harness.py b/ms_agent/skill/harness.py
new file mode 100644
index 000000000..2a43559fe
--- /dev/null
+++ b/ms_agent/skill/harness.py
@@ -0,0 +1,52 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Live path substitution for bundled harness skills.
+
+No harness skill is bundled right now. Ids in ``HARNESS_SKILL_IDS`` get
+``{home}`` / ``{work}`` / … filled at ``skill_view`` and slash-expand time.
+Every other skill is left unchanged.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+HARNESS_SKILL_IDS: frozenset[str] = frozenset()
+
+
+def harness_placeholders(config: Any = None) -> Dict[str, str]:
+ """Resolve the machine's current home / work / memory / MCP paths."""
+ from ms_agent.project.paths import global_home, memory_dir
+ from ms_agent.utils.workspace_context import resolve_workspace_root
+
+ try:
+ home = str(global_home())
+ except Exception: # noqa: BLE001 - never break skill_view
+ home = str(Path.home() / '.ms_agent')
+ try:
+ work = str(resolve_workspace_root(config))
+ except Exception: # noqa: BLE001
+ work = str(Path.cwd().resolve())
+ return {
+ 'home': home,
+ 'work': work,
+ 'memory_md': str(memory_dir(work) / 'MEMORY.md'),
+ 'project_mcp': str(Path(work) / '.ms_agent' / 'mcp.json'),
+ 'global_mcp': str(Path(home) / 'mcp.json'),
+ }
+
+
+def fill_harness_placeholders(
+ text: str,
+ config: Any = None,
+ skill_id: Optional[str] = None) -> str:
+ """Replace ``{home}`` / ``{work}`` / … in a harness skill body.
+
+ Token replace (not ``str.format``) so JSON examples with other braces
+ stay intact. Unknown skill ids are left unchanged.
+ """
+ if skill_id is not None and skill_id not in HARNESS_SKILL_IDS:
+ return text
+ mapping = harness_placeholders(config)
+ for key, value in mapping.items():
+ text = text.replace('{' + key + '}', value)
+ return text
diff --git a/ms_agent/skill/loader.py b/ms_agent/skill/loader.py
index 9d3c8b244..360c02d58 100644
--- a/ms_agent/skill/loader.py
+++ b/ms_agent/skill/loader.py
@@ -181,15 +181,20 @@ def _discover_single_skill(self,
return cached[1]
content = skill_md.read_text(encoding='utf-8')
- frontmatter = self.parser.parse_yaml_frontmatter(content)
- if (not frontmatter or 'name' not in frontmatter
- or 'description' not in frontmatter):
+ frontmatter = self.parser.parse_yaml_frontmatter(content) or {}
+ # Presence of SKILL.md is registration (same as WebUI live tree).
+ # Name/description are filled from the directory when omitted so a
+ # TUI ``/skills add`` still shows up on the skills page.
+ name = str(frontmatter.get('name') or skill_dir.name).strip()
+ description = str(
+ frontmatter.get('description') or name).strip() or name
+ if not name:
self._discovery_cache[skill_dir] = (fingerprint, None)
return None
descriptor = SkillDescriptor(
skill_id=skill_dir.name,
- name=frontmatter['name'],
- description=frontmatter['description'],
+ name=name,
+ description=description,
content=content,
version=frontmatter.get('version', 'latest'),
author=frontmatter.get('author'),
diff --git a/ms_agent/skill/prompt_injector.py b/ms_agent/skill/prompt_injector.py
index c411a4aea..d809fb7e2 100644
--- a/ms_agent/skill/prompt_injector.py
+++ b/ms_agent/skill/prompt_injector.py
@@ -33,13 +33,12 @@ class SkillPromptInjector:
UPDATE_NOTICE_HINT = """
**About skill list updates:**
The skill list here reflects the state when this session started. Skills
-can be added, removed, enabled, disabled or edited mid-conversation; when
-that happens, a skill-update notice containing the FULL
-current list appears in the conversation.
-- If any skill-update notice exists, the LATEST one is authoritative and
- supersedes this list and all earlier notices.
-- Earlier notices may be outdated; that is normal bookkeeping — do not
- mention notices or list changes to the user unless asked.
+can be added, removed, enabled, disabled or edited mid-conversation. A
+ skill-update notice may appear when that happens. It
+does not contain the skill list. Call `skills_list` for the current
+inventory.
+- skills_list is current. This prompt's list may be stale.
+- Do not mention notices or list changes to the user unless asked.
"""
def __init__(self,
diff --git a/ms_agent/skill/schema.py b/ms_agent/skill/schema.py
index c513b02fc..452a6cbdb 100644
--- a/ms_agent/skill/schema.py
+++ b/ms_agent/skill/schema.py
@@ -272,13 +272,13 @@ def parse_skill_directory(directory_path: str | Path) -> Optional[SkillSchema]:
with open(skill_md_path, 'r', encoding='utf-8') as f:
content = f.read()
- # Parse metadata
- frontmatter = SkillSchemaParser.parse_yaml_frontmatter(content)
- if not frontmatter or 'name' not in frontmatter or 'description' not in frontmatter:
- return None
-
- # Generate skill_id from directory name
+ # Parse metadata. SKILL.md is enough to register; fill missing
+ # name/description so a live-tree import still loads.
+ frontmatter = SkillSchemaParser.parse_yaml_frontmatter(content) or {}
skill_id = directory_path.name
+ name = str(frontmatter.get('name') or skill_id).strip() or skill_id
+ description = str(
+ frontmatter.get('description') or name).strip() or name
# Collect all files
files = []
@@ -357,8 +357,8 @@ def _walk(current_path: Path, relative_dir: str,
schema = SkillSchema(
skill_id=skill_id,
- name=frontmatter['name'],
- description=frontmatter['description'],
+ name=name,
+ description=description,
content=content,
version=frontmatter.get('version', 'latest'),
files=files,
diff --git a/ms_agent/skill/skill_tools.py b/ms_agent/skill/skill_tools.py
index d79b3b622..0d95fd315 100644
--- a/ms_agent/skill/skill_tools.py
+++ b/ms_agent/skill/skill_tools.py
@@ -8,6 +8,7 @@
from ms_agent.tools.base import ToolBase
from ms_agent.utils.logger import get_logger
from .catalog import USER_SKILLS_DIR
+from .harness import fill_harness_placeholders
from .schema import SkillSchemaParser
logger = get_logger()
@@ -34,6 +35,8 @@ def __init__(self,
search_engine=None):
super().__init__(config)
self._catalog = catalog
+ # Agent config (has output_dir); catalog._config is skills-only.
+ catalog._agent_config = config
self._enable_manage = enable_manage
self._tool_manager = tool_manager
self._search_engine = search_engine
@@ -259,7 +262,8 @@ def _handle_skill_view(self, args: dict) -> str:
'name': skill.name,
'description': skill.description,
'skill_dir': str(skill.skill_path),
- 'content': skill.content,
+ 'content': fill_harness_placeholders(
+ skill.content, self.config, skill.skill_id),
'linked_files': {
'scripts': [s.name for s in skill.scripts],
'references': [r.name for r in skill.references],
diff --git a/ms_agent/tools/code/code_executor.py b/ms_agent/tools/code/code_executor.py
index cfe43c3b5..edab2bcbc 100644
--- a/ms_agent/tools/code/code_executor.py
+++ b/ms_agent/tools/code/code_executor.py
@@ -8,6 +8,7 @@
from ms_agent.llm.utils import Tool
from ms_agent.tools.base import ToolBase
+from ms_agent.tools.code.local_code_executor import drop_unknown_call_args
from ms_agent.tools.code.sandbox_manager import SandboxManagerFactory
from ms_agent.utils import get_logger
from ms_agent.utils.constants import DEFAULT_OUTPUT_DIR
@@ -505,7 +506,7 @@ async def call_tool(self, server_name: str, *, tool_name: str,
try:
method = getattr(self, tool_name)
- return await method(**tool_args)
+ return await method(**drop_unknown_call_args(method, tool_args or {}))
except AttributeError:
return json.dumps(
{
diff --git a/ms_agent/tools/code/local_code_executor.py b/ms_agent/tools/code/local_code_executor.py
index 6a083c401..b1049fc51 100644
--- a/ms_agent/tools/code/local_code_executor.py
+++ b/ms_agent/tools/code/local_code_executor.py
@@ -1,5 +1,6 @@
import asyncio
import asyncio.subprocess as ai_subprocess
+import inspect
import json
import os
import shutil
@@ -15,6 +16,28 @@
logger = get_logger()
+
+def drop_unknown_call_args(method, call_args: dict) -> dict:
+ """Drop arguments the target function does not accept.
+
+ Models often add ``description``. A function that takes ``**kwargs``
+ keeps every field.
+ """
+ try:
+ params = inspect.signature(method).parameters
+ except (TypeError, ValueError):
+ return call_args
+ if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
+ return call_args
+ allowed = {
+ name
+ for name, p in params.items()
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD,
+ inspect.Parameter.KEYWORD_ONLY)
+ }
+ return {key: value for key, value in call_args.items() if key in allowed}
+
+
#: Ambient variables a POSIX command may read without them carrying anything
#: the agent should not have. The environment is built as an allow-list rather
#: than inherited, so anything missing here is simply absent for every command
@@ -360,6 +383,7 @@ async def call_tool(self, server_name: str, *, tool_name: str,
call_args = dict(tool_args or {})
if '__call_id' in call_args and 'call_id' not in call_args:
call_args['call_id'] = call_args.pop('__call_id')
+ call_args = drop_unknown_call_args(method, call_args)
return await method(**call_args)
except AttributeError:
return json.dumps(
diff --git a/ms_agent/tools/filesystem_tool.py b/ms_agent/tools/filesystem_tool.py
index d5031cc0c..beb00a01f 100644
--- a/ms_agent/tools/filesystem_tool.py
+++ b/ms_agent/tools/filesystem_tool.py
@@ -119,8 +119,9 @@ def __init__(self, config, **kwargs):
False)
if not self.trust_remote_code:
self.allow_read_all_files = False
- if hasattr(self.config, 'llm'):
- self.llm: LLM = LLM.from_config(self.config)
+ # Built at first abbreviated-read, not here: a missing API key must
+ # not block ToolManager (TUI setup still needs slash commands).
+ self.llm = None
index_dir = getattr(config, 'index_cache_dir', DEFAULT_INDEX_DIR)
self.index_dir = os.path.join(self.output_dir, index_dir)
self.system = self.SYSTEM_FOR_ABBREVIATIONS
@@ -1004,6 +1005,11 @@ async def read_file(self,
return {'result': text, 'attachments': image_refs}
return text
+ def _ensure_llm(self) -> LLM:
+ if self.llm is None:
+ self.llm = LLM.from_config(self.config)
+ return self.llm
+
async def _read_files_abbreviated(self, paths: list[str]) -> str:
results = {}
@@ -1024,6 +1030,7 @@ def process_file(path):
with open(target_path_real, 'r', encoding='utf-8') as f:
content = f.read()
+ llm = self._ensure_llm()
messages = [
Message(role='system', content=self.system),
Message(
@@ -1031,7 +1038,7 @@ def process_file(path):
content='The content to be abbreviated:\n\n'
+ content),
]
- response = self.llm.generate(messages=messages, stream=False)
+ response = llm.generate(messages=messages, stream=False)
os.makedirs(os.path.dirname(index_file), exist_ok=True)
with open(index_file, 'w', encoding='utf-8') as f:
f.write(response.content)
diff --git a/ms_agent/tools/mcp_client.py b/ms_agent/tools/mcp_client.py
index 9fd7da7d0..566af1501 100644
--- a/ms_agent/tools/mcp_client.py
+++ b/ms_agent/tools/mcp_client.py
@@ -2,10 +2,16 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import asyncio
+import base64
+import contextvars
import copy
+import inspect
+import json
+import mimetypes
import os
import re
import shutil
+import uuid
from contextlib import AsyncExitStack, suppress
from datetime import timedelta
from mcp import ClientSession, ListToolsResult, StdioServerParameters
@@ -23,6 +29,131 @@
logger = get_logger()
+
+def _session_read_timeout(seconds: float):
+ """Value ``ClientSession`` accepts for ``read_timeout_seconds``.
+
+ mcp 1.x takes a ``timedelta``. mcp 2.x takes seconds as a float and
+ adds that number to ``anyio.current_time()``.
+ """
+ param = inspect.signature(
+ ClientSession.__init__).parameters.get('read_timeout_seconds')
+ annotation = '' if param is None else str(param.annotation)
+ if 'timedelta' in annotation and 'float' not in annotation:
+ return timedelta(seconds=seconds)
+ return float(seconds)
+
+
+def _tool_call_is_error(response) -> bool:
+ """Whether an MCP ``call_tool`` result is an error.
+
+ mcp 1.x exposes ``isError``. mcp 2.x renamed it to ``is_error``.
+ """
+ flag = getattr(response, 'is_error', None)
+ if flag is None:
+ flag = getattr(response, 'isError', None)
+ return bool(flag)
+
+
+def _tool_output_schema(tool):
+ """Return schema, whichever spelling this SDK version uses."""
+ schema = getattr(tool, 'output_schema', None)
+ if schema is None:
+ schema = getattr(tool, 'outputSchema', None)
+ return schema if isinstance(schema, dict) else None
+
+
+def _describe_tool(tool) -> str:
+ """Description plus the declared return shape.
+
+ The chat-completions tool object only has a description, so a server that
+ published an output schema would otherwise never show the model what a
+ successful call looks like.
+ """
+ description = getattr(tool, 'description', None) or ''
+ schema = _tool_output_schema(tool)
+ if not schema:
+ return description
+ rendered = json.dumps(schema, ensure_ascii=False)
+ if len(rendered) > 800:
+ rendered = rendered[:800] + '…'
+ return f'{description}\nReturns: {rendered}'.strip()
+
+
+def _structured_content(response):
+ value = getattr(response, 'structured_content', None)
+ if value is None:
+ value = getattr(response, 'structuredContent', None)
+ return value
+
+
+def _content_mime(content) -> str:
+ mime = getattr(content, 'mime_type', None)
+ if not mime:
+ mime = getattr(content, 'mimeType', None)
+ return str(mime or 'application/octet-stream')
+
+
+def _resource_json(resource) -> str:
+ """Serialize an embedded resource on either SDK spelling."""
+ dump_json = getattr(resource, 'model_dump_json', None)
+ if callable(dump_json):
+ try:
+ return dump_json(by_alias=True)
+ except TypeError:
+ return dump_json()
+ dump = getattr(resource, 'model_dump', None)
+ if callable(dump):
+ try:
+ return json.dumps(dump(by_alias=True), ensure_ascii=False)
+ except TypeError:
+ return json.dumps(dump(), ensure_ascii=False)
+ if isinstance(resource, dict):
+ return json.dumps(resource, ensure_ascii=False)
+ return json.dumps(getattr(resource, '__dict__', str(resource)),
+ default=str,
+ ensure_ascii=False)
+
+
+def _tool_input_schema(tool) -> dict:
+ """JSON schema for one MCP tool.
+
+ mcp 1.x exposes ``inputSchema``. mcp 2.x renamed it to ``input_schema``
+ and dropped the old attribute.
+ """
+ schema = getattr(tool, 'input_schema', None)
+ if schema is None:
+ schema = getattr(tool, 'inputSchema', None)
+ if isinstance(schema, dict):
+ return schema
+ return {'type': 'object', 'properties': {}}
+
+
+def _retrieve_future(fut) -> None:
+ """Mark a finished future's exception as retrieved.
+
+ ``ready.set_exception(CancelledError())`` inside a connect that quit
+ cancelled otherwise logs ``Future exception was never retrieved`` when the
+ loop later garbage-collects it — after the user has already seen bye.
+ """
+ if fut is None or not fut.done() or fut.cancelled():
+ return
+ try:
+ fut.exception()
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
+ return
+
+
+def _is_teardown_noise(exc: BaseException) -> bool:
+ """MCP SDK / anyio errors that fire when closing streamable_http."""
+ text = f'{type(exc).__name__}: {exc}'
+ return any(s in text for s in (
+ 'cancel scope',
+ 'athrow()',
+ 'asynchronous generator',
+ ))
+
+
EncodingErrorHandler = Literal['strict', 'ignore', 'replace']
DEFAULT_ENCODING = 'utf-8'
@@ -43,6 +174,13 @@ def _int_env(name: str, default: int) -> int:
CONNECTION_TIMEOUT = _int_env('CONNECTION_TIMEOUT', 120)
+#: Cap for one remote MCP handshake. WebUI's health check uses the same 6
+#: seconds. A dead address must not sit on ``CONNECTION_TIMEOUT`` (120s).
+#: stdio does not use this as a death sentence: a local command keeps
+#: ``DEFAULT_SERVER_STARTUP_TIMEOUT`` in the background, and a turn only
+#: waits ``MCP_TURN_WAIT``.
+MCP_PROBE_TIMEOUT = 6
+
#: How many servers may be started at once. Bounded because each stdio server
#: is a child process and a large config would otherwise fork all of them
#: simultaneously.
@@ -102,6 +240,37 @@ def stdio_child_env(config_env: Optional[dict]) -> dict:
return env
+# The stdio helper keeps the child private. This records it for the task
+# that is opening the server, so a later turn can read ``returncode``.
+_stdio_child: 'contextvars.ContextVar[list[Any] | None]' = (
+ contextvars.ContextVar('ms_agent_stdio_child', default=None))
+
+
+def _install_stdio_child_hook() -> None:
+ """Remember the process ``stdio_client`` just spawned.
+
+ The MCP helper does not return that process. Missing the private
+ function is safe: death is then noticed when the session itself drops.
+ """
+ try:
+ import mcp.client.stdio as stdio_mod
+ except ImportError:
+ return
+ current = getattr(stdio_mod, '_create_platform_compatible_process', None)
+ if current is None or getattr(current, '_ms_agent_stdio_hook', False):
+ return
+
+ async def hooked(*args, **kwargs):
+ process = await current(*args, **kwargs)
+ holder = _stdio_child.get()
+ if holder is not None:
+ holder.append(process)
+ return process
+
+ hooked._ms_agent_stdio_hook = True # type: ignore[attr-defined]
+ stdio_mod._create_platform_compatible_process = hooked
+
+
def resolve_stdio_command(command: str) -> str:
"""Absolute path for *command*, searching PATH then the usual user dirs."""
if os.path.sep in command:
@@ -174,6 +343,15 @@ def __init__(
# let go.
self._server_tasks: Dict[str, 'asyncio.Task'] = {}
self._server_shutdown: Dict[str, asyncio.Event] = {}
+ # The Future ``connect_to_server`` waits on. Kept so a cancelled
+ # connect can still retrieve the CancelledError it stored there.
+ self._server_ready: Dict[str, 'asyncio.Future'] = {}
+ # Every owner task, including ones already popped from ``_server_tasks``
+ # while their teardown is still running.
+ self._live_tasks: set['asyncio.Task'] = set()
+ # stdio children, so a later turn can see that the process exited
+ # while this session object is still registered.
+ self._stdio_children: Dict[str, Any] = {}
self.exit_stack = AsyncExitStack()
self.mcp_config: Dict[str, Dict[str, Any]] = {'mcpServers': {}}
if config is not None:
@@ -203,7 +381,8 @@ async def call_tool(self, server_name: str, tool_name: str,
texts = []
resources = []
- if response.isError:
+ attachments = []
+ if _tool_call_is_error(response):
sep = '\n\n'
if all(isinstance(item, str) for item in response.content):
detail = sep.join(response.content)
@@ -222,19 +401,67 @@ async def call_tool(self, server_name: str, tool_name: str,
'is_error':
True,
}
- for content in response.content:
- if content.type == 'text':
+ for content in response.content or []:
+ kind = getattr(content, 'type', None)
+ if kind == 'text':
texts.append(content.text)
- elif content.type == 'resource':
+ elif kind == 'resource':
import json5
- json_str = content.resource.model_dump_json(by_alias=True)
+ json_str = _resource_json(content.resource)
texts.append(json_str)
resources.append(json5.loads(json_str))
-
- if resources:
- return {'text': '\n\n'.join(texts), 'resources': resources}
-
- return '\n\n'.join(texts)
+ elif kind == 'image':
+ path = self._write_media(
+ getattr(content, 'data', '') or '',
+ _content_mime(content), 'image')
+ attachments.append({
+ 'type': 'image',
+ 'path': path,
+ 'media_type': _content_mime(content),
+ 'label': f'Image: {os.path.basename(path)}',
+ })
+ texts.append(f'Image returned ({_content_mime(content)}).')
+ elif kind == 'audio':
+ path = self._write_media(
+ getattr(content, 'data', '') or '',
+ _content_mime(content), 'audio')
+ texts.append(
+ f'Audio returned ({_content_mime(content)}), saved at {path}.')
+
+ structured = _structured_content(response)
+ if structured is not None and not texts:
+ texts.append(
+ json.dumps(structured, ensure_ascii=False, default=str))
+
+ body = '\n\n'.join(texts)
+ if resources or attachments:
+ result = {'text': body}
+ if resources:
+ result['resources'] = resources
+ if attachments:
+ result['attachments'] = attachments
+ return result
+ return body
+
+ def _write_media(self, data: str, mime: str, kind: str) -> str:
+ """Persist a base64 tool result next to the workspace.
+
+ Image refs the model can see are file paths, not inline bytes. Audio
+ has no model channel, so the path in the text is the whole delivery.
+ """
+ raw = base64.b64decode(data or '')
+ ext = mimetypes.guess_extension(mime.split(';', 1)[0].strip()) or ''
+ if ext == '.jpe':
+ ext = '.jpg'
+ if not ext:
+ ext = '.png' if kind == 'image' else '.bin'
+ root = self.output_dir or os.getcwd()
+ folder = os.path.join(root, 'mcp_media')
+ os.makedirs(folder, exist_ok=True)
+ path = os.path.join(folder, f'{uuid.uuid4().hex}{ext}')
+ with open(path, 'wb') as handle:
+ handle.write(raw)
+ return os.path.relpath(path, root)
def _filter_session_tools(
self,
@@ -254,8 +481,8 @@ def _filter_session_tools(
Tool(
tool_name=t.name,
server_name=server_name,
- description=t.description,
- parameters=t.inputSchema,
+ description=_describe_tool(t),
+ parameters=_tool_input_schema(t),
) for t in session_tools
]
@@ -317,17 +544,37 @@ def list_connected_servers(self) -> list[str]:
def is_connected(self, server_name: str) -> bool:
return server_name in self.sessions
- async def disconnect_server(self, server_name: str) -> None:
+ def stdio_process_exited(self, server_name: str) -> bool:
+ """Whether the stdio child has already exited.
+
+ The session can still be registered: the owner task is blocked
+ until something asks it to stop. ``returncode`` is the fact.
+ """
+ process = self._stdio_children.get(server_name)
+ if process is None:
+ return False
+ return getattr(process, 'returncode', None) is not None
+
+ def _track_task(self, task: 'asyncio.Task') -> None:
+ self._live_tasks.add(task)
+ task.add_done_callback(self._live_tasks.discard)
+ task.add_done_callback(_retrieve_future)
+
+ async def disconnect_server(self,
+ server_name: str,
+ *,
+ graceful: bool = True) -> None:
"""Disconnect a single MCP server."""
self.exclude_functions.pop(server_name, None)
self.include_functions.pop(server_name, None)
- await self._stop_server(server_name)
+ await self._stop_server(server_name, graceful=graceful)
async def connect_single_server(
self,
server_name: str,
server_config: Dict[str, Any],
timeout: int = CONNECTION_TIMEOUT,
+ startup_timeout: Optional[float] = None,
) -> str:
"""Connect one server from a normalized config entry."""
if self.is_connected(server_name):
@@ -348,6 +595,7 @@ async def connect_single_server(
server_name=server_name,
env=env_dict,
timeout=timeout,
+ startup_timeout=startup_timeout,
**server,
)
@@ -428,7 +676,7 @@ async def _open_session(self, stack: AsyncExitStack, server_name: str,
ClientSession(
read,
write,
- read_timeout_seconds=timedelta(seconds=timeout),
+ read_timeout_seconds=_session_read_timeout(timeout),
**session_kwargs))
elif command:
@@ -451,8 +699,16 @@ async def _open_session(self, stack: AsyncExitStack, server_name: str,
'encoding_error_handler', DEFAULT_ENCODING_ERROR_HANDLER),
)
- stdio, write = await stack.enter_async_context(
- stdio_client(server_params))
+ holder: list[Any] = []
+ token = _stdio_child.set(holder)
+ try:
+ _install_stdio_child_hook()
+ stdio, write = await stack.enter_async_context(
+ stdio_client(server_params))
+ finally:
+ _stdio_child.reset(token)
+ if holder:
+ self._stdio_children[server_name] = holder[-1]
session_kwargs = session_kwargs or {}
read_timeout = max(
session_kwargs.pop('read_timeout_seconds', timeout), 1)
@@ -463,7 +719,7 @@ async def _open_session(self, stack: AsyncExitStack, server_name: str,
ClientSession(
stdio,
write,
- read_timeout_seconds=timedelta(seconds=read_timeout),
+ read_timeout_seconds=_session_read_timeout(read_timeout),
**session_kwargs))
else:
raise ValueError(
@@ -500,7 +756,11 @@ async def _own_server(
except BaseException as exc: # noqa: BLE001
if not ready.done():
ready.set_exception(exc)
- elif not isinstance(exc, asyncio.CancelledError):
+ elif isinstance(exc, asyncio.CancelledError):
+ pass
+ elif _is_teardown_noise(exc):
+ logger.debug('MCP server %s closed: %s', server_name, exc)
+ else:
logger.warning('MCP server %s dropped: %s', server_name, exc)
finally:
self.sessions.pop(server_name, None)
@@ -508,12 +768,15 @@ async def _own_server(
async def connect_to_server(self,
server_name: str,
timeout: int = CONNECTION_TIMEOUT,
- startup_timeout: Optional[int] = None,
+ startup_timeout: Optional[float] = None,
**kwargs):
if self.is_connected(server_name):
return server_name
logger.info(f'connect to {server_name}')
ready: 'asyncio.Future' = asyncio.get_running_loop().create_future()
+ # Retrieve as soon as the owner stores an exception, including the
+ # CancelledError a quit leaves behind after the waiter has moved on.
+ ready.add_done_callback(_retrieve_future)
shutdown = asyncio.Event()
task = asyncio.create_task(
self._own_server(server_name, {
@@ -521,8 +784,10 @@ async def connect_to_server(self,
**kwargs
}, ready, shutdown),
name=f'mcp-server:{server_name}')
+ self._track_task(task)
self._server_tasks[server_name] = task
self._server_shutdown[server_name] = shutdown
+ self._server_ready[server_name] = ready
try:
if startup_timeout:
# Shield so a timeout stops US waiting without cancelling the
@@ -537,32 +802,50 @@ async def connect_to_server(self,
# the startup timeout the caller already spent.
await self._stop_server(server_name, graceful=False)
raise
+ self._server_ready.pop(server_name, None)
return server_name
async def _stop_server(self,
server_name: str,
graceful: bool = True) -> None:
"""Ask a server's owner task to close its transport, and wait for it."""
+ self._stdio_children.pop(server_name, None)
shutdown = self._server_shutdown.pop(server_name, None)
task = self._server_tasks.pop(server_name, None)
+ ready = self._server_ready.pop(server_name, None)
self.sessions.pop(server_name, None)
if shutdown is not None:
shutdown.set()
if task is None or task.done():
+ _retrieve_future(task)
+ _retrieve_future(ready)
return
if graceful:
try:
await asyncio.wait_for(
asyncio.shield(task), timeout=SERVER_STOP_TIMEOUT)
+ _retrieve_future(ready)
+ return
+ except asyncio.TimeoutError:
+ logger.debug('MCP server %s stop timed out', server_name)
+ _retrieve_future(ready)
+ except asyncio.CancelledError:
+ # The TUI generator is closing. The owner already has the
+ # shutdown signal — wait it out. Cancelling it here would
+ # athrow() streamablehttp_client from the wrong task.
+ with suppress(BaseException):
+ await asyncio.shield(task)
+ _retrieve_future(ready)
return
- except (asyncio.TimeoutError, asyncio.CancelledError):
- pass
except BaseException as exc: # noqa: BLE001
logger.debug('MCP server %s stopped with %s', server_name, exc)
+ _retrieve_future(ready)
return
task.cancel()
with suppress(BaseException):
await task
+ _retrieve_future(task)
+ _retrieve_future(ready)
def _plan_server(self, name: str, server: Dict[str, Any],
default_timeout: int) -> Dict[str, Any]:
@@ -675,11 +958,60 @@ async def add_mcp_config(self, mcp_config: Dict[str, Dict[str, Any]]):
server_name=name, env=env_dict, **server)
self.mcp_config['mcpServers'].update(new_mcp_config)
+ async def _reap_live_tasks(self) -> None:
+ """Stop owner tasks that a cancelled connect did not finish awaiting.
+
+ Quit during a probe used to leave those tasks for the loop shutdown,
+ which then printed ``CancelledError`` after bye.
+ """
+ # A server nobody has signalled yet is still inside connect. Cancel
+ # it. One whose shutdown event is already set is closing itself;
+ # cancelling that owner exits its anyio scope in the wrong task.
+ for name, task in list(self._server_tasks.items()):
+ if task.done():
+ continue
+ shutdown = self._server_shutdown.get(name)
+ if shutdown is None or not shutdown.is_set():
+ task.cancel()
+ pending = [task for task in list(self._live_tasks) if not task.done()]
+ readies = list(self._server_ready.values())
+ self._server_tasks.clear()
+ self._server_shutdown.clear()
+ self._server_ready.clear()
+ self.sessions.clear()
+ try:
+ if pending:
+ await asyncio.shield(
+ asyncio.gather(*pending, return_exceptions=True))
+ except asyncio.CancelledError:
+ return
+ finally:
+ for task in pending:
+ _retrieve_future(task)
+ for ready in readies:
+ _retrieve_future(ready)
+
async def cleanup(self):
"""Clean up resources"""
- for name in list(self._server_tasks):
- await self.disconnect_server(name)
- await self.exit_stack.aclose()
+ try:
+ for name in list(self._server_tasks):
+ try:
+ # Not initialized yet: there is no session to drain, and
+ # the grace period would hang bye on the probe that just
+ # timed out.
+ established = name in self.sessions
+ await self.disconnect_server(name, graceful=established)
+ except asyncio.CancelledError:
+ raise
+ except Exception: # noqa: BLE001
+ logger.debug(
+ 'MCP disconnect %s during cleanup',
+ name,
+ exc_info=True)
+ finally:
+ await self._reap_live_tasks()
+ with suppress(BaseException):
+ await self.exit_stack.aclose()
async def __aenter__(self) -> 'MCPClient':
try:
diff --git a/ms_agent/tools/search/availability.py b/ms_agent/tools/search/availability.py
new file mode 100644
index 000000000..0f3b93b95
--- /dev/null
+++ b/ms_agent/tools/search/availability.py
@@ -0,0 +1,78 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Whether a search engine can be imported, and the sentence to show when not.
+
+The pip name is the thing a person can run. It is not always the import name
+(``exa-py`` imports as ``exa_py``, ``google-search-results`` as ``serpapi``).
+Tavily has no third-party package. A missing key is reported by the key
+column, not by this sentence.
+"""
+from __future__ import annotations
+
+import importlib
+from typing import Iterable, Mapping, Optional
+
+# engine id -> distribution name on PyPI
+_PIP_NAME = {
+ 'arxiv': 'arxiv',
+ 'exa': 'exa-py',
+ 'serpapi': 'google-search-results',
+}
+# engine id -> module importlib should load
+_IMPORT_NAME = {
+ 'arxiv': 'arxiv',
+ 'exa': 'exa_py',
+ 'serpapi': 'serpapi',
+}
+
+
+def _missing_pip(engine: str) -> Optional[str]:
+ pip_name = _PIP_NAME.get(engine)
+ import_name = _IMPORT_NAME.get(engine)
+ if not pip_name or not import_name:
+ return None
+ try:
+ importlib.import_module(import_name)
+ except ModuleNotFoundError:
+ return pip_name
+ return None
+
+
+def unavailable_notice(engine: str, *, has_key: bool,
+ requires_key: bool) -> Optional[str]:
+ """One operable sentence, or None when this engine is not blocked on a package.
+
+ ``requires_key and not has_key`` stays on the key line. Installing a
+ package would not make that engine run.
+ """
+ if requires_key and not has_key:
+ return None
+ pip_name = _missing_pip(engine)
+ if not pip_name:
+ return None
+ return (f'UNAVAILABLE: Package `{pip_name}` is not installed. '
+ f'Run: pip install {pip_name}, then restart ms-agent.')
+
+
+def notices_for_engines(engines: Iterable[str],
+ api_keys: Mapping[str, object],
+ requires_key) -> Optional[str]:
+ """Join per-engine package sentences for a live web-search tool.
+
+ ``requires_key`` is ``search_settings.requires_key``. One engine keeps its
+ sentence intact. Several missing packages share a single UNAVAILABLE label.
+ """
+ lines = []
+ for engine in engines:
+ line = unavailable_notice(
+ engine,
+ has_key=bool(api_keys.get(engine)),
+ requires_key=requires_key(engine),
+ )
+ if line:
+ lines.append(line)
+ if not lines:
+ return None
+ if len(lines) == 1:
+ return lines[0]
+ bodies = [line.split(': ', 1)[1] for line in lines]
+ return 'UNAVAILABLE: ' + ' '.join(bodies)
diff --git a/ms_agent/tools/search/websearch_tool.py b/ms_agent/tools/search/websearch_tool.py
index a0f2e3e4e..e13a5c95d 100644
--- a/ms_agent/tools/search/websearch_tool.py
+++ b/ms_agent/tools/search/websearch_tool.py
@@ -746,6 +746,26 @@ def _bounded_json(self, response: Dict[str, Any]) -> str:
out = _json_dumps(response)
return out
+ async def reload_from_config(self) -> None:
+ """Rebuild engines after ``tools.web_search`` changes on this config."""
+ fresh = type(self)(self.config)
+ keep = {
+ '_engines',
+ '_engine_classes',
+ '_content_fetcher',
+ '_content_optimizer',
+ '_executor',
+ '_summary_usage_total',
+ '_summary_usage_model',
+ }
+ for key, value in vars(fresh).items():
+ if key in keep:
+ continue
+ setattr(self, key, value)
+ self._engines.clear()
+ self._engine_classes.clear()
+ await self.connect()
+
async def connect(self) -> None:
"""Initialize search engines and content fetcher."""
for engine_type in self._engine_types:
diff --git a/ms_agent/tools/todolist_tool.py b/ms_agent/tools/todolist_tool.py
index e9623986c..f8b7d8266 100644
--- a/ms_agent/tools/todolist_tool.py
+++ b/ms_agent/tools/todolist_tool.py
@@ -107,9 +107,17 @@ async def connect(self) -> None:
_ensure_dir(self._lock_dir())
def _paths(self) -> _PlanPaths:
+ # Absolute filenames (WebUI / TUI session dirs) win; relative ones
+ # stay under output_dir as a project-shared plan.
+ plan_json = self._plan_filename
+ plan_md = self._plan_md_filename
+ if not os.path.isabs(plan_json):
+ plan_json = os.path.join(self.output_dir, plan_json)
+ if not os.path.isabs(plan_md):
+ plan_md = os.path.join(self.output_dir, plan_md)
return _PlanPaths(
- plan_json=os.path.join(self.output_dir, self._plan_filename),
- plan_md=os.path.join(self.output_dir, self._plan_md_filename),
+ plan_json=plan_json,
+ plan_md=plan_md,
lock_dir=self._lock_dir(),
)
diff --git a/ms_agent/tools/tool_manager.py b/ms_agent/tools/tool_manager.py
index 5d5473fb7..8e803d85c 100644
--- a/ms_agent/tools/tool_manager.py
+++ b/ms_agent/tools/tool_manager.py
@@ -38,6 +38,58 @@
logger = get_logger()
+
+def _must_propagate(exc: BaseException) -> bool:
+ """Cancellation and unknown base exceptions still leave the tool call.
+
+ An ExceptionGroup from a dead MCP transport is not one of those: it used
+ to escape ``except Exception`` and end the turn as a generic failure.
+ """
+ if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt, SystemExit,
+ GeneratorExit)):
+ return True
+ if type(exc).__name__ in ('ExceptionGroup', 'BaseExceptionGroup'):
+ members = getattr(exc, 'exceptions', None)
+ if isinstance(members, tuple) and members:
+ return all(_must_propagate(item) for item in members)
+ return False
+ return not isinstance(exc, Exception)
+
+
+def _config_key(tool: ToolBase) -> Optional[str]:
+ """The ``tools.`` name /tools lists, when this object has one."""
+ named = getattr(tool, 'SERVER_NAME', None)
+ if isinstance(named, str) and named:
+ return named
+ return {
+ 'FileSystemTool': 'file_system',
+ 'CodeExecutionTool': 'code_executor',
+ 'LocalCodeExecutionTool': 'code_executor',
+ 'ImageReaderTool': 'image_reader',
+ 'ImageGenerator': 'image_generator',
+ 'VideoGenerator': 'video_generator',
+ 'LocalSearchTool': 'local_search',
+ }.get(type(tool).__name__)
+
+
+def _connect_failure_text(tool: ToolBase, exc: BaseException) -> str:
+ """One line for /tools. Package installs stay on the search sentence."""
+ if type(tool).__name__ == 'WebSearchTool':
+ from ms_agent.config.search_settings import requires_key
+ from ms_agent.tools.search.availability import notices_for_engines
+ notice = notices_for_engines(
+ getattr(tool, '_engine_types', ()) or (),
+ getattr(tool, '_api_keys', {}) or {},
+ requires_key,
+ )
+ if notice:
+ return notice
+ line = ' '.join(str(exc).split())
+ if len(line) > 120:
+ line = line[:119] + '…'
+ return f'UNAVAILABLE: {line}'
+
+
MAX_TOOL_NAME_LEN = int(os.getenv('MAX_TOOL_NAME_LEN', 64))
# Default wait around each tool invocation (seconds). Override via config.tool_call_timeout or TOOL_CALL_TIMEOUT.
TOOL_CALL_TIMEOUT = int(os.getenv('TOOL_CALL_TIMEOUT', 120))
@@ -173,6 +225,9 @@ def __init__(
self.mcp_success_handler = mcp_success_handler
self.extra_tools: List[ToolBase] = []
+ # config key -> one-line reason. /tools reads this. MCP servers are
+ # not recorded here; /mcp list already shows those.
+ self.connect_failures: Dict[str, str] = {}
self.has_split_task_tool = False
if hasattr(config, 'tools') and hasattr(config.tools,
'image_generator'):
@@ -351,6 +406,7 @@ async def connect(self):
and self.mcp_config.get('mcpServers')):
logger.warning_once(
'mcp package not installed; MCP tools disabled for this run')
+ self.connect_failures = {}
for tool in self.extra_tools:
try:
await tool.connect()
@@ -358,6 +414,10 @@ async def connect(self):
logger.warning(
f'Tool {getattr(tool, "name", type(tool).__name__)} '
f'failed to connect: {e}; disabling.')
+ key = _config_key(tool)
+ if key:
+ self.connect_failures[key] = _connect_failure_text(
+ tool, e)
# reindex_tool() self-gates its MCP portion on _skip_mcp_reindex, so it
# is always safe to call here: extra tools get indexed in every path,
@@ -372,13 +432,13 @@ async def cleanup(self):
if self._managed_client and self.servers:
try:
await self.servers.cleanup()
- except Exception: # noqa
+ except (asyncio.CancelledError, Exception): # noqa
pass
self.servers = None
for tool in self.extra_tools:
try:
await tool.cleanup()
- except Exception: # noqa
+ except (asyncio.CancelledError, Exception): # noqa
pass
def _clear_mcp_index_entries(self) -> None:
@@ -428,26 +488,45 @@ def _resolve_tool_name(self, tool_name: str) -> tuple:
"""Map what the model asked for onto a registered tool.
Some models reach for the server segment alone — ``code_executor``
- instead of ``code_executor---shell_executor`` — and used to get an
- AssertionError wrapped in a traceback, which says nothing about what
- the name should have been. When the shorthand picks out exactly one
- tool there is no ambiguity to resolve, so the call proceeds and the
- result carries a note; otherwise it is reported as not found, with
- candidates.
+ instead of ``code_executor---shell_executor`` — or for the tool
+ segment alone — ``skills_list`` instead of ``skills---skills_list``.
+ When that shorthand picks out exactly one registered name, the call
+ proceeds and the result carries a note. A fuzzy neighbour is not
+ renamed.
Returns ``(resolved_name, note)``; ``resolved_name`` is ``None`` when
nothing matched.
"""
if tool_name in self._tool_index:
return tool_name, ''
- prefix = f'{tool_name}{self.TOOL_SPLITER}'
+ splitter = self.TOOL_SPLITER
+ prefix = f'{tool_name}{splitter}'
matches = [key for key in self._tool_index if key.startswith(prefix)]
if len(matches) == 1:
- return matches[0], (
- f'Note: "{tool_name}" was resolved to "{matches[0]}". '
- 'Use the exact tool name in subsequent calls.')
+ return matches[0], self._resolved_note(tool_name, matches[0])
+ # A unique prefix is the only shorthand that may have several tools
+ # under one server. A written name that is exactly one registered
+ # suffix (``skills_list`` → ``skills---skills_list``) is the same
+ # kind of correction. Anything else, including a fuzzy neighbour,
+ # stays an error.
+ if not matches:
+ # Same segment call_tool receives: everything after the first
+ # separator. A tool id may itself contain ``---``.
+ suffix_matches = [
+ key for key in self._tool_index
+ if splitter in key and self._registered_tool_suffix(
+ key, splitter) == tool_name
+ ]
+ if len(suffix_matches) == 1:
+ return suffix_matches[0], self._resolved_note(
+ tool_name, suffix_matches[0])
return None, ''
+ @staticmethod
+ def _resolved_note(asked: str, resolved: str) -> str:
+ return (f'Note: "{asked}" was resolved to "{resolved}". '
+ 'Use the exact tool name in subsequent calls.')
+
def _unknown_tool_message(self, tool_name: str) -> str:
"""Say what to call instead, rather than how the lookup failed."""
import difflib
@@ -831,7 +910,9 @@ async def single_call_tool(self, tool_info: ToolCall):
'is_error':
True,
}
- except Exception as e:
+ except BaseException as e:
+ if _must_propagate(e):
+ raise
import traceback
tb_str = traceback.format_exc()
logger.warning(tb_str)
diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py
index aa25d4800..940a681be 100644
--- a/ms_agent/tui/app.py
+++ b/ms_agent/tui/app.py
@@ -22,6 +22,7 @@
import asyncio
import logging
import os
+import signal
from omegaconf import OmegaConf
from pathlib import Path
from rich.console import Console
@@ -31,6 +32,7 @@
from ms_agent.config import Config
from ms_agent.config.env import Env
+from ms_agent.config.search_settings import DEFAULT_WEB_SEARCH_BLOCK
from ms_agent.tui.input import PromptToolkitInput
from ms_agent.tui.renderer import RichEventSink
from ms_agent.tui.state import TuiState
@@ -39,6 +41,30 @@
logger = get_logger()
+# Same discriminator WebUI session_overrides use. Writing
+# tools.todo_list.plan_filename without mcp:false makes ToolManager treat
+# todo_list as an MCP server ('url' or 'command' parameter is required).
+# Snapshots default off: TUI has no /rollback, and a home-dir work tree
+# would git-add the whole $HOME on the first turn.
+TUI_RESOLVER_DEFAULTS = {
+ 'enable_snapshots': False,
+ 'tools': {
+ 'todo_list': {
+ 'enabled': True,
+ 'mcp': False,
+ },
+ 'web_search': dict(DEFAULT_WEB_SEARCH_BLOCK),
+ },
+ # Non-empty so LLMAgent.prepare_skills runs. An empty ``{}`` is falsy
+ # under OmegaConf. User and project skills still load.
+ 'skills': {
+ 'prompt_injection': 'all',
+ # Freeze the skill section for the session. Changes are announced
+ # on the next user message (see tui/live_config.py).
+ 'update_notice': True,
+ },
+}
+
class TuiApp:
@@ -51,6 +77,7 @@ def __init__(
work_dir: Optional[str] = None,
emit_events: Optional[str] = None,
mcp_server_file: Optional[str] = None,
+ explicit_config: bool = False,
) -> None:
Env.load_dotenv_into_environ(env_file)
self.console = Console()
@@ -59,9 +86,12 @@ def __init__(
self.work_dir = str(
Path(work_dir).expanduser().resolve() if work_dir else Path.cwd().
resolve())
+ self._project = self._open_project(self.work_dir)
- config = Config.from_task(config_path)
- config = self._prepare_config(config, permission_mode, self.work_dir)
+ config = self._load_runtime_config(
+ config_path, self.work_dir, explicit_config=explicit_config)
+ config = self._prepare_config(
+ config, permission_mode, self.work_dir, self._project)
self.config = config
mode = str(
@@ -84,16 +114,10 @@ def __init__(
self._jsonl_sink = JsonlEventSink(emit_events)
event_sink = TeeEventSink(self.renderer, self._jsonl_sink)
- # Session layer (M1). Work dir == project (CC-aligned): sessions live at
- # ~/.ms_agent/projects//sessions.
+ # Same path as WebUI: ProjectManager.open_folder (find-by-path, else
+ # path-key). Sessions then live under that project's id.
from ms_agent.project import SessionManager
- from ms_agent.project.paths import project_key
- from ms_agent.project.types import Project
- proj = Project(
- id=project_key(self.work_dir),
- name=Path(self.work_dir).name or 'project',
- path=self.work_dir)
- self._sm = SessionManager(proj)
+ self._sm = SessionManager(self._project)
self.session = None
# Bridge managed config files into the runtime (what a WebUI backend
@@ -112,18 +136,39 @@ def __init__(
# Build the agent ONCE with the UI seams injected. load_cache is set
# per session by _apply_session (True only on resume).
from ms_agent.agent.llm_agent import LLMAgent
+ from ms_agent.config.mcp_schema import ResolvedMCPConfig
+ from ms_agent.mcp.runtime import MCPRuntime
+ from ms_agent.tui.live_config import apply_live_config
+ servers = {}
+ if isinstance(self._mcp_config, dict):
+ servers = dict(self._mcp_config.get('mcpServers') or {})
+ mcp_runtime = MCPRuntime(
+ config=ResolvedMCPConfig(mcp_servers=servers))
self.agent = LLMAgent(
config,
trust_remote_code=trust_remote_code,
event_sink=event_sink,
- mcp_config=self._mcp_config)
+ mcp_config=self._mcp_config,
+ mcp_runtime=mcp_runtime)
+
+ async def _on_user_turn(messages):
+ await apply_live_config(self.agent, messages)
+
+ self.agent._on_user_turn = _on_user_turn
# Consume the agent's single command router (no duplicate); register the
# TUI session commands and drive input through it (slash completion).
self.router = self.agent._get_command_router()
self._register_session_commands()
self.input = PromptToolkitInput(
- self.state, self.router, self.theme, console=self.console)
+ self.state,
+ self.router,
+ self.theme,
+ console=self.console,
+ renderer=self.renderer)
+ self.input._sigint = self
+ from ms_agent.tui.select import bind_sigint_rearm
+ bind_sigint_rearm(self._arm_turn_sigint)
self.agent._input_source = self.input
# Always inject the TUI handler — it is only *called* in interactive
@@ -141,11 +186,143 @@ def __init__(
# ('new', None) | ('resume', '<#|id>') | None, set by session commands.
self._pending_switch: Optional[Tuple[str, Optional[str]]] = None
+ # First chat collected while recovering from a missing API key.
+ self._queued_query: Optional[str] = None
+ # Ctrl-C during a stream cancels this turn, not the whole TUI.
+ self._serve_task: Optional[asyncio.Task] = None
+ self._turn_task: Optional[asyncio.Task] = None
+ self._streaming = False
+ self._sigint_turn = False
+ self._sigint_exit = False
+ self._sigint_armed = False
+ # Sessions this TUI process minted. Empty leftovers may be pruned;
+ # WebUI (or another TUI) sessions must not.
+ self._owned_session_ids: set[str] = set()
+
+ @staticmethod
+ def _apply_provider_credentials(config, overwrite: bool = False) -> None:
+ """Fill llm keys from settings.json ``providers.``.
+
+ ``overwrite=True`` is for a live ``/model provider`` edit of the
+ current service (replace, don't only fill blanks).
+ """
+ try:
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ raw = ModelSettingsManager(global_home())._load_raw()
+ except Exception:
+ return
+ if not isinstance(raw, dict):
+ raw = {}
+
+ def _set(field, value, *, force: bool) -> None:
+ if value in (None, ''):
+ if force:
+ OmegaConf.update(config, field, '', merge=True)
+ return
+ if force or not OmegaConf.select(config, field, default=None):
+ OmegaConf.update(config, field, value, merge=True)
+
+ service = str(OmegaConf.select(config, 'llm.service', default='') or '')
+ providers = raw.get('providers') or {}
+ entry = providers.get(service) if isinstance(providers.get(service), dict) else {}
+ if not entry and service:
+ for pid, item in providers.items():
+ if str(pid).lower() == service.lower() and isinstance(item, dict):
+ entry = item
+ break
+ if not service:
+ return
+ # Canonicalize MiniMax → minimax so later lookups hit the builtin id.
+ from ms_agent.llm.spec import get_registry
+ spec = get_registry().get(service)
+ if spec is not None and spec.name != service:
+ OmegaConf.update(config, 'llm.service', spec.name, merge=True)
+ service = spec.name
+ if not entry:
+ hit = providers.get(spec.name)
+ if isinstance(hit, dict):
+ entry = hit
+
+ key_field = f'llm.{service}_api_key'
+ url_field = f'llm.{service}_base_url'
+ _set(key_field, entry.get('api_key'), force=overwrite)
+ _set('llm.api_key', entry.get('api_key'), force=overwrite)
+ _set(url_field, entry.get('base_url'), force=overwrite)
+ _set('llm.base_url', entry.get('base_url'), force=overwrite)
+ proto = entry.get('protocol')
+ if proto and (overwrite or not OmegaConf.select(
+ config, 'llm.protocol', default=None)):
+ OmegaConf.update(config, 'llm.protocol', proto, merge=True)
+
+ @staticmethod
+ def _open_project(work_dir: str):
+ """Same ProjectManager.open_folder path WebUI uses for a local folder.
+
+ New mounts inherit PersonalizationSettings.memory_enabled (WebUI's
+ default for new projects). An already-registered folder keeps its
+ stored flags.
+ """
+ from ms_agent.project import ProjectManager
+ from ms_agent.project.paths import global_home
+ from ms_agent.personalization.settings import PersonalizationSettings
+
+ mem = False
+ backend = None
+ try:
+ loaded = PersonalizationSettings().load()
+ mem = bool(loaded.memory_enabled)
+ backend = loaded.memory_backend
+ except Exception:
+ pass
+ return ProjectManager(base_dir=str(global_home())).open_folder(
+ work_dir, memory_enabled=mem, memory_backend=backend)
# -- config shaping --
@staticmethod
- def _prepare_config(config, permission_mode, work_dir):
+ def _load_runtime_config(config_path: str,
+ work_dir: str,
+ *,
+ explicit_config: bool = False):
+ """Same layered merge WebUI uses (ConfigResolver), not Config.from_task
+ alone.
+
+ Default TUI (no ``--config``): framework yaml → settings.json → project
+ patch, so WebUI's ``default_model`` / ``llm`` is what the first turn
+ actually runs. An explicit ``--config`` yaml still wins over settings.
+ """
+ from ms_agent.config.resolver import ConfigResolver
+ from ms_agent.project.paths import global_home
+
+ resolver = ConfigResolver(
+ global_dir=str(global_home()),
+ project_root=work_dir,
+ defaults=TUI_RESOLVER_DEFAULTS,
+ )
+ agent_config = Config.from_task(config_path) if explicit_config else None
+ return resolver.resolve(
+ agent_config=agent_config,
+ project_path=work_dir,
+ )
+
+ @staticmethod
+ def _bind_todo_list_session(cfg, sess_dir: str) -> None:
+ """Point the builtin plan tool at this session dir.
+
+ ``mcp: false`` must be set whenever plan_filename is written: a
+ ``tools.todo_list`` node without that flag is treated as an MCP server.
+ """
+ plan_json = os.path.join(sess_dir, 'plan.json')
+ plan_md = os.path.join(sess_dir, 'plan.md')
+ OmegaConf.update(cfg, 'tools.todo_list.mcp', False, merge=True)
+ OmegaConf.update(
+ cfg, 'tools.todo_list.plan_filename', plan_json, merge=True)
+ OmegaConf.update(
+ cfg, 'tools.todo_list.plan_md_filename', plan_md, merge=True)
+
+ @staticmethod
+ def _prepare_config(config, permission_mode, work_dir, project=None):
OmegaConf.update(config, 'generation_config.stream', True, merge=True)
OmegaConf.update(
config, 'generation_config.stream_output', True, merge=True)
@@ -160,11 +337,16 @@ def _prepare_config(config, permission_mode, work_dir):
OmegaConf.update(config, 'session_log.enabled', True, merge=True)
# Interactive lifecycle regardless of stdin detection.
OmegaConf.update(config, 'interactive', True, merge=True)
+ # Same as WebUI: data-driven provider layer (credentials + protocol).
+ OmegaConf.update(config, 'llm.use_provider_router', True, merge=True)
# max_chat_round bounds autonomous *steps*; under route A the counter
# accumulates across interactive turns (and restores on resume), so a
# small per-task value would cut a long chat short. Raise it high — the
# user (not a round cap) ends an interactive session.
OmegaConf.update(config, 'max_chat_round', 1000, merge=True)
+ # Seed before _apply_session writes plan paths, so a fresh TUI without
+ # a WebUI-seeded settings.json still does not MCP-connect todo_list.
+ OmegaConf.update(config, 'tools.todo_list.mcp', False, merge=True)
if permission_mode:
OmegaConf.update(
config, 'permission.mode', permission_mode, merge=True)
@@ -175,14 +357,47 @@ def _prepare_config(config, permission_mode, work_dir):
if c != 'input_callback'
]
OmegaConf.update(config, 'callbacks', cbs, merge=False)
- # Merge the work-dir project patch (e.g. a persisted /model override).
+ # Merge a work-dir ``.ms_agent/config.yaml`` pin if one exists.
+ # ``/model`` no longer writes this file; skipped when resolve() already
+ # applied it.
+ if not getattr(config, '_project_patch_applied', False):
+ try:
+ from ms_agent.config.resolver import ConfigResolver
+ patch = ConfigResolver()._load_project_patch(work_dir)
+ if patch is not None:
+ config = OmegaConf.merge(config, patch)
+ except Exception:
+ logger.debug(
+ 'work-dir config patch merge skipped', exc_info=True)
+ TuiApp._apply_provider_credentials(config)
+ # Same files WebUI writes: settings.json personalization + project
+ # instruction. File-based AGENTS.md / PROFILE.md are read live.
+ if project is not None and getattr(project, 'instruction', ''):
+ OmegaConf.update(
+ config,
+ 'personalization.project_instruction',
+ project.instruction,
+ merge=True,
+ )
try:
- from ms_agent.config.resolver import ConfigResolver
- patch = ConfigResolver()._load_project_patch(work_dir)
- if patch is not None:
- config = OmegaConf.merge(config, patch)
+ from ms_agent.personalization.settings import PersonalizationSettings
+ loaded = PersonalizationSettings().load()
+ if loaded.global_instruction:
+ OmegaConf.update(
+ config,
+ 'personalization.global_instruction',
+ loaded.global_instruction,
+ merge=True,
+ )
except Exception:
- logger.debug('work-dir config patch merge skipped', exc_info=True)
+ logger.debug('personalization settings merge skipped', exc_info=True)
+ if project is not None:
+ try:
+ from ms_agent.personalization.memory_apply import (
+ apply_project_memory)
+ apply_project_memory(config, project)
+ except Exception:
+ logger.debug('project memory apply skipped', exc_info=True)
return config
# -- session commands (registered into the agent's router) --
@@ -265,16 +480,85 @@ def _apply_session(self, session, resume: bool = False) -> None:
otherwise the legacy output_dir/tag history (``read_history``) would be
loaded when the new SessionLog is still empty — replaying the previous
session's messages. SessionLog is the single source of truth here.
+
+ When the session has its own model, that model is applied to the live
+ agent. The global default is left alone, so a new chat still starts on
+ the last model the user chose, not on whichever old chat was open.
"""
sess_dir = str(self._sm.sessions_dir / session.id)
cfg = self.agent.config
OmegaConf.update(cfg, 'session_log.dir', sess_dir, merge=True)
OmegaConf.update(
cfg, 'session_log.session_key', session.session_key, merge=True)
+ # Same as WebUI: plan files live beside the session log, not a
+ # project-shared workspace plan.json. mcp:false is required so
+ # ToolManager does not treat todo_list as an MCP server.
+ self._bind_todo_list_session(cfg, sess_dir)
+ # prepare_tools() rebuilds TodoListTool from this config each
+ # run_loop; do not patch extra_tools here (those instances are
+ # discarded).
self.config = cfg # keep the app reference in sync for banners/views
self.session = session
self.state.session_name = session.name
self.agent.load_cache = resume
+ if session.model:
+ self._bind_live_model(session.model_provider, session.model)
+ else:
+ # No per-conversation model yet (a chat from before this was
+ # stored). Follow the default for this visit, and do not write it
+ # onto the session: we do not know which model it originally used.
+ provider, model = self._default_binding()
+ self._bind_live_model(provider, model)
+
+ def _default_binding(self) -> tuple[str | None, str | None]:
+ """Provider and model a new conversation starts on.
+
+ Reads the shared default, not the model currently on the agent. After
+ resuming an older chat the agent is on that chat's model; a new chat
+ must still start on the default.
+ """
+ from ms_agent.config.model_settings import (
+ ModelSettingsManager, strip_provider_model_prefix)
+ from ms_agent.project.paths import global_home
+
+ raw = ModelSettingsManager(global_home()).get_default_model() or ''
+ if '/' in raw:
+ provider, model = raw.split('/', 1)
+ model = strip_provider_model_prefix(provider, model) or model
+ return provider or None, model or None
+ if raw:
+ provider = str(
+ OmegaConf.select(self.agent.config, 'llm.service', default='')
+ or '') or None
+ return provider, raw
+ model = str(
+ OmegaConf.select(self.agent.config, 'llm.model', default='')
+ or '') or None
+ provider = str(
+ OmegaConf.select(self.agent.config, 'llm.service', default='')
+ or '') or None
+ return provider, model
+
+ def _open_conversation(self):
+ """Create a session stamped with the current global default."""
+ provider, model = self._default_binding()
+ return self._sm.create(model=model, model_provider=provider)
+
+ def _bind_live_model(self, provider: str | None, model: str | None) -> None:
+ """Point the live agent at a conversation's model.
+
+ Does not write ``default_model``. The next ``run()`` builds the client
+ from this config.
+ """
+ if not model:
+ return
+ cfg = self.agent.config
+ OmegaConf.update(cfg, 'llm.model', model, merge=True)
+ if provider:
+ OmegaConf.update(cfg, 'llm.service', provider, merge=True)
+ self._apply_provider_credentials(cfg, overwrite=True)
+ self.config = cfg
+ self.state.model = model
def _resume_target(self, arg: str):
sessions = self._sm.list()
@@ -290,16 +574,16 @@ def _session_has_history(self, session) -> bool:
except Exception:
return True # on doubt, keep it
- def _prune_empty_sessions(self) -> None:
- """Delete sessions that never received a user turn (leftover empties
- from prior launches), so ``/sessions`` stays meaningful."""
- for s in self._sm.list():
- self._prune_if_empty(s)
-
def _prune_if_empty(self, session) -> None:
+ # Only drop unused sessions this process created. A resumed WebUI
+ # chat with no user-role line must stay.
+ owned = getattr(self, '_owned_session_ids', set())
+ if session.id not in owned:
+ return
if not self._session_has_history(session):
try:
self._sm.delete(session.id)
+ owned.discard(session.id)
except Exception:
pass
@@ -357,8 +641,7 @@ def _banner(self) -> None:
info.add_column(style='blue', justify='right')
info.add_column()
info.add_row(
- 'model',
- f'{getattr(llm, "service", "?")}/{getattr(llm, "model", "?")}')
+ 'model', str(getattr(llm, 'model', '') or '').strip() or '?')
info.add_row('tools', ', '.join(tools) or '[dim]none[/]')
info.add_row('perm', f'[yellow]{self.permission_mode}[/]')
info.add_row('dir', f'[dim]{wd}[/]')
@@ -429,50 +712,168 @@ def _quiet_logs() -> None:
# -- main loop (route A: one lifecycle per session) --
+ def _arm_turn_sigint(self) -> None:
+ """Ctrl-C during a running turn cancels that turn and returns to the prompt.
+
+ prompt_toolkit owns Ctrl-C while a line is being edited. That
+ KeyboardInterrupt quits the TUI, because nothing is running. It also
+ removes this handler, so the prompt and any arrow menu re-arm it
+ on the way out.
+ """
+ try:
+ loop = asyncio.get_running_loop()
+ loop.add_signal_handler(signal.SIGINT, self._on_turn_sigint)
+ except (NotImplementedError, RuntimeError, ValueError):
+ return
+ self._sigint_armed = True
+
+ def _disarm_turn_sigint(self) -> None:
+ if not getattr(self, '_sigint_armed', False):
+ return
+ try:
+ asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
+ except (NotImplementedError, RuntimeError, ValueError):
+ pass
+ self._sigint_armed = False
+
+ def _on_turn_sigint(self) -> None:
+ if not getattr(self, '_streaming', False):
+ return
+ turn = getattr(self, '_turn_task', None)
+ if turn is None or turn.done():
+ return
+ if getattr(self, '_sigint_turn', False):
+ # Already cancelling this turn. Stay on that path and return to
+ # the prompt; the next Ctrl-C, at the prompt, quits the TUI.
+ return
+ self._sigint_turn = True
+ turn.cancel()
+
+ async def _run_one_turn(self, query):
+ """One session lifecycle. Ctrl-C resolves as ``'interrupted'``."""
+ self._streaming = True
+ try:
+ gen = await self.agent.run(query, stream=True)
+ if gen is None:
+ return None
+ async for _ in gen:
+ pass
+ return None
+ except KeyboardInterrupt:
+ return 'interrupted'
+ except asyncio.CancelledError:
+ if getattr(self, '_sigint_turn', False):
+ return 'interrupted'
+ raise
+
+ def _note_interrupted(self) -> None:
+ self.renderer.finalize()
+ self.console.print('[dim](interrupted — Ctrl-C or /quit to exit)[/]')
+ try:
+ self._sm.get_session_log(self.session).append({
+ 'role': 'assistant',
+ 'content': '(interrupted)',
+ })
+ except Exception:
+ pass
+
async def _serve(self) -> None:
self._banner()
- self._prune_empty_sessions(
- ) # clear leftover empties from prior launches
- self.session = self._sm.create(model=self._model or None)
+ # Do not wipe empty sessions on startup: WebUI may have created a
+ # chat the user has not typed into yet. Empty leftovers from *this*
+ # TUI process are pruned when leaving the session (below).
+ self.session = self._open_conversation()
+ self._owned_session_ids.add(self.session.id)
resume = False # a fresh session reads a prompt; a resumed one restores
self.renderer.rule(f'session {self.session.id}', 'green')
+ self._serve_task = asyncio.current_task()
+ self._sigint_turn = False
+ self._sigint_exit = False
+ self._streaming = False
+ self._arm_turn_sigint()
+ try:
+ await self._serve_loop(resume)
+ finally:
+ self._disarm_turn_sigint()
+ self.console.print('[dim]bye[/]')
+
+ async def _serve_loop(self, resume: bool) -> None:
while True:
self._pending_switch = None
self._apply_session(self.session, resume=resume)
+ self._sigint_turn = False
+ query = self._queued_query
+ self._queued_query = None
+ self._turn_task = asyncio.create_task(self._run_one_turn(query))
try:
- gen = await self.agent.run(None, stream=True)
- async for _ in gen:
- pass
- except EOFError:
- break # Ctrl-D at the prompt exits
- except KeyboardInterrupt:
- # Ctrl-C interrupts the running turn but keeps the REPL alive.
- # Cap the turn with an assistant marker so the resume below
- # doesn't re-run the interrupted user prompt.
- self.renderer.finalize()
- self.console.print('[dim](interrupted — /quit to exit)[/]')
try:
- self._sm.get_session_log(self.session).append({
- 'role':
- 'assistant',
- 'content':
- '(interrupted)'
- })
- except Exception:
- pass
- resume = True
- continue
- except Exception as e: # noqa: BLE001 — surface, don't crash the REPL
- self.renderer.finalize()
- logger.warning('TUI lifecycle error', exc_info=True)
- self.console.print(
- Panel(
- f'[bold]{type(e).__name__}[/]: {e}',
- title='[red]error[/]',
- border_style='red',
- subtitle='[dim]LOG_LEVEL=INFO for details[/]',
- expand=False))
- break
+ outcome = await self._turn_task
+ except EOFError:
+ break # Ctrl-D at the prompt exits
+ except asyncio.CancelledError:
+ if getattr(self, '_sigint_exit', False):
+ break
+ task = asyncio.current_task()
+ quitting = bool(
+ getattr(
+ getattr(
+ getattr(self, 'agent', None), 'runtime',
+ None),
+ 'should_stop',
+ False))
+ # A cancelled task (/quit teardown, or this task itself)
+ # leaves. Any other CancelledError stays in the session.
+ # Task.cancelling() exists on Python 3.11+.
+ cancelling = getattr(task, 'cancelling', None)
+ if callable(cancelling) and cancelling():
+ raise
+ if quitting:
+ break
+ self.renderer.finalize()
+ self.console.print(
+ '[dim](turn failed — session still open, '
+ '/model to switch, /quit to exit)[/]')
+ resume = True
+ continue
+ except KeyboardInterrupt:
+ # Ctrl-C interrupts the running turn but keeps the REPL.
+ # Cap the turn so resume does not re-run the prompt.
+ self._note_interrupted()
+ resume = True
+ continue
+ except Exception as e: # noqa: BLE001 — stay in the REPL
+ # A leaked MCP cancel-scope error is teardown noise, not a
+ # request to leave. cleanup_tools already swallows it on
+ # /quit; if it still surfaces mid-turn, stay in the session.
+ self.renderer.finalize()
+ logger.warning('TUI turn error', exc_info=True)
+ # run_loop already emitted ErrorRaised; the renderer drew
+ # the panel. Reprinting it and then leaving looked like a
+ # crash. Keep the REPL so the user can /model or try again.
+ from ms_agent.llm.model_discovery import (
+ CHAT_UNSUPPORTED,
+ MODEL_ID_REJECTED,
+ chat_failed_because_unsupported,
+ model_id_rejected,
+ )
+ model = self.state.model or ''
+ err = str(e)
+ if chat_failed_because_unsupported(model, err):
+ self.console.print(CHAT_UNSUPPORTED)
+ if model_id_rejected(err):
+ self.console.print(MODEL_ID_REJECTED)
+ self.console.print(
+ '[dim](turn failed — session still open, '
+ '/model to switch, /quit to exit)[/]')
+ resume = True
+ continue
+ if outcome == 'interrupted':
+ self._note_interrupted()
+ resume = True
+ continue
+ finally:
+ self._turn_task = None
+ self._streaming = False
self._name_session_from_log()
# Resolve a resume target against the live list before pruning.
switch = self._pending_switch
@@ -485,7 +886,8 @@ async def _serve(self) -> None:
break # user quit
kind = switch[0]
if kind == 'new':
- self.session = self._sm.create(model=self._model or None)
+ self.session = self._open_conversation()
+ self._owned_session_ids.add(self.session.id)
resume = False
self.renderer.rule(f'new session {self.session.id}', 'green')
elif kind == 'resume':
@@ -499,7 +901,6 @@ async def _serve(self) -> None:
self.renderer.rule(f'resumed {resume_target.name}',
'green')
self._render_history_tail(resume_target)
- self.console.print('[dim]bye[/]')
def run(self) -> None:
self._quiet_logs()
@@ -520,6 +921,7 @@ def main(
work_dir: Optional[str] = None,
emit_events: Optional[str] = None,
mcp_server_file: Optional[str] = None,
+ explicit_config: bool = False,
) -> None:
TuiApp(
config_path,
@@ -528,4 +930,5 @@ def main(
trust_remote_code=trust_remote_code,
work_dir=work_dir,
emit_events=emit_events,
- mcp_server_file=mcp_server_file).run()
+ mcp_server_file=mcp_server_file,
+ explicit_config=explicit_config).run()
diff --git a/ms_agent/tui/input.py b/ms_agent/tui/input.py
index 6806239b7..3dfbcbedb 100644
--- a/ms_agent/tui/input.py
+++ b/ms_agent/tui/input.py
@@ -13,6 +13,7 @@
from __future__ import annotations
import asyncio
+import re
import sys
from typing import TYPE_CHECKING, Optional
@@ -22,6 +23,49 @@
if TYPE_CHECKING:
from ms_agent.command.router import CommandRouter
+# Bracketed paste wraps the clipboard in these markers. If they leak into the
+# line (the terminal was not in paste mode) they must not become part of the
+# command. The caret form is how a cooked terminal echoes the same bytes.
+_PASTE_MARKERS = (
+ '\x1b[200~',
+ '\x1b[201~',
+ '^[[200~',
+ '^[[201~',
+)
+# Cursor Position Report. A cooked terminal echoes it as text, so
+# ``^[[8;8R/mcp list`` would otherwise be sent to the model.
+_CPR_RE = re.compile(r'(?:\x1b\[\d+;\d+R|\^\[\[\d+;\d+R)')
+
+
+def _strip_terminal_noise(line: str) -> str:
+ for marker in _PASTE_MARKERS:
+ line = line.replace(marker, '')
+ return _CPR_RE.sub('', line)
+
+
+def split_prompt_lines(text: str) -> list:
+ """Split one paste into the lines the command loop should see.
+
+ prompt_toolkit returns a multiline paste as a single string. Fed through
+ whole, ``/model`` treats the next line as part of the model id. A trailing
+ empty line (the paste ended on Enter) is not itself a command. A cursor
+ position report (``^[[8;8R``) is terminal noise, not part of the command.
+ """
+ cleaned = (text or '').replace('\r\n', '\n').replace('\r', '\n')
+ if '\n' not in cleaned:
+ return [_strip_terminal_noise(cleaned)]
+ lines = cleaned.split('\n')
+ if lines and lines[-1] == '':
+ lines.pop()
+ kept = []
+ for line in lines:
+ stripped = _strip_terminal_noise(line)
+ # A line that was only a cursor report must not be sent.
+ if stripped == '' and line != '':
+ continue
+ kept.append(stripped)
+ return kept or ['']
+
def slash_matches(router, text: str):
"""Yield ``(name, description)`` for /command completions of ``text``.
@@ -49,11 +93,17 @@ def __init__(self,
state: TuiState,
router: Optional['CommandRouter'] = None,
theme: Theme = DEFAULT_THEME,
- console: object = None) -> None:
+ console: object = None,
+ renderer: object = None) -> None:
self._state = state
self._router = router
self._theme = theme
self._console = console # rich Console, for the pre-prompt divider
+ # RichEventSink. Released before prompt_toolkit draws, so Live's
+ # refresh thread is not still writing when raw mode starts.
+ self._renderer = renderer
+ # Lines left over from a multiline paste. Each becomes its own prompt.
+ self._queued: list = []
self._session = self._build_session()
def _build_session(self):
@@ -108,6 +158,7 @@ def get_completions(self, document, complete_event):
history=InMemoryHistory(),
completer=_SlashCompleter(),
complete_while_typing=True,
+ # Up and down walk this history.
bottom_toolbar=self._toolbar,
placeholder=HTML(
'Type a message'
@@ -116,6 +167,11 @@ def get_completions(self, document, complete_event):
except Exception:
return None
+ def note_model(self, model: str) -> None:
+ """Refresh the status-bar model after a command mutates it."""
+ if model:
+ self._state.model = str(model)
+
def _toolbar(self):
s = self._state
parts = []
@@ -133,15 +189,43 @@ def _message(self):
return HTML(
f'{self._theme.prompt_symbol} ')
+ def _release_terminal(self) -> None:
+ renderer = self._renderer
+ if renderer is None:
+ return
+ release = getattr(renderer, 'release_terminal', None)
+ if release is not None:
+ release()
+
async def read_prompt(self, prompt: str = '❯ ') -> str:
# The TUI owns its prompt presentation; the caller's ``prompt`` arg is
# only a fallback hint for the non-interactive path.
+ # Live must be fully stopped before prompt_toolkit enables raw mode
+ # and bracketed paste. A queued paste line skips the divider, but
+ # still waits out a region left up by the turn that just finished.
+ self._release_terminal()
+ if self._queued:
+ return self._queued.pop(0)
if self._session is not None and sys.stdin.isatty():
# A subtle full-width divider separates the input from the
# conversation above (Claude Code style).
if self._console is not None:
from rich.rule import Rule
self._console.print(Rule(style='grey30'))
- return await self._session.prompt_async(self._message())
+ # prompt_toolkit owns Ctrl-C while the line is being edited.
+ # After it returns, the TUI handler covers the stream that follows.
+ sigint = getattr(self, '_sigint', None)
+ if sigint is not None:
+ sigint._streaming = False
+ try:
+ text = await self._session.prompt_async(self._message())
+ finally:
+ if sigint is not None:
+ sigint._arm_turn_sigint()
+ sigint._streaming = True
+ lines = split_prompt_lines(text)
+ if len(lines) > 1:
+ self._queued.extend(lines[1:])
+ return lines[0] if lines else ''
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, input, prompt)
diff --git a/ms_agent/tui/live_config.py b/ms_agent/tui/live_config.py
new file mode 100644
index 000000000..42a729e2e
--- /dev/null
+++ b/ms_agent/tui/live_config.py
@@ -0,0 +1,363 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Apply ledger changes on the next user message, inside the same agent.
+
+TUI sets :attr:`LLMAgent._on_user_turn` to :func:`apply_live_config`.
+WebUI leaves the attribute unset and keeps its own rebuild path.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+from typing import Any
+
+from omegaconf import OmegaConf
+
+from ms_agent.llm.message_text import prepend_text
+from ms_agent.utils import get_logger
+
+logger = get_logger()
+
+_SKILL_NOTICE = (
+ '\n'
+ 'Skill inventory updated. The list in the system prompt is from '
+ 'session start; skills_list is current. Do not mention this notice '
+ 'to the user.\n'
+ ''
+)
+
+
+def _token(payload: Any) -> str:
+ return hashlib.sha1(
+ json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
+
+
+def _work_dir(agent) -> str | None:
+ work = str(OmegaConf.select(agent.config, 'output_dir', default='') or '')
+ return work or None
+
+
+def _install_llm(agent, rebuilt) -> None:
+ from ms_agent.command.builtin.config_cmds import _install_rebuilt_llm
+ target = agent.llm
+ if target is not None and _install_rebuilt_llm(target, rebuilt):
+ if agent.runtime is not None:
+ agent.runtime.llm = target
+ return
+ agent.llm = rebuilt
+ if agent.runtime is not None:
+ agent.runtime.llm = rebuilt
+
+
+def _drop_live_llm(agent) -> None:
+ """Remove the client that was built with a key the config no longer has.
+
+ The agent stays. Slash commands still read ``config`` off the placeholder.
+ Memory tools must not keep the old client either.
+ """
+ from types import SimpleNamespace
+ model = str(OmegaConf.select(agent.config, 'llm.model', default='') or '')
+ stub = SimpleNamespace(config=agent.config, model=model, _setup_stub=True)
+ agent.llm = stub
+ runtime = getattr(agent, 'runtime', None)
+ if runtime is not None:
+ runtime.llm = stub
+ for orch in list(getattr(agent, 'memory_tools', None) or []):
+ set_llm = getattr(orch, 'set_llm', None)
+ if set_llm is None:
+ continue
+ try:
+ set_llm(None)
+ except Exception:
+ logger.debug('live llm release skipped', exc_info=True)
+
+
+def _point_memory_at_llm(agent) -> None:
+ for orch in list(getattr(agent, 'memory_tools', None) or []):
+ set_llm = getattr(orch, 'set_llm', None)
+ if set_llm is not None and agent.llm is not None:
+ set_llm(agent.llm)
+
+
+async def _sync_llm(agent) -> None:
+ from ms_agent.llm import LLM
+ from ms_agent.llm.credentials import client_rejected_missing_key
+ from ms_agent.llm.runtime_token import llm_runtime_token
+ if agent.llm is None:
+ return
+ token = llm_runtime_token(agent.config)
+ if getattr(agent, '_llm_fp', None) == token:
+ return
+ # A placeholder with no fingerprint is startup. The setup prompt owns it.
+ # A placeholder that already has a fingerprint was installed because the
+ # key was cleared; a later edit must be allowed to build a client again.
+ if (getattr(agent.llm, '_setup_stub', False)
+ and getattr(agent, '_llm_fp', None) is None):
+ return
+ try:
+ rebuilt = LLM.from_config(agent.config)
+ except Exception as exc:
+ if client_rejected_missing_key(exc):
+ _drop_live_llm(agent)
+ agent._llm_fp = token
+ return
+ logger.debug('live llm rebuild skipped', exc_info=True)
+ return
+ if rebuilt is None:
+ return
+ _install_llm(agent, rebuilt)
+ agent._llm_fp = token
+ _point_memory_at_llm(agent)
+
+
+def _emit_once(agent, key: str, text: str) -> None:
+ seen = getattr(agent, '_live_notice_keys', None)
+ if seen is None:
+ seen = set()
+ agent._live_notice_keys = seen
+ if key in seen:
+ return
+ seen.add(key)
+ emit = getattr(agent, '_emit_user_notice', None)
+ if emit is not None:
+ emit(text)
+ return
+ logger.error(text)
+
+
+def _note_unreadable_config(agent) -> None:
+ """One sentence per broken ledger file or unreadable skill directory."""
+ from pathlib import Path
+
+ from ms_agent.project.paths import global_home
+
+ roots = [Path(global_home())]
+ work = _work_dir(agent)
+ if work:
+ roots.append(Path(work) / '.ms_agent')
+ names = ('mcp.json', 'skills.json', 'settings.json')
+ for root in roots:
+ for name in names:
+ path = root / name
+ if not path.is_file():
+ continue
+ try:
+ json.loads(path.read_text(encoding='utf-8'))
+ except json.JSONDecodeError:
+ _emit_once(
+ agent, f'json:{path}', f'{name} 读不进去。请检查格式。')
+ except OSError:
+ continue
+ skills = root / 'skills'
+ if not skills.is_dir():
+ continue
+ try:
+ next(skills.iterdir(), None)
+ except OSError:
+ _emit_once(
+ agent, f'skills:{skills}',
+ '技能目录读不了。请检查这个目录的权限。')
+
+
+def _search_in_sync(tool, block: dict) -> bool:
+ engines = block.get('engines')
+ if engines and not isinstance(engines, str):
+ wanted = [str(item).lower().strip() for item in engines]
+ else:
+ wanted = [str(block.get('engine') or 'arxiv').lower().strip()]
+ live = [
+ str(item).lower().strip()
+ for item in (getattr(tool, '_engine_types', None) or [])
+ ]
+ if live != wanted:
+ return False
+ keys = getattr(tool, '_api_keys', None) or {}
+ fields = {
+ 'tavily': 'tavily_api_key',
+ 'exa': 'exa_api_key',
+ 'serpapi': 'serpapi_api_key',
+ }
+ for engine in wanted:
+ field = fields.get(engine)
+ if field and field in block:
+ if str(block.get(field) or '') != str(keys.get(engine) or ''):
+ return False
+ return True
+
+
+def _find_search_tool(agent):
+ manager = getattr(agent, 'tool_manager', None)
+ if manager is None:
+ return None
+ for tool in getattr(manager, 'extra_tools', None) or []:
+ if getattr(tool, 'SERVER_NAME', None) == 'web_search':
+ return tool
+ return None
+
+
+async def _sync_search(agent) -> None:
+ from ms_agent.config.search_settings import SearchSettingsManager
+ from ms_agent.project.paths import global_home
+
+ block = SearchSettingsManager(global_home()).raw_block()
+ token = _token(block)
+ if getattr(agent, '_search_fp', None) == token:
+ return
+ try:
+ OmegaConf.update(agent.config, 'tools.web_search', block, merge=True)
+ except Exception:
+ logger.debug('live search config update failed', exc_info=True)
+ return
+ tool = _find_search_tool(agent)
+ if tool is None or not getattr(tool, '_engines', None):
+ agent._search_fp = token
+ return
+ if getattr(agent, '_search_fp', None) is None and _search_in_sync(tool, block):
+ agent._search_fp = token
+ return
+ reload = getattr(tool, 'reload_from_config', None)
+ if reload is None:
+ agent._search_fp = token
+ return
+ try:
+ await reload()
+ except Exception:
+ logger.debug('live search reload failed', exc_info=True)
+ return
+ agent._search_fp = token
+
+
+async def _sync_mcp(agent) -> None:
+ runtime = getattr(agent, 'mcp_runtime', None)
+ if runtime is None:
+ return
+ from ms_agent.config.mcp_schema import ResolvedMCPConfig
+ from ms_agent.project.paths import global_home
+ from ms_agent.tui.managed_config import resolve_mcp_config
+ raw = resolve_mcp_config(
+ str(global_home()),
+ _work_dir(agent),
+ getattr(agent, 'mcp_server_file', None),
+ ) or {}
+ servers = dict((raw.get('mcpServers') or {}))
+ plugin = getattr(agent, '_plugin_runtime', None)
+ loaded = getattr(getattr(plugin, 'load_result', None), 'mcp_servers', None)
+ if isinstance(loaded, dict):
+ for name, entry in loaded.items():
+ servers.setdefault(name, entry)
+ token = _token(servers)
+ pending = set(getattr(agent, '_mcp_reconnect_names', None) or ())
+ # Config is unchanged. Still look at the live processes: a child may
+ # have exited, or a startup that began last turn may have finished.
+ # Neither of those is said in the chat. /mcp list carries the status.
+ if getattr(agent, '_mcp_fp', None) == token and not pending:
+ refresh = getattr(runtime, 'refresh_for_turn', None)
+ if refresh is not None:
+ try:
+ await refresh()
+ except Exception:
+ logger.debug('live mcp refresh failed', exc_info=True)
+ return
+ try:
+ await runtime.apply_config(ResolvedMCPConfig(mcp_servers=servers))
+ except Exception:
+ logger.debug('live mcp apply failed', exc_info=True)
+ if pending:
+ agent._mcp_reconnect_names = set()
+ agent._mcp_fp = token
+
+
+def _sync_skills(agent, messages) -> None:
+ runtime = getattr(agent, '_skill_runtime', None)
+ skills = getattr(agent.config, 'skills', None)
+ if runtime is None or not skills:
+ return
+ from ms_agent.project.paths import global_home
+ from ms_agent.tui.managed_config import merge_skills_into_config
+ try:
+ merge_skills_into_config(agent.config, str(global_home()),
+ _work_dir(agent))
+ changed = runtime.sync_with_config(agent.config.skills)
+ except Exception:
+ logger.debug('live skill sync failed', exc_info=True)
+ return
+ if not changed or not bool(getattr(skills, 'update_notice', False)):
+ return
+ if not messages or getattr(messages[-1], 'role', None) != 'user':
+ return
+ messages[-1].content = prepend_text(messages[-1].content, _SKILL_NOTICE)
+
+
+def _unregister_memory(agent) -> None:
+ from ms_agent.memory.unified.memory_tool import MemoryTool
+ manager = getattr(agent, 'tool_manager', None)
+ if manager is not None:
+ doomed = [
+ tool for tool in manager.extra_tools
+ if isinstance(tool, MemoryTool)
+ ]
+ manager.extra_tools = [
+ tool for tool in manager.extra_tools if tool not in doomed
+ ]
+ index = getattr(manager, '_tool_index', None) or {}
+ for key, val in list(index.items()):
+ if val and val[0] in doomed:
+ index.pop(key, None)
+ agent.memory_tools.clear()
+ agent._memory_guidance = ''
+
+
+async def _sync_memory(agent) -> None:
+ runtime = getattr(agent, 'runtime', None)
+ pending = bool(getattr(runtime, '_memory_pending', False))
+ work = _work_dir(agent)
+ if not work:
+ return
+ from ms_agent.personalization.memory_apply import apply_project_memory
+ from ms_agent.project import ProjectManager
+ from ms_agent.project.paths import global_home
+ project = ProjectManager(base_dir=str(global_home())).find_by_path(work)
+ if project is None:
+ return
+ kind = apply_project_memory(agent.config, project)
+ want = kind == 'file'
+ have = bool(getattr(agent, 'memory_tools', None))
+ if want == have:
+ if pending and runtime is not None:
+ runtime._memory_pending = False
+ return
+ if have:
+ _unregister_memory(agent)
+ try:
+ from ms_agent.memory.memory_manager import SharedMemoryManager
+ from ms_agent.project.paths import memory_dir
+ await SharedMemoryManager.close_matching(str(memory_dir(work)))
+ except Exception:
+ logger.debug('memory store close skipped', exc_info=True)
+ if want:
+ load = getattr(agent, 'load_memory', None)
+ if load is not None:
+ await load()
+ if pending:
+ if not getattr(agent, 'memory_tools', None):
+ _emit_once(agent, 'memory-start', '记忆没有启动。请检查记忆目录。')
+ if runtime is not None:
+ runtime._memory_pending = False
+
+
+async def apply_live_config(agent, messages=None) -> None:
+ """Refresh skills, MCP, the model client, and memory tools.
+
+ ``messages`` is the turn that is about to run. When it ends in a user
+ message, a skill change is prefixed there. Pass ``None`` before the
+ first ``create_messages`` so the frozen skill section includes edits
+ made at the opening prompt.
+ """
+ try:
+ _note_unreadable_config(agent)
+ _sync_skills(agent, messages)
+ await _sync_mcp(agent)
+ await _sync_search(agent)
+ await _sync_llm(agent)
+ await _sync_memory(agent)
+ except Exception:
+ logger.debug('live config sync failed', exc_info=True)
diff --git a/ms_agent/tui/renderer.py b/ms_agent/tui/renderer.py
index d491b6550..e0f0ad3f6 100644
--- a/ms_agent/tui/renderer.py
+++ b/ms_agent/tui/renderer.py
@@ -16,7 +16,7 @@
import re
import time
from contextlib import contextmanager
-from rich.console import Console
+from rich.console import Console, Group
from rich.markdown import Markdown
from rich.markup import escape
from rich.panel import Panel
@@ -29,6 +29,23 @@
from ms_agent.tui.tool_view import tool_header, tool_summary
from ms_agent.ui.events import AgentEvent
+_UNAVAILABLE = 'UNAVAILABLE'
+
+
+def _bold_unavailable(text: str) -> Text:
+ """Bold the word UNAVAILABLE. The rest of a command panel stays plain.
+
+ Command text is not markup, so a ``[bold]`` tag would print literally.
+ """
+ body = Text()
+ parts = re.split(f'({_UNAVAILABLE})', text or '')
+ for part in parts:
+ if part == _UNAVAILABLE:
+ body.append(part, style='bold')
+ else:
+ body.append(part)
+ return body
+
_KEY_LINE = re.compile(r'^\s*[\w.\-]+:(\s|$)')
@@ -122,15 +139,58 @@ def _print(self, *args, **kwargs) -> None:
self.console.print(*args, **kwargs)
def finalize(self) -> None:
- """Tear down any in-flight Live region (call on error / turn abort)."""
- if self._live is not None:
- try:
- self._live.stop()
- except Exception:
- pass
- self._live = None
+ """Stop any in-flight Live region (call on error / turn abort)."""
+ self.release_terminal()
+
+ def release_terminal(self) -> None:
+ """Stop the streaming region and wait until it can no longer write.
+
+ ``Live.stop`` only signals the refresh thread; it does not join it.
+ Joining here means the next owner of the terminal (the input prompt)
+ is the only writer. A reply that was still streaming is typeset once
+ below the title line already printed above the region.
+ """
+ buf = self._content_buf
self._content_buf = ''
self._label_shown = False
+ terminal = bool(self.console.is_terminal)
+ self._stop_live()
+ if terminal and buf.strip():
+ self.console.print(Markdown(buf))
+
+ def _stop_live(self) -> None:
+ live = self._live
+ if live is None:
+ return
+ self._live = None
+ # Grab the thread first: stop() drops the reference without joining.
+ thread = getattr(live, '_refresh_thread', None)
+ try:
+ live.stop()
+ except Exception:
+ pass
+ if thread is not None:
+ try:
+ thread.join()
+ except RuntimeError:
+ pass
+
+ def _stream_window(self, text: str):
+ """The last screen of ``text``.
+
+ Live's ``crop`` keeps the top of a renderable, so the renderable
+ itself has to be the tail. One screen (minus a row, so the refresh
+ does not scroll) is all that gets redrawn each frame.
+ """
+ width = max(1, self.console.size.width)
+ height = max(1, self.console.size.height - 1)
+ lines = list(Text(text).wrap(self.console, width))
+ tail = lines[-height:] if lines else []
+ if not tail:
+ return Text('')
+ if len(tail) == 1:
+ return tail[0]
+ return Group(*tail)
# ── assistant content (streamed) ───────────────────────────────────────
@@ -148,31 +208,31 @@ def _on_content_delta(self, ev) -> None:
if self.console.is_terminal:
if self._live is None:
from rich.live import Live
+ # One screen, erased when the reply finishes. stdout stays
+ # ours so this thread cannot still be writing when the prompt
+ # switches the terminal into raw / bracketed-paste mode.
self._live = Live(
console=self.console,
refresh_per_second=12,
- vertical_overflow='visible')
+ transient=True,
+ vertical_overflow='crop',
+ redirect_stdout=False,
+ redirect_stderr=False)
self._live.start()
- # Stream as plain Text (tolerant of half-formed markdown); the
- # final ContentEnd reflows once into formatted Markdown.
- self._live.update(Text(self._content_buf))
+ # Plain text while tokens are still arriving; ContentEnd typesets
+ # the whole reply once, after this region has stopped.
+ self._live.update(self._stream_window(self._content_buf))
else:
# Non-terminal (piped / CI): write raw for clean, scriptable output.
self.console.file.write(ev.text)
self.console.file.flush()
def _on_content_end(self, ev) -> None:
- buf = self._content_buf
- if self._live is not None:
- renderable = Markdown(buf) if buf.strip() else Text('')
- self._live.update(renderable)
- self._live.stop()
- self._live = None
- elif not self.console.is_terminal:
+ terminal = bool(self.console.is_terminal)
+ self.release_terminal()
+ if not terminal:
self.console.file.write('\n')
self.console.file.flush()
- self._content_buf = ''
- self._label_shown = False
self._last_kind = None # next turn starts fresh (input divider spaces it)
# ── reasoning (collapsed dim block) ─────────────────────────────────────
@@ -268,7 +328,7 @@ def _on_notice(self, ev) -> None:
word_wrap=True,
background_color='default')
else:
- body = Text(ev.text)
+ body = _bold_unavailable(ev.text)
self._print(Panel(body, border_style='dim', expand=False))
return
style = {
diff --git a/ms_agent/tui/select.py b/ms_agent/tui/select.py
index a86eb0a66..1c0116c1e 100644
--- a/ms_agent/tui/select.py
+++ b/ms_agent/tui/select.py
@@ -17,6 +17,21 @@
from typing import List, Optional, Sequence
+_sigint_rearm = None
+
+
+def bind_sigint_rearm(callback) -> None:
+ """Put the TUI's SIGINT handler back after an arrow menu.
+
+ prompt_toolkit's Application removes the loop handler when the menu
+ exits. A permission menu runs in the middle of a turn, before the next
+ prompt re-arms that handler, so Ctrl-C would be ignored until the user
+ submits another line.
+ """
+ global _sigint_rearm
+ _sigint_rearm = callback
+
+
async def select_async(options: Sequence[str],
*,
default: int = 0,
@@ -112,7 +127,16 @@ def _render():
erase_when_done=True,
mouse_support=False,
)
- return await app.run_async()
+ try:
+ # Do not let the menu replace the TUI's SIGINT handler. Ctrl-C in
+ # the menu is the c-c key binding above. handle_sigint=True would
+ # remove the loop handler on the way out, and Ctrl-C would do
+ # nothing for the rest of the turn.
+ return await app.run_async(handle_sigint=False)
+ finally:
+ rearm = _sigint_rearm
+ if rearm is not None:
+ rearm()
async def _fallback_numeric(n: int) -> Optional[int]:
diff --git a/ms_agent/utils/constants.py b/ms_agent/utils/constants.py
index 4182cb148..d2e22e5ea 100644
--- a/ms_agent/utils/constants.py
+++ b/ms_agent/utils/constants.py
@@ -1,6 +1,10 @@
from dataclasses import dataclass
from typing import Dict, Optional
+# Chat model id examples and project defaults point at. If inference
+# returns 400, pick another current chat model. Do not pin a retired id.
+DEFAULT_MODELSCOPE_CHAT_MODEL = 'Qwen/Qwen3.8-Flash-Next'
+
# The default output dir when explicitly referenced in legacy configs.
# When ``output_dir`` is omitted, ``resolve_workspace_root()`` uses cwd instead.
DEFAULT_OUTPUT_DIR = './output'
diff --git a/ms_agent/utils/history_secrets.py b/ms_agent/utils/history_secrets.py
new file mode 100644
index 000000000..05218374c
--- /dev/null
+++ b/ms_agent/utils/history_secrets.py
@@ -0,0 +1,121 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Keep API keys and MCP credentials out of the on-disk history yaml.
+
+The yaml under ``/.ms_agent/memory/`` is a turn cache, not the
+credential store. Keys stay in ``~/.ms_agent/settings.json`` and MCP headers
+stay in ``mcp.json``. A legacy ``read_history`` that installs the yaml as the
+live config fills those values back in from those files.
+"""
+from __future__ import annotations
+
+from typing import Optional
+
+
+def blank_config_secrets(node) -> None:
+ """Blank secret values in a plain config tree. Never writes ``***``."""
+ from ms_agent.agent_hub._workspace import (
+ SECRET_BAG_KEYS,
+ is_secret_key,
+ scrub_url_secrets,
+ )
+ from ms_agent.command.usage import contains_secret
+
+ if isinstance(node, dict):
+ for key, val in list(node.items()):
+ name = str(key)
+ if name in SECRET_BAG_KEYS and isinstance(val, dict):
+ node[key] = {
+ inner: ('' if _bag_value_is_secret(inner, inner_val,
+ contains_secret,
+ is_secret_key)
+ else inner_val)
+ for inner, inner_val in val.items()
+ }
+ continue
+ if is_secret_key(name) and not isinstance(val, (dict, list)):
+ if val not in (None, ''):
+ node[key] = ''
+ continue
+ if isinstance(val, str):
+ node[key] = _blank_secret_text(
+ val, contains_secret, scrub_url_secrets)
+ else:
+ blank_config_secrets(val)
+ elif isinstance(node, list):
+ for index, item in enumerate(node):
+ if isinstance(item, str):
+ node[index] = _blank_secret_text(
+ item, contains_secret, scrub_url_secrets)
+ else:
+ blank_config_secrets(item)
+
+
+def config_for_history_disk(config):
+ """Copy ``config`` with secrets removed. The caller's object is unchanged."""
+ from omegaconf import OmegaConf
+
+ try:
+ data = OmegaConf.to_container(config, resolve=False)
+ except Exception:
+ return config
+ if not isinstance(data, dict):
+ return config
+ blank_config_secrets(data)
+ try:
+ return OmegaConf.create(data)
+ except Exception:
+ return config
+
+
+def refill_history_config(config, work_dir: Optional[str]) -> None:
+ """Put keys and MCP headers back after a stripped yaml is loaded.
+
+ ``overwrite`` so an emptied ``api_key`` does not hide the value in
+ settings.json. MCP is replaced from mcp.json so a blanked
+ ``Authorization`` does not stick.
+ """
+ if config is None:
+ return
+ try:
+ from ms_agent.tui.app import TuiApp
+ TuiApp._apply_provider_credentials(config, overwrite=True)
+ except Exception:
+ pass
+ try:
+ from omegaconf import OmegaConf
+
+ from ms_agent.config.resolver import ConfigResolver
+ from ms_agent.project.paths import global_home
+
+ fresh = OmegaConf.create({})
+ ConfigResolver(
+ global_home(), project_root=work_dir)._merge_mcp(fresh, work_dir)
+ merged = OmegaConf.select(fresh, '_merged_mcp', default=None)
+ if merged:
+ OmegaConf.update(
+ config,
+ '_merged_mcp',
+ OmegaConf.to_container(merged, resolve=False),
+ merge=False,
+ )
+ except Exception:
+ pass
+
+
+def _bag_value_is_secret(name, value, contains_secret, is_secret_key) -> bool:
+ if value in (None, ''):
+ return False
+ if is_secret_key(str(name)):
+ return True
+ return contains_secret(str(value))
+
+
+def _blank_secret_text(value, contains_secret, scrub_url_secrets) -> str:
+ if not value:
+ return value
+ cleaned = scrub_url_secrets(value)
+ if cleaned != value:
+ return cleaned
+ if contains_secret(value) and ' ' not in value and '\n' not in value:
+ return ''
+ return value
diff --git a/ms_agent/utils/utils.py b/ms_agent/utils/utils.py
index 3fdbc9fd0..4c15c31a3 100644
--- a/ms_agent/utils/utils.py
+++ b/ms_agent/utils/utils.py
@@ -203,8 +203,10 @@ def save_history(output_dir: str, task: str, config: DictConfig,
os.makedirs(cache_dir, exist_ok=True)
config_file = os.path.join(cache_dir, f'{task}.yaml')
message_file = os.path.join(cache_dir, f'{task}.json')
+ # The yaml is a turn cache. Keys stay in settings.json / mcp.json.
+ from .history_secrets import config_for_history_disk
with open(config_file, 'w') as f:
- OmegaConf.save(config, f)
+ OmegaConf.save(config_for_history_disk(config), f)
with open(message_file, 'w') as f:
json.dump([message.to_dict() for message in messages],
f,
diff --git a/projects/deep_research/README.md b/projects/deep_research/README.md
index 49a0213dd..64d2e301b 100644
--- a/projects/deep_research/README.md
+++ b/projects/deep_research/README.md
@@ -158,12 +158,12 @@ if __name__ == '__main__':
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
)
# Get web-search engine client
@@ -254,12 +254,12 @@ if __name__ == "__main__":
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
generation_config={'extra_body': {
'enable_thinking': False
}})
diff --git a/projects/deep_research/README_zh.md b/projects/deep_research/README_zh.md
index 136a16a20..3ade79ed9 100644
--- a/projects/deep_research/README_zh.md
+++ b/projects/deep_research/README_zh.md
@@ -158,12 +158,12 @@ if __name__ == '__main__':
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
)
# Get web-search engine client
@@ -254,12 +254,12 @@ if __name__ == "__main__":
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model='Qwen/Qwen3.8-Flash-Next',
generation_config={'extra_body': {
'enable_thinking': False
}})
diff --git a/projects/deep_research/run.py b/projects/deep_research/run.py
index d2094fdc2..c263e1c96 100644
--- a/projects/deep_research/run.py
+++ b/projects/deep_research/run.py
@@ -2,6 +2,7 @@
import asyncio
from ms_agent.llm.openai import OpenAIChat
+from ms_agent.utils.constants import DEFAULT_MODELSCOPE_CHAT_MODEL
from ms_agent.tools.search.search_base import SearchEngine
from ms_agent.tools.search_engine import get_web_search_tool
from ms_agent.workflow.deep_research.principle import MECEPrinciple
@@ -104,12 +105,12 @@ def run_deep_workflow(user_prompt: str,
"""
* `api_key` (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to https://modelscope.cn/my/myaccesstoken # noqa
* `base_url`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
- * `model`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+ * `model`: (str), the model ID for inference. `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
"""
chat_client = OpenAIChat(
api_key='xxx-xxx',
base_url='https://api-inference.modelscope.cn/v1/',
- model='Qwen/Qwen3-235B-A22B-Instruct-2507',
+ model=DEFAULT_MODELSCOPE_CHAT_MODEL,
generation_config={'extra_body': {
'enable_thinking': False
}})
diff --git a/projects/doc_research/README.md b/projects/doc_research/README.md
index 8400eb0de..ad2cb7e8b 100644
--- a/projects/doc_research/README.md
+++ b/projects/doc_research/README.md
@@ -69,12 +69,12 @@ pip install 'ms-agent[research]'
```bash
export OPENAI_API_KEY=xxx-xxx
export OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1/
-export OPENAI_MODEL_ID=Qwen/Qwen3-235B-A22B-Instruct-2507
+export OPENAI_MODEL_ID=Qwen/Qwen3.8-Flash-Next
```
* `OPENAI_API_KEY`: (str), your API key, replace `xxx-xxx` with your actual key. Alternatively, you can use ModelScope API key, refer to [ModelScopeAccessToken](https://modelscope.cn/my/myaccesstoken)
* `OPENAI_BASE_URL`: (str), the base URL for API requests, `https://api-inference.modelscope.cn/v1/` for ModelScope API-Inference
-* `OPENAI_MODEL_ID`: (str), the model ID for inference, `Qwen/Qwen3-235B-A22B-Instruct-2507` can be recommended for document research tasks.
+* `OPENAI_MODEL_ID`: (str), the model ID for inference, `Qwen/Qwen3.8-Flash-Next` is a current chat model. If the service returns 400, choose another chat model it serves now.
### 3. Run Application
diff --git a/projects/doc_research/README_zh.md b/projects/doc_research/README_zh.md
index 3138b3ccc..88757d752 100644
--- a/projects/doc_research/README_zh.md
+++ b/projects/doc_research/README_zh.md
@@ -70,12 +70,12 @@ pip install 'ms-agent[research]'
```bash
export OPENAI_API_KEY=xxx-xxx
export OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1/
-export OPENAI_MODEL_ID=Qwen/Qwen3-235B-A22B-Instruct-2507
+export OPENAI_MODEL_ID=Qwen/Qwen3.8-Flash-Next
```
* `OPENAI_API_KEY`: (str), API key, 替换 `xxx-xxx`,或使用魔搭ModelScope提供的API key,参考 [ModelScopeAccessToken](https://modelscope.cn/my/myaccesstoken)
* `OPENAI_BASE_URL`: (str), base url, 或使用`ModelScope API-Inference`:`https://api-inference.modelscope.cn/v1/`
-* `OPENAI_MODEL_ID`: (str), model id or name, 推荐使用`Qwen/Qwen3-235B-A22B-Instruct-2507`执行复杂研究任务
+* `OPENAI_MODEL_ID`: (str), model id or name, 推荐使用`Qwen/Qwen3.8-Flash-Next`。如果接口返回 400,请换一个当前可用的对话模型
### 3. 运行应用
diff --git a/projects/fin_research/searcher.py b/projects/fin_research/searcher.py
index 543227ad1..a3c58345e 100644
--- a/projects/fin_research/searcher.py
+++ b/projects/fin_research/searcher.py
@@ -9,6 +9,7 @@
from ms_agent.llm.openai import OpenAIChat
from ms_agent.tools.search_engine import get_web_search_tool
from ms_agent.utils import get_logger
+from ms_agent.utils.constants import DEFAULT_MODELSCOPE_CHAT_MODEL
from ms_agent.workflow.deep_research.research_workflow_beta import \
ResearchWorkflowBeta
@@ -35,7 +36,7 @@ def __init__(self,
base_url = getattr(llm_config, 'openai_base_url',
'') or os.getenv('OPENAI_BASE_URL')
model = getattr(llm_config, 'model',
- '') or 'Qwen/Qwen3-235B-A22B-Instruct-2507'
+ '') or DEFAULT_MODELSCOPE_CHAT_MODEL
self.chat_client = OpenAIChat(
api_key=api_key, base_url=base_url, model=model)
else:
diff --git a/requirements/framework.txt b/requirements/framework.txt
index 575530bcb..0f7c30be0 100644
--- a/requirements/framework.txt
+++ b/requirements/framework.txt
@@ -5,7 +5,7 @@ httpx>=0.28.1
json5
markdown
matplotlib
-mcp<2
+mcp
modelscope>=1.35.2
modelscope-hub>=0.4.0
numpy
diff --git a/requirements/research.txt b/requirements/research.txt
index 26ab623d5..67ce2d3fd 100644
--- a/requirements/research.txt
+++ b/requirements/research.txt
@@ -6,7 +6,7 @@ google-search-results
gradio>=5.0.0
json5
markdown
-mcp<2
+mcp
modelscope
openai
pandas
diff --git a/setup.py b/setup.py
index a8701dbc7..c11c56f55 100644
--- a/setup.py
+++ b/setup.py
@@ -253,6 +253,7 @@ def run(self):
# get_defaults() returns {} and cross-framework convert
# silently degrades to a raw file copy.
'agent_hub/default_configs/**/*',
+ 'skills/**/*',
'webui/**/*',
# Capability Gateway skill package (copied into ms_agent/skills at build).
'skills/**/*',
diff --git a/tests/agent_hub/test_secrets.py b/tests/agent_hub/test_secrets.py
index 3c72ddb89..523b8fb84 100644
--- a/tests/agent_hub/test_secrets.py
+++ b/tests/agent_hub/test_secrets.py
@@ -58,6 +58,10 @@ def test_every_vendor_prefix_is_redacted(self):
("huggingface", "hf_XyZ1a2B3c4D5e6F7g8H9"),
("npm", "npm_XyZ1a2B3c4D5e6F7g8H9"),
("shopify", "shpat_XyZ1a2B3c4D5e6F7g8H9"),
+ ("tavily", "tvly-THIS-IS-MY-SECRET"),
+ ("modelscope", "ms-9f3c7ab2e81d04a6"),
+ ("modelscope_uuid",
+ "ms-8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8"),
)
for label, token in samples:
with self.subTest(vendor=label):
@@ -78,6 +82,24 @@ def test_bearer_header_in_curl(self):
self.assertNotIn(SK_KEY, out)
self.assertTrue(hits)
+ def test_tavily_and_modelscope_prefixes_alone(self):
+ """A Got: line may contain only the token, with no secret-shaped word."""
+ for token in ("tvly-THIS-IS-MY-SECRET", "ms-9f3c7ab2e81d04a6"):
+ with self.subTest(token=token[:8]):
+ out, hits = redact_text(token)
+ self.assertNotIn(token, out)
+ self.assertIn("[REDACTED:api_key]", out)
+ self.assertTrue(hits)
+
+ def test_ms_product_names_are_not_keys(self):
+ for text in ("ms-agent-skills", "ms-agent-webui-frontend",
+ "use ms-agent-align-e2e for the run",
+ "ms-agent_20260726_101010"):
+ with self.subTest(text=text):
+ out, hits = redact_text(text)
+ self.assertEqual(out, text)
+ self.assertEqual(hits, ())
+
def test_sk_prefix_needs_a_left_boundary(self):
"""``sk-`` inside an ordinary word is not a credential."""
for text in ("task-tracking pipeline", "ping",
@@ -154,6 +176,8 @@ class TestDocumentedPlaceholdersSurvive(unittest.TestCase):
"See https://example.com/docs?api_key=&model=qwen3-max",
# placeholder spellings
"OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx",
+ "TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxx",
+ "MODELSCOPE_API_KEY=ms-xxxxxxxxxxxxxxxx",
"api_key: sk-your-key-here",
'token = "changeme123456"',
# data fields that are NOT credentials
diff --git a/tests/cli/test_cli_entry.py b/tests/cli/test_cli_entry.py
index 3acb6a41b..2e2884dbb 100644
--- a/tests/cli/test_cli_entry.py
+++ b/tests/cli/test_cli_entry.py
@@ -11,6 +11,8 @@
from ms_agent.cli.agent import AgentCMD
from ms_agent.cli.cli import run_cmd
+from ms_agent.cli.tui import TuiCMD
+from ms_agent.cli.ui import UICMD
def test_agent_upload_positional_path_is_rejected(monkeypatch, capsys):
@@ -80,3 +82,49 @@ def test_non_agent_command_keeps_unknown_args(monkeypatch):
run_cmd()
assert dispatched.get('ok')
+
+
+def test_bare_ms_agent_starts_tui(monkeypatch):
+ monkeypatch.setattr(sys, 'argv', ['ms-agent'])
+ dispatched = {}
+ monkeypatch.setattr(
+ TuiCMD, 'execute', lambda self: dispatched.setdefault('ok', True))
+
+ run_cmd()
+ assert dispatched.get('ok')
+
+
+def test_bare_options_are_tui_options(monkeypatch):
+ monkeypatch.setattr(sys, 'argv', ['ms-agent', '--work-dir', '/tmp/work'])
+ seen = {}
+
+ def _execute(self):
+ seen['work_dir'] = self.args.work_dir
+
+ monkeypatch.setattr(TuiCMD, 'execute', _execute)
+ run_cmd()
+ assert seen['work_dir'] == '/tmp/work'
+
+
+def test_explicit_ui_is_not_rewritten_to_tui(monkeypatch):
+ monkeypatch.setattr(sys, 'argv', ['ms-agent', 'ui'])
+ dispatched = {}
+ monkeypatch.setattr(
+ UICMD, 'execute', lambda self: dispatched.setdefault('ui', True))
+ monkeypatch.setattr(
+ TuiCMD, 'execute', lambda self: dispatched.setdefault('tui', True))
+
+ run_cmd()
+ assert dispatched == {'ui': True}
+
+
+def test_unknown_word_still_prints_help(monkeypatch):
+ monkeypatch.setattr(sys, 'argv', ['ms-agent', 'not-a-command'])
+ monkeypatch.setattr(
+ TuiCMD, 'execute',
+ lambda self: (_ for _ in ()).throw(
+ AssertionError('tui must not start')))
+
+ with pytest.raises(SystemExit) as exc_info:
+ run_cmd()
+ assert exc_info.value.code == 2
diff --git a/tests/command/test_builtin.py b/tests/command/test_builtin.py
index 63da124b7..e432297c3 100644
--- a/tests/command/test_builtin.py
+++ b/tests/command/test_builtin.py
@@ -82,6 +82,11 @@ async def test_help_lists_commands(self, router):
assert result.type == CommandResultType.MESSAGE
assert '/stop' in result.content
assert '/help' in result.content
+ assert '/mcp' in result.content
+ assert '/skills' in result.content
+ assert '/search' in result.content
+ assert '/instruction' in result.content
+ assert '/profile' in result.content
@pytest.mark.asyncio
async def test_help_alias(self, router):
diff --git a/tests/command/test_instruction_cmds.py b/tests/command/test_instruction_cmds.py
new file mode 100644
index 000000000..fab4ac57b
--- /dev/null
+++ b/tests/command/test_instruction_cmds.py
@@ -0,0 +1,134 @@
+"""TUI /instruction and /profile write the same files WebUI settings use."""
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.command.builtin import register_builtin_commands
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import CommandContext
+from ms_agent.prompting import workspace_files as wf
+
+
+def make_router():
+ router = CommandRouter()
+ register_builtin_commands(router)
+ return router
+
+
+def make_ctx(text, runtime=None):
+ router = make_router()
+ cmd, args = CommandRouter.parse_input(text)
+ return CommandContext(
+ raw_input=text,
+ command_name=cmd,
+ args=args,
+ source='cli',
+ runtime=runtime,
+ extra={'router': router},
+ )
+
+
+def _runtime(work):
+ return type('R', (), {
+ 'config': OmegaConf.create({'output_dir': str(work)}),
+ })()
+
+
+@pytest.fixture(autouse=True)
+def isolate_home(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ wf.reset_cache()
+ yield home
+ wf.reset_cache()
+
+
+class TestInstructionCommand:
+ @pytest.mark.asyncio
+ async def test_show_empty(self, tmp_path):
+ runtime = _runtime(tmp_path / 'work')
+ (tmp_path / 'work').mkdir()
+ result = await make_router().dispatch(make_ctx('/instruction', runtime))
+ assert 'Global' in result.content
+ assert 'Project' in result.content
+ assert '(empty)' in result.content
+
+ @pytest.mark.asyncio
+ async def test_global_writes_agents_md(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx('/instruction global Always answer in French.'))
+ assert 'saved' in result.content.lower()
+ text = (isolate_home / 'AGENTS.md').read_text(encoding='utf-8')
+ assert 'Always answer in French.' in text
+ assert text.lstrip().startswith('---')
+ shown = await make_router().dispatch(make_ctx('/instruction global'))
+ assert 'Always answer in French.' in shown.content
+
+ @pytest.mark.asyncio
+ async def test_project_writes_private_slot_only(self, tmp_path):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ root = work / 'AGENTS.md'
+ root.write_text('# team\nkeep me\n', encoding='utf-8')
+ result = await make_router().dispatch(
+ make_ctx('/instruction project Use FastAPI.', _runtime(work)))
+ assert 'saved' in result.content.lower()
+ private = work / '.ms_agent' / 'AGENTS.md'
+ assert private.read_text(encoding='utf-8').strip() == 'Use FastAPI.'
+ assert root.read_text(encoding='utf-8') == '# team\nkeep me\n'
+
+ @pytest.mark.asyncio
+ async def test_project_clear(self, tmp_path):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ await make_router().dispatch(
+ make_ctx('/instruction project hello', _runtime(work)))
+ result = await make_router().dispatch(
+ make_ctx('/instruction project clear', _runtime(work)))
+ assert 'cleared' in result.content.lower()
+ assert (work / '.ms_agent' / 'AGENTS.md').read_text(
+ encoding='utf-8').strip() == ''
+
+ @pytest.mark.asyncio
+ async def test_project_requires_work_dir(self):
+ result = await make_router().dispatch(
+ make_ctx('/instruction project hello'))
+ assert 'work dir' in result.content.lower()
+
+ @pytest.mark.asyncio
+ async def test_alias_ins(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx('/ins global Be brief.'))
+ assert 'saved' in result.content.lower()
+ assert 'Be brief.' in (isolate_home / 'AGENTS.md').read_text('utf-8')
+
+
+class TestProfileCommand:
+ @pytest.mark.asyncio
+ async def test_callme_and_about(self, isolate_home):
+ await make_router().dispatch(make_ctx('/profile callme Alice'))
+ result = await make_router().dispatch(
+ make_ctx('/profile about I work on agents.'))
+ assert 'saved' in result.content.lower()
+ text = (isolate_home / 'PROFILE.md').read_text(encoding='utf-8')
+ assert '- Call me: Alice' in text
+ assert 'I work on agents.' in text
+ shown = await make_router().dispatch(make_ctx('/profile'))
+ assert 'Alice' in shown.content
+ assert 'I work on agents.' in shown.content
+
+ @pytest.mark.asyncio
+ async def test_callme_clear_keeps_about(self, isolate_home):
+ await make_router().dispatch(make_ctx('/profile callme Alice'))
+ await make_router().dispatch(make_ctx('/profile about researcher'))
+ await make_router().dispatch(make_ctx('/profile callme clear'))
+ call_me, about = wf.read_profile()
+ assert call_me == ''
+ assert about == 'researcher'
+
+ @pytest.mark.asyncio
+ async def test_quoted_about(self, isolate_home):
+ await make_router().dispatch(
+ make_ctx('/profile about "line one and two"'))
+ _, about = wf.read_profile()
+ assert about == 'line one and two'
diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py
index 334670362..2a2e6768b 100644
--- a/tests/command/test_interactive_input.py
+++ b/tests/command/test_interactive_input.py
@@ -37,6 +37,39 @@ async def test_plain_prompt_submitted(self):
assert turn.action == 'submit'
assert turn.text == 'research quantum computing'
+ @pytest.mark.asyncio
+ async def test_mutate_state_refreshes_status_model(self):
+ class Src:
+ def __init__(self):
+ self.model = 'old'
+ self._lines = iter(['/switch', 'task'])
+
+ def note_model(self, model):
+ self.model = model
+
+ async def read_prompt(self, prompt='>>> '):
+ return next(self._lines)
+
+ class LLM:
+ model = 'old'
+ config = None
+
+ runtime = type('RT', (), {'llm': LLM()})()
+
+ async def switch(ctx):
+ ctx.runtime.llm.model = 'gpt-4o'
+ return CommandResult(
+ type=CommandResultType.MUTATE_STATE, content='Switched')
+
+ router = CommandRouter()
+ router.register(CommandDef(name='switch', description='x'), switch)
+ src = Src()
+ session = InteractiveSession(router, input_source=src)
+ turn = await session.run_turn(runtime=runtime)
+ assert src.model == 'gpt-4o'
+ assert turn.action == 'submit'
+ assert turn.text == 'task'
+
@pytest.mark.asyncio
async def test_info_command_then_prompt(self):
# /help shows output and re-prompts; the next plain line is the task.
@@ -122,6 +155,20 @@ async def submit(ctx):
assert turn.action == 'submit'
assert turn.text == 'expanded prompt'
+ @pytest.mark.asyncio
+ async def test_command_exception_stays_in_loop(self):
+ async def boom(ctx):
+ raise TypeError('__class__ assignment only supported for mutable types')
+
+ router = CommandRouter()
+ router.register(CommandDef(name='boom', description='x'), boom)
+ session = InteractiveSession(router)
+ inputs = iter(['/boom', 'still here'])
+ with patch('builtins.input', lambda *a: next(inputs)):
+ turn = await session.run_turn()
+ assert turn.action == 'submit'
+ assert turn.text == 'still here'
+
def _make_agent(config=None):
"""Build an LLMAgent without running its heavy __init__."""
@@ -211,3 +258,233 @@ def test_interactive_auto_adds_when_not_listed(self):
cbs = self._input_callbacks(agent)
assert len(cbs) == 1
assert cbs[0]._session._router is agent._get_command_router()
+
+
+class TestEnsureLlmReady:
+ def _agent(self):
+ from ms_agent.agent.runtime import Runtime
+ from ms_agent.command import CommandRouter, register_builtin_commands
+ agent = _make_agent({'llm': {'model': 'm'}})
+ agent._interactive = True
+ agent._input_source = None
+ agent._event_sink = None
+ agent._pending_attachments = None
+ agent.runtime = Runtime(llm=None)
+ router = CommandRouter()
+ register_builtin_commands(router)
+ agent._get_command_router = lambda: router
+ agent._stub_llm_for_setup()
+ return agent
+
+ @pytest.mark.asyncio
+ async def test_quit_sets_should_stop(self):
+ agent = self._agent()
+
+ def boom():
+ raise ValueError('No API key found for provider "modelscope"')
+
+ agent.prepare_llm = boom
+ with patch('builtins.input', return_value='/quit'):
+ result = await agent._ensure_llm_ready('hello')
+ assert result is None
+ assert agent.runtime.should_stop is True
+
+ @pytest.mark.asyncio
+ async def test_retries_after_prompt(self):
+ from types import SimpleNamespace
+ agent = self._agent()
+ n = {'i': 0}
+
+ def maybe():
+ n['i'] += 1
+ if n['i'] < 2:
+ raise ValueError('No API key found for provider "modelscope"')
+ agent.llm = SimpleNamespace(config=agent.config, model='m')
+
+ agent.prepare_llm = maybe
+ with patch('builtins.input', return_value='ping'):
+ result = await agent._ensure_llm_ready('first')
+ assert result == 'ping'
+ assert n['i'] == 2
+ assert not getattr(agent.llm, '_setup_stub', False)
+
+ @pytest.mark.asyncio
+ async def test_non_interactive_still_raises(self):
+ agent = self._agent()
+ agent._interactive = False
+
+ def boom():
+ raise ValueError('No API key found for provider "modelscope"')
+
+ agent.prepare_llm = boom
+ with pytest.raises(ValueError, match='No API key found'):
+ await agent._ensure_llm_ready('hi')
+
+ @pytest.mark.asyncio
+ async def test_key_command_retries_the_sentence_that_asked(self):
+ from types import SimpleNamespace
+ agent = self._agent()
+ attempts = {'n': 0}
+
+ def maybe():
+ attempts['n'] += 1
+ if attempts['n'] < 2:
+ raise ValueError('No API key found for provider "modelscope"')
+ agent.llm = SimpleNamespace(config=agent.config, model='m')
+
+ agent.prepare_llm = maybe
+ agent._llm_credentials_present = lambda: True
+ seen = []
+
+ class _Sink:
+ def emit(self, event):
+ seen.append(event.text)
+
+ agent._event_sink = _Sink()
+
+ def _input(prompt=''):
+ if attempts.get('reads', 0):
+ raise AssertionError('prompted for another line')
+ attempts['reads'] = 1
+ return '/help'
+
+ with patch('builtins.input', side_effect=_input):
+ result = await agent._ensure_llm_ready('hello')
+ assert result == 'hello'
+ assert attempts['n'] == 2
+ assert seen[-1] == '继续发送未完成的消息。'
+
+ def test_setup_notice_is_printed_once_per_process(self):
+ import ms_agent.agent.llm_agent as mod
+ mod._credential_notice_sent = False
+ agent = self._agent()
+ seen = []
+
+ class _Sink:
+ def emit(self, event):
+ seen.append(event.text)
+
+ agent._event_sink = _Sink()
+ err = ValueError('No API key found for provider "modelscope"')
+ try:
+ agent._emit_credential_setup(err)
+ agent._emit_credential_setup(err)
+ assert len(seen) == 1
+ assert seen[0] == (
+ '还没有 modelscope 的密钥。\n\n'
+ ' /model provider key modelscope <密钥>')
+ assert 'No API key found' not in seen[0]
+ finally:
+ mod._credential_notice_sent = False
+
+ def test_built_client_rearms_the_setup_notice(self, monkeypatch):
+ from types import SimpleNamespace
+ import ms_agent.agent.llm_agent as mod
+ mod._credential_notice_sent = True
+ agent = self._agent()
+ monkeypatch.setattr(
+ mod.LLM, 'from_config',
+ lambda config: SimpleNamespace(config=config, model='m'))
+ monkeypatch.setattr(
+ 'ms_agent.llm.runtime_token.llm_runtime_token',
+ lambda config: 'tok')
+ try:
+ agent.prepare_llm()
+ assert mod._credential_notice_sent is False
+ seen = []
+
+ class _Sink:
+ def emit(self, event):
+ seen.append(event.text)
+
+ agent._event_sink = _Sink()
+ err = ValueError('No API key found for provider "modelscope"')
+ agent._emit_credential_setup(err)
+ agent._emit_credential_setup(err)
+ assert seen == [(
+ '还没有 modelscope 的密钥。\n\n'
+ ' /model provider key modelscope <密钥>')]
+ finally:
+ mod._credential_notice_sent = False
+
+ @pytest.mark.asyncio
+ async def test_cleared_key_keeps_the_next_line_not_the_one_that_arrived(self):
+ # Same wait as a session that never had a key: the line that found
+ # no client is dropped, the setup notice is emitted, and the next
+ # submitted line is what will be sent.
+ from types import SimpleNamespace
+ from ms_agent.llm.utils import Message
+ agent = self._agent()
+ notices = []
+ agent._emit_credential_setup = lambda exc: notices.append(str(exc))
+ attempts = {'n': 0}
+
+ def maybe():
+ attempts['n'] += 1
+ if attempts['n'] < 2:
+ raise ValueError(
+ 'No API key found for provider "modelscope"')
+ agent.llm = SimpleNamespace(config=agent.config, model='m')
+
+ agent.prepare_llm = maybe
+ bound = {}
+
+ class _Mem:
+ def set_llm(self, llm):
+ bound['llm'] = llm
+
+ agent.memory_tools = [_Mem()]
+ messages = [
+ Message(role='assistant', content='earlier'),
+ Message(role='user', content='hi'),
+ ]
+ with patch('builtins.input', return_value='ping'):
+ await agent._hold_turn_until_llm_ready(messages, 1)
+ assert [m.content for m in messages] == ['earlier', 'ping']
+ assert notices and 'No API key found' in notices[0]
+ assert agent.runtime.should_stop is False
+ assert bound['llm'] is agent.llm
+ assert not getattr(agent.llm, '_setup_stub', False)
+
+ @pytest.mark.asyncio
+ async def test_cleared_key_quit_drops_the_unsent_line(self):
+ from ms_agent.llm.utils import Message
+ agent = self._agent()
+
+ def boom():
+ raise ValueError('No API key found for provider "modelscope"')
+
+ agent.prepare_llm = boom
+ messages = [
+ Message(role='assistant', content='earlier'),
+ Message(role='user', content='hi'),
+ ]
+ with patch('builtins.input', return_value='/quit'):
+ await agent._hold_turn_until_llm_ready(messages, 1)
+ assert [m.content for m in messages] == ['earlier']
+ assert agent.runtime.should_stop is True
+
+ @pytest.mark.asyncio
+ async def test_hold_sends_the_arrived_line_when_a_client_builds(self):
+ from types import SimpleNamespace
+ from ms_agent.llm.utils import Message
+ agent = self._agent()
+ original = Message(role='user', content='hi')
+ messages = [Message(role='assistant', content='earlier'), original]
+
+ def ok():
+ agent.llm = SimpleNamespace(config=agent.config, model='m')
+
+ agent.prepare_llm = ok
+ await agent._hold_turn_until_llm_ready(messages, 1)
+ assert messages[-1] is original
+
+ @pytest.mark.asyncio
+ async def test_hold_leaves_a_live_turn_alone(self):
+ from types import SimpleNamespace
+ from ms_agent.llm.utils import Message
+ agent = self._agent()
+ agent.llm = SimpleNamespace(model='m')
+ messages = [Message(role='user', content='hi')]
+ await agent._hold_turn_until_llm_ready(messages, 0)
+ assert messages[0].content == 'hi'
diff --git a/tests/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py
new file mode 100644
index 000000000..5d0873e69
--- /dev/null
+++ b/tests/command/test_memory_cmds.py
@@ -0,0 +1,184 @@
+"""TUI /memory writes PersonalizationSettings + project flags like WebUI."""
+import json
+from types import SimpleNamespace
+
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.command.builtin import register_builtin_commands
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import CommandContext
+from ms_agent.personalization.memory_apply import apply_project_memory
+from ms_agent.personalization.settings import PersonalizationSettings
+from ms_agent.personalization.types import PersonalizationConfig
+from ms_agent.project.manager import ProjectManager
+from ms_agent.tui.app import TuiApp
+
+
+def make_router():
+ router = CommandRouter()
+ register_builtin_commands(router)
+ return router
+
+
+def make_ctx(text, runtime=None):
+ cmd, args = CommandRouter.parse_input(text)
+ return CommandContext(
+ raw_input=text,
+ command_name=cmd,
+ args=args,
+ source='cli',
+ runtime=runtime,
+ extra={'router': make_router()},
+ )
+
+
+class MockRuntime:
+ def __init__(self, work):
+ self.config = OmegaConf.create({'output_dir': str(work)})
+ self.memory_tools = []
+
+ async def load_memory(self):
+ self.memory_tools.append('loaded')
+
+
+@pytest.fixture(autouse=True)
+def isolate_home(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ return home
+
+
+class TestMemoryCommand:
+ @pytest.mark.asyncio
+ async def test_global_default_persists(self, isolate_home):
+ result = await make_router().dispatch(make_ctx('/memory global on'))
+ assert 'Global memory default → on' in result.content
+ loaded = PersonalizationSettings().load()
+ assert loaded.memory_enabled is True
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['personalization']['memory_enabled'] is True
+
+ @pytest.mark.asyncio
+ async def test_project_toggle_writes_meta_and_config(
+ self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ ProjectManager(base_dir=str(isolate_home)).open_folder(str(work))
+ runtime = MockRuntime(work)
+ result = await make_router().dispatch(
+ make_ctx('/memory on', runtime))
+ assert 'Project memory → on' in result.content
+ project = ProjectManager(base_dir=str(isolate_home)).find_by_path(
+ str(work))
+ assert project.memory_enabled is True
+ assert runtime._memory_pending is True
+ assert OmegaConf.select(
+ runtime.config, 'memory.unified_memory', default=None) is None
+ assert '下一条消息生效' not in result.content
+ assert runtime.memory_tools == []
+
+ @pytest.mark.asyncio
+ async def test_project_toggle_sees_work_dir_on_agent_runtime(
+ self, tmp_path, isolate_home):
+ from types import SimpleNamespace
+ work = tmp_path / 'repo'
+ work.mkdir()
+ ProjectManager(base_dir=str(isolate_home)).open_folder(str(work))
+ runtime = SimpleNamespace(
+ config=None,
+ llm=SimpleNamespace(
+ config=OmegaConf.create({'output_dir': str(work)})),
+ memory_tools=[],
+ )
+ result = await make_router().dispatch(
+ make_ctx('/memory on', runtime))
+ assert 'Project memory → on' in result.content
+
+ @pytest.mark.asyncio
+ async def test_backend_without_scope_is_global_only(
+ self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ pm = ProjectManager(base_dir=str(isolate_home))
+ project = pm.open_folder(str(work), memory_backend='file')
+ runtime = MockRuntime(work)
+ result = await make_router().dispatch(
+ make_ctx('/memory backend vector', runtime))
+ assert 'Global memory backend default → vector' in result.content
+ assert 'this project is unchanged' in result.content
+ loaded = PersonalizationSettings().load()
+ assert loaded.memory_backend == 'vector'
+ still = pm.get(project.id)
+ assert still.memory_backend == 'file'
+
+ @pytest.mark.asyncio
+ async def test_project_backend_does_not_rewrite_global(
+ self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ pm = ProjectManager(base_dir=str(isolate_home))
+ project = pm.open_folder(str(work), memory_backend='file')
+ runtime = MockRuntime(work)
+ result = await make_router().dispatch(
+ make_ctx('/memory project backend vector', runtime))
+ assert 'Project memory backend → vector' in result.content
+ loaded = PersonalizationSettings().load()
+ assert loaded.memory_backend != 'vector'
+ updated = pm.get(project.id)
+ assert updated.memory_backend == 'vector'
+
+ @pytest.mark.asyncio
+ async def test_global_backend_alias(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx('/memory global backend file'))
+ assert 'Global memory backend default → file' in result.content
+ loaded = PersonalizationSettings().load()
+ assert loaded.memory_backend == 'file'
+
+ @pytest.mark.asyncio
+ async def test_vector_does_not_silent_file_fallback(
+ self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ pm = ProjectManager(base_dir=str(isolate_home))
+ project = pm.open_folder(str(work))
+ pm.update(project.id, memory_enabled=True, memory_backend='vector')
+ runtime = MockRuntime(work)
+ result = await make_router().dispatch(
+ make_ctx('/memory on', runtime))
+ assert 'vector' in result.content.lower()
+ assert OmegaConf.select(
+ runtime.config, 'memory', default=None) is None
+
+
+def test_apply_project_memory_file_node():
+ cfg = OmegaConf.create({})
+ project = SimpleNamespace(
+ id='abc', memory_enabled=True, memory_backend='file')
+ assert apply_project_memory(cfg, project) == 'file'
+ assert cfg.memory.unified_memory.user_id == 'abc'
+
+
+def test_open_folder_inherits_global_memory_default(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ PersonalizationSettings().save(
+ PersonalizationConfig(memory_enabled=True, memory_backend='file'))
+ work = tmp_path / 'fresh'
+ work.mkdir()
+ project = TuiApp._open_project(str(work))
+ assert project.memory_enabled is True
+ again = TuiApp._open_project(str(work))
+ assert again.id == project.id
+
+
+def test_prepare_config_injects_unified_memory(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ cfg = OmegaConf.create({})
+ project = SimpleNamespace(
+ id='p1', instruction='', memory_enabled=True, memory_backend='file')
+ out = TuiApp._prepare_config(cfg, None, str(tmp_path / 'work'), project)
+ assert out.memory.unified_memory.storage.backend == 'file'
diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py
index d7a9bee8b..edc487bf3 100644
--- a/tests/command/test_new_cmds.py
+++ b/tests/command/test_new_cmds.py
@@ -1,4 +1,5 @@
"""Tests for new builtin commands: /usage, /model, /config, /quit, /tools, /compact, /context."""
+import json
import pytest
from dataclasses import dataclass, field
from typing import List
@@ -49,6 +50,20 @@ def make_router():
return router
+def _keyed(provider: str) -> None:
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ ModelSettingsManager(global_home()).patch_provider(
+ provider, api_key='sk-test', base_url='https://example.invalid/v1')
+
+
+def _catalog_ok(monkeypatch, ids):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('ok', 200, list(ids)))
+
+
def make_ctx(text, runtime=None, messages=None):
router = make_router()
cmd, args = CommandRouter.parse_input(text)
@@ -125,6 +140,18 @@ async def test_alias_stats(self):
class TestModel:
+ @pytest.fixture(autouse=True)
+ def _isolate_home(self, tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path / 'ms_home'))
+ for name in (
+ 'OPENAI_API_KEY',
+ 'DASHSCOPE_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ 'MODELSCOPE_API_KEY',
+ 'MINIMAX_API_KEY',
+ ):
+ monkeypatch.delenv(name, raising=False)
+
@pytest.mark.asyncio
async def test_show_current_model(self):
router = make_router()
@@ -134,17 +161,228 @@ async def test_show_current_model(self):
assert 'openai' in result.content
@pytest.mark.asyncio
- async def test_switch_model(self):
+ async def test_switch_model(self, monkeypatch):
runtime = MockRuntime()
router = make_router()
- ctx = make_ctx('/model gpt-4o', runtime=runtime)
- result = await router.dispatch(ctx)
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['gpt-4o'])
+ result = await router.dispatch(
+ make_ctx('/model gpt-4o', runtime=runtime))
assert result.type == CommandResultType.MUTATE_STATE
assert 'gpt-4o' in result.content
assert runtime.llm.model == 'gpt-4o'
@pytest.mark.asyncio
- async def test_switch_model_persists_to_project_patch(self, tmp_path):
+ async def test_slash_in_model_id_stays_on_current_provider(self, monkeypatch):
+ runtime = MockRuntime()
+ router = make_router()
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['MiniMax/MiniMax-M2.1'])
+ result = await router.dispatch(
+ make_ctx('/model MiniMax/MiniMax-M2.1', runtime=runtime))
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert runtime.llm.model == 'MiniMax/MiniMax-M2.1'
+ assert runtime.llm.config.llm.service == 'openai'
+ assert 'Provider: openai' in result.content
+ assert 'Model: MiniMax/MiniMax-M2.1' in result.content
+
+ @pytest.mark.asyncio
+ async def test_slash_does_not_select_a_provider(self, monkeypatch):
+ runtime = MockRuntime()
+ router = make_router()
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['Qwen/Qwen3.8-Flash-Next'])
+ result = await router.dispatch(
+ make_ctx('/model Qwen/Qwen3.8-Flash-Next', runtime=runtime))
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert runtime.llm.config.llm.service == 'openai'
+ assert runtime.llm.model == 'Qwen/Qwen3.8-Flash-Next'
+ assert 'No provider named' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_provider_slash_model_is_not_a_provider_switch(
+ self, monkeypatch):
+ runtime = MockRuntime()
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['gpt-4o'])
+ result = await make_router().dispatch(
+ make_ctx('/model dashscope/qwen-max', runtime=runtime))
+ assert runtime.llm.config.llm.service == 'openai'
+ assert runtime.llm.model != 'qwen-max'
+ assert 'dashscope/qwen-max' in result.content
+ assert 'No provider named' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_space_form_sets_provider_when_model_has_slash(
+ self, monkeypatch):
+ runtime = MockRuntime()
+ _keyed('dashscope')
+ _catalog_ok(monkeypatch, ['MiniMax/MiniMax-M2.1'])
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model dashscope MiniMax/MiniMax-M2.1', runtime=runtime))
+ assert runtime.llm.config.llm.service == 'dashscope'
+ assert runtime.llm.model == 'MiniMax/MiniMax-M2.1'
+
+ @pytest.mark.asyncio
+ async def test_switch_provider_without_key_rolls_back(self, monkeypatch):
+ monkeypatch.delenv('MINIMAX_API_KEY', raising=False)
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model minimax MiniMax-M2.1', runtime=runtime))
+ assert result.type == CommandResultType.MESSAGE
+ assert runtime.llm.config.llm.service == 'openai'
+ assert runtime.llm.model == 'qwen3.7-plus'
+ assert 'Cannot switch to:' in result.content
+ assert 'Provider: minimax' in result.content
+ assert 'Model: MiniMax-M2.1' in result.content
+ assert 'Still on:' in result.content
+ assert 'Provider: openai' in result.content
+ assert 'Model: qwen3.7-plus' in result.content
+ assert 'Set a key: /model provider key minimax ' in result.content
+ assert 'Staying on' not in result.content
+ assert 'keep that provider:\n /model minimax MiniMax-M2.1' not in result.content
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ stored = ModelSettingsManager(global_home()).get_default_model()
+ assert stored != 'minimax/MiniMax-M2.1'
+
+ @pytest.mark.asyncio
+ async def test_switch_fail_hints_keyed_provider(self, monkeypatch):
+ for env in (
+ 'MINIMAX_API_KEY',
+ 'OPENAI_API_KEY',
+ 'DASHSCOPE_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ 'MODELSCOPE_API_KEY',
+ 'GOOGLE_API_KEY',
+ 'GEMINI_API_KEY',
+ 'KIMI_API_KEY',
+ 'MOONSHOT_API_KEY',
+ 'DEEPSEEK_API_KEY',
+ 'OPENROUTER_API_KEY',
+ 'GLM_API_KEY',
+ 'ZHIPU_API_KEY',
+ 'ZHIPUAI_API_KEY',
+ ):
+ monkeypatch.delenv(env, raising=False)
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('no_catalog', 404, []))
+ runtime = MockRuntime()
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ result = await router.dispatch(
+ make_ctx('/model minimax MiniMax-M2.1', runtime=runtime))
+ assert 'Or switch to a keyed provider (acme):' in result.content
+ assert '/model acme ' in result.content
+ assert '{provider_id}' not in result.content
+ assert 'keep that provider' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_same_provider_without_key_does_not_repeat_command(
+ self, monkeypatch):
+ monkeypatch.delenv('MINIMAX_API_KEY', raising=False)
+ runtime = MockRuntime()
+ runtime.llm.model = 'minimax MiniMax-M2.1'
+ runtime.llm.config.llm.service = 'minimax'
+ runtime.llm.config.llm.model = 'minimax MiniMax-M2.1'
+ result = await make_router().dispatch(
+ make_ctx('/model minimax MiniMax-M2.1', runtime=runtime))
+ assert result.type == CommandResultType.MESSAGE
+ assert runtime.llm.config.llm.service == 'minimax'
+ assert runtime.llm.model == 'MiniMax-M2.1'
+ assert runtime.llm.config.llm.model == 'MiniMax-M2.1'
+ assert 'Already on:' in result.content
+ assert 'Provider: minimax' in result.content
+ assert 'Model: MiniMax-M2.1' in result.content
+ assert 'minimax MiniMax-M2.1' not in result.content
+ assert 'Staying on' not in result.content
+ assert '/model minimax MiniMax-M2.1' not in result.content
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ stored = ModelSettingsManager(global_home()).get_default_model()
+ assert stored != 'minimax/minimax MiniMax-M2.1'
+
+ @pytest.mark.asyncio
+ async def test_show_strips_glued_provider_prefix(self, tmp_path):
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ home = tmp_path / 'ms_home'
+ home.mkdir(parents=True, exist_ok=True)
+ (home / 'settings.json').write_text(json.dumps({
+ 'default_model': 'minimax/minimax MiniMax-M2.1',
+ 'llm': {
+ 'provider': 'minimax',
+ 'model': 'minimax MiniMax-M2.1',
+ },
+ }))
+ runtime = MockRuntime()
+ runtime.llm.model = 'minimax MiniMax-M2.1'
+ runtime.llm.config.llm.service = 'minimax'
+ runtime.llm.config.llm.model = 'minimax MiniMax-M2.1'
+ result = await make_router().dispatch(
+ make_ctx('/model', runtime=runtime))
+ assert 'Provider: minimax' in result.content
+ assert 'Model: MiniMax-M2.1' in result.content
+ assert 'Model: minimax MiniMax-M2.1' not in result.content
+ assert 'Switch: /model minimax MiniMax-M2.1' in result.content
+ assert runtime.llm.model == 'MiniMax-M2.1'
+ stored = ModelSettingsManager(global_home()).get_default_model()
+ assert stored == 'minimax/MiniMax-M2.1'
+
+ @pytest.mark.asyncio
+ async def test_show_warns_when_live_client_differs(self):
+ runtime = MockRuntime()
+ runtime.llm.model = 'MiniMax-M2.1'
+ runtime.llm.config.llm.service = 'minimax'
+ runtime.llm.spec = type('S', (), {'name': 'dashscope'})()
+ runtime.llm.transport = type(
+ 'T', (), {'base_url': 'https://dashscope.example/v1'})()
+ result = await make_router().dispatch(
+ make_ctx('/model', runtime=runtime))
+ assert 'WARNING' in result.content
+ assert 'dashscope' in result.content
+ assert 'https://dashscope.example/v1' in result.content
+ assert 'realign with /model dashscope MiniMax-M2.1.' in result.content
+ assert 'dashscope/MiniMax-M2.1' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_show_current_separates_provider_and_model(self):
+ runtime = MockRuntime()
+ runtime.llm.model = 'MiniMax/MiniMax-M2.1'
+ runtime.llm.config.llm.service = 'dashscope'
+ result = await make_router().dispatch(
+ make_ctx('/model', runtime=runtime))
+ assert 'Provider: dashscope' in result.content
+ assert 'Model: MiniMax/MiniMax-M2.1' in result.content
+ assert 'Switch: /model dashscope MiniMax/MiniMax-M2.1' in result.content
+
+ @pytest.mark.asyncio
+ async def test_switch_model_replaces_setup_stub(self, monkeypatch):
+ from types import SimpleNamespace
+
+ config = _make_mock_config()
+ stub = SimpleNamespace(config=config, model='old', _setup_stub=True)
+ runtime = MockRuntime(llm=stub)
+ router = make_router()
+ ctx = make_ctx('/model dashscope qwen3.8-flash', runtime=runtime)
+ _keyed('dashscope')
+ _catalog_ok(monkeypatch, ['qwen3.8-flash'])
+ result = await router.dispatch(ctx)
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert 'Switch: /model dashscope qwen3.8-flash' in result.content
+ assert '/model dashscope/qwen3.8-flash' not in result.content
+ assert runtime.llm.model == 'qwen3.8-flash'
+ assert config.llm.service == 'dashscope'
+
+ @pytest.mark.asyncio
+ async def test_switch_model_persists_to_settings_not_project_patch(
+ self, tmp_path, monkeypatch):
# The committed source YAML must never be mutated by /model.
yaml_text = (
'llm:\n'
@@ -158,35 +396,419 @@ async def test_switch_model_persists_to_project_patch(self, tmp_path):
config = OmegaConf.create({
'llm': {'service': 'openai', 'model': 'qwen3.5-plus'},
'local_dir': str(tmp_path),
+ 'output_dir': str(tmp_path),
'name': 'searcher.yaml',
})
runtime = MockRuntime(llm=MockLLM(model='qwen3.5-plus', config=config))
router = make_router()
ctx = make_ctx('/model qwen3.7-max', runtime=runtime)
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['qwen3.7-max'])
result = await router.dispatch(ctx)
assert result.type == CommandResultType.MUTATE_STATE
- assert 'Saved to' in result.content
+ assert 'qwen3.7-max' in result.content
+ assert 'Saved as the default' not in result.content
+ assert '下一条消息使用它' not in result.content
+ assert 'project patch' not in result.content.lower()
# The source YAML is untouched.
assert cfg_file.read_text(encoding='utf-8') == yaml_text
- # The override landed in the project patch, which from_task merges back.
- # Writer now targets the new .ms_agent/ dir (resolver reads both).
+ # /model writes the WebUI-shared default only — not a folder pin that
+ # would later hide a WebUI default change.
patch_file = tmp_path / '.ms_agent' / 'config.yaml'
- assert patch_file.exists()
- patch_cfg = OmegaConf.load(str(patch_file))
- assert patch_cfg.llm.model == 'qwen3.7-max'
+ assert not patch_file.exists()
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ assert ModelSettingsManager(global_home()).get_default_model() == (
+ 'openai/qwen3.7-max')
@pytest.mark.asyncio
- async def test_switch_model_no_source_file(self):
- # config without local_dir/name -> in-memory only, no crash
+ async def test_switch_model_no_source_file(self, tmp_path, monkeypatch):
+ # Still writes the WebUI-shared default_model in settings.json.
runtime = MockRuntime()
router = make_router()
ctx = make_ctx('/model gpt-4o', runtime=runtime)
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['gpt-4o'])
result = await router.dispatch(ctx)
assert result.type == CommandResultType.MUTATE_STATE
- assert 'in-memory only' in result.content
+ assert 'gpt-4o' in result.content
+ assert 'Saved as the default' not in result.content
+ assert '下一条消息使用它' not in result.content
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ assert ModelSettingsManager(global_home()).get_default_model() == 'openai/gpt-4o'
+
+ @pytest.mark.asyncio
+ async def test_switch_updates_this_session_and_the_default(
+ self, tmp_path, monkeypatch):
+ """Switching changes the shared default and this conversation.
+
+ Another conversation's stored model stays put, so coming back to it
+ does not follow the new default.
+ """
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project import SessionManager
+ from ms_agent.project.manager import ProjectManager
+ from ms_agent.tui.app import TuiApp
+
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ project = TuiApp._open_project(str(work))
+ sm = SessionManager(project)
+ current = sm.create(model='qwen3.7-plus', model_provider='openai')
+ other = sm.create(model='kept-model', model_provider='openai')
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'qwen3.7-plus',
+ 'openai_api_key': 'sk-test',
+ },
+ 'session_log': {
+ 'dir': str(sm.sessions_dir / current.id),
+ },
+ })
+ runtime = MockRuntime(llm=MockLLM(model='qwen3.7-plus', config=config))
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['gpt-4o'])
+ result = await make_router().dispatch(
+ make_ctx('/model gpt-4o', runtime=runtime))
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert ModelSettingsManager(home).get_default_model() == 'openai/gpt-4o'
+ stored = sm.get(current.id)
+ assert stored.model == 'gpt-4o'
+ assert stored.model_provider == 'openai'
+ untouched = sm.get(other.id)
+ assert untouched.model == 'kept-model'
+ assert ProjectManager(str(home)).get(project.id) is not None
+
+ @pytest.mark.asyncio
+ async def test_model_list_reads_settings(self, tmp_path):
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ ModelSettingsManager(global_home()).set_default_model(
+ 'a-1', provider='acme')
+ router = make_router()
+ ctx = make_ctx('/model list', runtime=MockRuntime())
+ result = await router.dispatch(ctx)
+ assert 'Default: /model acme a-1' in result.content
+ assert 'acme/a-1' not in result.content
+ assert 'Providers:' in result.content
+ assert 'WebUI' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_model_list_default_keeps_slash_inside_the_model_id(self):
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ ModelSettingsManager(global_home()).set_default_model(
+ 'Qwen/Qwen3.8-Flash-Next', provider='modelscope')
+ result = await make_router().dispatch(
+ make_ctx('/model list', runtime=MockRuntime()))
+ assert ('Default: /model modelscope Qwen/Qwen3.8-Flash-Next'
+ in result.content)
+ assert 'modelscope/Qwen/' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_model_list_does_not_fetch(self, monkeypatch):
+ called = []
+
+ def boom(*_a, **_k):
+ called.append(1)
+ return []
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.fetch_model_ids', boom)
+ await make_router().dispatch(
+ make_ctx('/model list', runtime=MockRuntime()))
+ assert called == []
+
+ @pytest.mark.asyncio
+ async def test_model_list_live_filters_and_does_not_persist(
+ self, tmp_path, monkeypatch):
+ for env in (
+ 'DASHSCOPE_API_KEY',
+ 'MODELSCOPE_API_KEY',
+ 'OPENAI_API_KEY',
+ ):
+ monkeypatch.delenv(env, raising=False)
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('no_catalog', 404, []))
+ runtime = MockRuntime()
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk-secret url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ await router.dispatch(
+ make_ctx('/model catalog add acme catalog-only', runtime=runtime))
+
+ def fake(url, protocol, api_key):
+ assert url == 'https://acme/v1'
+ assert api_key == 'sk-secret'
+ return [
+ 'qwen-plus',
+ 'qwen-vl-max',
+ 'wanx-v1',
+ 'text-embedding-v3',
+ ]
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.fetch_model_ids', fake)
+ result = await router.dispatch(
+ make_ctx('/model list live acme', runtime=runtime))
+ assert 'preview only' in result.content
+ assert 'catalog: catalog-only' in result.content
+ assert 'live: 2 chat' in result.content
+ assert ' qwen-plus' in result.content
+ assert ' qwen-vl-max' in result.content
+ assert 'wanx-v1' not in result.content
+ assert 'text-embedding-v3' not in result.content
+ assert 'dropped' not in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['models'] == ['catalog-only']
+
+ skipped = await router.dispatch(
+ make_ctx('/model list live openai', runtime=runtime))
+ assert 'skipped (no API key)' in skipped.content
+
+ @pytest.mark.asyncio
+ async def test_provider_add_set_key_catalog(self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('no_catalog', 404, []))
+ runtime = MockRuntime()
+ router = make_router()
+ added = await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk-secret url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ assert 'Provider acme saved' in added.content
+ assert 'Switch with /model acme ' in added.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['api_key'] == 'sk-secret'
+ assert data['providers']['acme']['base_url'] == 'https://acme/v1'
+ listed = await router.dispatch(make_ctx('/model list', runtime=runtime))
+ assert 'sk-secret' not in listed.content
+ assert 'key=sk-s****cret' in listed.content
+ await router.dispatch(
+ make_ctx('/model provider key acme sk-new', runtime=runtime))
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['api_key'] == 'sk-new'
+ await router.dispatch(
+ make_ctx('/model catalog add acme a-1', runtime=runtime))
+ await router.dispatch(
+ make_ctx('/model catalog remove acme a-1', runtime=runtime))
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert 'a-1' not in data['providers']['acme'].get('models', [])
+ await router.dispatch(
+ make_ctx('/model provider remove acme', runtime=runtime))
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert 'acme' not in data.get('providers', {})
+
+ @pytest.mark.asyncio
+ async def test_catalog_drop_uses_current_provider(self, tmp_path):
+ runtime = MockRuntime()
+ runtime.llm.config.llm.service = 'acme'
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ await router.dispatch(
+ make_ctx('/model catalog add acme qwen3.9-flash', runtime=runtime))
+ dropped = await router.dispatch(
+ make_ctx('/model catalog drop qwen3.9-flash', runtime=runtime))
+ assert dropped.content == (
+ 'Removed qwen3.9-flash from acme catalog (current provider).')
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert 'qwen3.9-flash' not in data['providers']['acme'].get('models', [])
+
+ @pytest.mark.asyncio
+ async def test_catalog_unknown_action_does_not_dump_full_usage(self):
+ result = await make_router().dispatch(
+ make_ctx('/model catalog foo bar', runtime=MockRuntime()))
+ assert 'Unknown catalog action' in result.content
+ assert 'Need: /model catalog' in result.content
+ assert '/model provider add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_catalog_remove_model_only_is_focused_without_override(self):
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model catalog remove qwen3.9-flash', runtime=MockRuntime()))
+ assert 'Need: /model catalog remove ' in result.content
+ assert '/model provider add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_catalog_remove_unknown_model_does_not_claim_success(
+ self, tmp_path):
+ runtime = MockRuntime()
+ runtime.llm.config.llm.service = 'acme'
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ await router.dispatch(
+ make_ctx('/model catalog add acme qwen3.8-flash', runtime=runtime))
+ result = await router.dispatch(
+ make_ctx(
+ '/model catalog remove qwen3.9-flash12', runtime=runtime))
+ assert 'not in the acme catalog (current provider)' in result.content
+ assert 'Pinned: qwen3.8-flash' in result.content
+ assert 'Removed' not in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['models'] == ['qwen3.8-flash']
+
+ @pytest.mark.asyncio
+ async def test_catalog_no_args_lists_pinned(self, tmp_path):
+ runtime = MockRuntime()
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/model provider add acme key=sk url=https://acme/v1 protocol=openai',
+ runtime=runtime))
+ await router.dispatch(
+ make_ctx('/model catalog add acme qwen3.8-flash', runtime=runtime))
+ result = await router.dispatch(
+ make_ctx('/model catalog', runtime=runtime))
+ assert 'Pinned catalogs' in result.content
+ assert 'acme: qwen3.8-flash' in result.content
+ assert 'Need: /model catalog add|remove' in result.content
+
+ @pytest.mark.asyncio
+ async def test_provider_key_missing_args_is_focused(self):
+ result = await make_router().dispatch(
+ make_ctx('/model provider key', runtime=MockRuntime()))
+ assert 'Need: /model provider key |clear' in result.content
+ assert 'Got: /model provider key' in result.content
+ assert '/model catalog add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_help_usage_example_is_dashscope_qwen(self):
+ result = await make_router().dispatch(
+ make_ctx('/model help', runtime=MockRuntime()))
+ assert 'Example: /model dashscope qwen3.8-flash' in result.content
+ assert 'e.g. dashscope' in result.content
+ assert 'e.g. qwen3.8-flash' in result.content
+ assert 'Saved in ' in result.content
+ assert 'settings.json' in result.content
+ assert 'WebUI' not in result.content
+ assert 'MS_AGENT_HOME' not in result.content
+ assert '' in result.content
+ assert 'MiniMax' not in result.content
+ assert '~/.ms_agent' not in result.content
+ assert '' not in result.content
+ assert '/' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_unknown_option_is_focused(self):
+ result = await make_router().dispatch(
+ make_ctx('/model list foo', runtime=MockRuntime()))
+ assert 'Need: /model list live [provider]' in result.content
+ assert '/model provider add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_unknown_provider_action_is_focused(self):
+ result = await make_router().dispatch(
+ make_ctx('/model provider foo bar', runtime=MockRuntime()))
+ assert "Unknown provider action 'foo'" in result.content
+ assert 'Need: /model provider' in result.content
+ assert '/model catalog add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_cannot_remove_builtin_without_override(self):
+ result = await make_router().dispatch(
+ make_ctx('/model provider remove openai', runtime=MockRuntime()))
+ assert 'Cannot remove builtin' in result.content
+
+ @pytest.mark.asyncio
+ async def test_unknown_provider_prefix_is_rejected(self, tmp_path):
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model dashcope qwen-max', runtime=runtime))
+ assert result.type == CommandResultType.MESSAGE
+ assert 'No provider named dashcope' in result.content
+ assert 'Known:' in result.content
+ assert runtime.llm.model == 'qwen3.7-plus'
+ assert runtime.llm.config.llm.model == 'qwen3.7-plus'
+ settings = tmp_path / 'ms_home' / 'settings.json'
+ if settings.exists():
+ assert 'dashcope' not in settings.read_text()
+
+ @pytest.mark.asyncio
+ async def test_provider_word_ignores_case(self, monkeypatch):
+ runtime = MockRuntime()
+ _keyed('dashscope')
+ _catalog_ok(monkeypatch, ['qwen-max'])
+ result = await make_router().dispatch(
+ make_ctx('/model DashScope qwen-max', runtime=runtime))
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert runtime.llm.config.llm.service == 'dashscope'
+ assert runtime.llm.model == 'qwen-max'
+ assert 'No provider named' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_runtime_only_key_still_checks_the_catalog(self, monkeypatch):
+ monkeypatch.delenv('OPENAI_API_KEY', raising=False)
+ _catalog_ok(monkeypatch, ['gpt-4o'])
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model not-a-real-model', runtime=runtime))
+ assert '列表里没有 not-a-real-model' in result.content
+ assert runtime.llm.config.llm.model == 'qwen3.7-plus'
+
+ @pytest.mark.asyncio
+ async def test_runtime_only_key_notes_a_missing_catalog(self, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.delenv('OPENAI_API_KEY', raising=False)
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('no_catalog', 404, []))
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model some-model', runtime=runtime))
+ assert '没有返回模型列表' in result.content
+ assert 'Switched to' in result.content
+
+ @pytest.mark.asyncio
+ async def test_catalog_add_rejects_secret_shaped_model(self, tmp_path):
+ runtime = MockRuntime()
+ secret = 'sk-THIS-IS-MY-SECRET'
+ result = await make_router().dispatch(
+ make_ctx(f'/model catalog add {secret}', runtime=runtime))
+ assert 'API key' in result.content
+ assert secret not in result.content
+ assert 'REDACTED' in result.content
+ settings = tmp_path / 'ms_home' / 'settings.json'
+ if settings.exists():
+ assert secret not in settings.read_text()
+
+ @pytest.mark.asyncio
+ async def test_switch_rejects_secret_shaped_model(self):
+ runtime = MockRuntime()
+ secret = 'sk-THIS-IS-MY-SECRET'
+ result = await make_router().dispatch(
+ make_ctx(f'/model {secret}', runtime=runtime))
+ assert 'API key' in result.content
+ assert secret not in result.content
+ assert runtime.llm.config.llm.model == 'qwen3.7-plus'
+
+ @pytest.mark.asyncio
+ async def test_provider_set_error_masks_secret(self):
+ secret = 'sk-THIS-IS-MY-SECRET'
+ result = await make_router().dispatch(
+ make_ctx(f'/model provider set {secret}', runtime=MockRuntime()))
+ assert 'No fields to patch' in result.content
+ assert secret not in result.content
+ assert 'REDACTED' in result.content
@pytest.mark.asyncio
async def test_no_runtime(self):
@@ -220,6 +842,55 @@ async def test_masks_api_keys(self):
assert 'sk-test' not in result.content
assert '***' in result.content
+ @pytest.mark.asyncio
+ async def test_masks_search_keys_and_mcp_headers(self):
+ cfg = OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'gpt-4o',
+ 'openai_api_key': 'sk-real-key-value',
+ 'base_url':
+ 'https://user:sk-THIS-IS-A-REAL-KEY-99@gateway.example/v1',
+ 'openai_base_url':
+ 'https://sk-THIS-IS-A-REAL-KEY-99@gateway.example/v1',
+ },
+ 'tools': {
+ 'web_search': {
+ 'tavily_api_key': 'tvly-THIS-IS-MY-SECRET',
+ },
+ },
+ 'mcpServers': {
+ 'docs': {
+ 'url': 'https://example.invalid/mcp',
+ 'headers': {
+ 'Authorization': 'Bearer THIS-IS-MY-TOKEN',
+ 'Content-Type': 'application/json',
+ 'X-Custom': 'keep-me',
+ },
+ 'env': {
+ 'PATH': '/usr/bin',
+ },
+ },
+ },
+ })
+ runtime = MockRuntime()
+ runtime.llm.config = cfg
+ result = await make_router().dispatch(
+ make_ctx('/config', runtime=runtime))
+ assert 'tvly-THIS-IS-MY-SECRET' not in result.content
+ assert 'THIS-IS-MY-TOKEN' not in result.content
+ assert 'sk-real-key-value' not in result.content
+ assert 'tavily_api_key' in result.content
+ assert 'Authorization' in result.content
+ assert 'application/json' in result.content
+ assert 'keep-me' in result.content
+ assert '/usr/bin' in result.content
+ assert 'sk-THIS-IS-A-REAL-KEY-99' not in result.content
+ assert 'gateway.example/v1' in result.content
+ assert 'https://user:[REDACTED:api_key]@gateway.example/v1' in (
+ result.content)
+ assert 'https://[REDACTED:api_key]@gateway.example/v1' in result.content
+
class TestQuit:
@pytest.mark.asyncio
@@ -258,6 +929,24 @@ async def test_excludes_plugins_key(self):
result = await router.dispatch(ctx)
assert 'plugins' not in result.content
+ @pytest.mark.asyncio
+ async def test_connect_failure_is_not_listed_as_ready(self):
+ runtime = MockRuntime()
+ runtime.tool_manager = type('Mgr', (), {
+ 'connect_failures': {
+ 'web_search':
+ 'UNAVAILABLE: Package `arxiv` is not installed. '
+ 'Run: pip install arxiv, then restart ms-agent.',
+ },
+ })()
+ result = await make_router().dispatch(
+ make_ctx('/tools', runtime=runtime))
+ file_row = next(line for line in result.content.splitlines()
+ if 'file_system' in line)
+ assert 'UNAVAILABLE' not in file_row
+ assert '• web_search UNAVAILABLE: Package `arxiv`' in result.content
+ assert '• code_executor' in result.content
+
@pytest.mark.asyncio
async def test_no_runtime(self):
router = make_router()
@@ -381,9 +1070,207 @@ def test_help_lists_new_commands(self):
assert 'tools' in all_names
assert 'compact' in all_names
assert 'context' in all_names
+ assert 'mcp' in all_names
+ assert 'skills' in all_names
+ assert 'search' in all_names
+ assert 'instruction' in all_names
+ assert 'profile' in all_names
+ assert 'memory' in all_names
def test_total_builtin_count(self):
router = make_router()
cmds = router.list_commands('cli')
total = sum(len(v) for v in cmds.values())
- assert total == 12
+ assert total == 18
+
+
+class TestModelProbe:
+ @pytest.fixture(autouse=True)
+ def _isolate_home(self, tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path / 'ms_home'))
+ monkeypatch.delenv('OPENAI_API_KEY', raising=False)
+
+ @pytest.mark.asyncio
+ async def test_auth_rolls_back_the_new_key(self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('auth', 401, []))
+ _keyed('acme')
+ result = await make_router().dispatch(
+ make_ctx('/model provider key acme sk-new', runtime=MockRuntime()))
+ assert '没有换成它' in result.content
+ assert '可复制' in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['api_key'] == 'sk-test'
+
+ @pytest.mark.asyncio
+ async def test_url_change_does_not_send_the_saved_key(
+ self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ calls = []
+
+ def spy(url, protocol, api_key):
+ calls.append((url, protocol, api_key))
+ return CatalogProbe('ok', 200, ['m'])
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog', spy)
+ _keyed('acme')
+ calls.clear()
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model provider url acme https://other.example/v1',
+ runtime=MockRuntime()))
+ assert calls == []
+ assert 'Saved base URL for acme.' in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['base_url'] == (
+ 'https://other.example/v1')
+ assert data['providers']['acme']['api_key'] == 'sk-test'
+
+ @pytest.mark.asyncio
+ async def test_set_url_only_does_not_probe(self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ calls = []
+
+ def spy(url, protocol, api_key):
+ calls.append(api_key)
+ return CatalogProbe('ok', 200, ['m'])
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog', spy)
+ _keyed('acme')
+ calls.clear()
+ await make_router().dispatch(
+ make_ctx(
+ '/model provider set acme url=https://set.example/v1',
+ runtime=MockRuntime()))
+ assert calls == []
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['base_url'] == 'https://set.example/v1'
+ assert data['providers']['acme']['api_key'] == 'sk-test'
+
+ @pytest.mark.asyncio
+ async def test_new_key_is_sent_only_to_the_saved_url(
+ self, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ calls = []
+
+ def spy(url, protocol, api_key):
+ calls.append((url, api_key))
+ return CatalogProbe('ok', 200, ['m'])
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog', spy)
+ _keyed('acme')
+ calls.clear()
+ await make_router().dispatch(
+ make_ctx(
+ '/model provider key acme sk-new-key', runtime=MockRuntime()))
+ assert calls == [('https://example.invalid/v1', 'sk-new-key')]
+
+ @pytest.mark.asyncio
+ async def test_unreachable_keeps_the_new_key(self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('unreachable', None, []))
+ _keyed('acme')
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model provider key acme sk-offline', runtime=MockRuntime()))
+ assert 'Saved API key for acme.' in result.content
+ assert '这个地址现在连不上,密钥还没向供应商确认。' in result.content
+ assert '没有保存' not in result.content
+ assert '没有换成它' not in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['acme']['api_key'] == 'sk-offline'
+
+ @pytest.mark.asyncio
+ async def test_unreachable_keeps_a_key_and_url_written_together(
+ self, tmp_path, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ calls = []
+
+ def spy(url, protocol, api_key):
+ calls.append((url, api_key))
+ return CatalogProbe('unreachable', None, [])
+
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog', spy)
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model provider add gate '
+ 'key=sk-together url=https://gate.example/v1',
+ runtime=MockRuntime()))
+ assert calls == [('https://gate.example/v1', 'sk-together')]
+ assert '这个地址现在连不上,密钥还没向供应商确认。' in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert data['providers']['gate']['api_key'] == 'sk-together'
+ assert data['providers']['gate']['base_url'] == 'https://gate.example/v1'
+
+ @pytest.mark.asyncio
+ async def test_clear_key_does_not_claim_it_applied(self, tmp_path):
+ _keyed('acme')
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model provider key acme clear', runtime=MockRuntime()))
+ assert result.content.startswith('已清除 acme 的密钥。')
+ assert '已生效' not in result.content
+ assert 'Live credentials' not in result.content
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert 'api_key' not in data['providers']['acme']
+
+ @pytest.mark.asyncio
+ async def test_clear_live_key_rearms_the_setup_notice(self, tmp_path):
+ import ms_agent.agent.llm_agent as mod
+ _keyed('openai')
+ mod._credential_notice_sent = True
+ try:
+ result = await make_router().dispatch(
+ make_ctx(
+ '/model provider key openai clear', runtime=MockRuntime()))
+ assert result.content == '已清除 openai 的密钥。'
+ assert mod._credential_notice_sent is False
+ finally:
+ mod._credential_notice_sent = False
+ data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text())
+ assert 'api_key' not in data['providers']['openai']
+
+ @pytest.mark.asyncio
+ async def test_missing_id_does_not_change_the_default(self, monkeypatch):
+ _keyed('openai')
+ _catalog_ok(monkeypatch, ['qwen-plus', 'qwen-max'])
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model gpt-4o', runtime=runtime))
+ assert result.type == CommandResultType.MESSAGE
+ assert '没有改默认模型' in result.content
+ assert 'qwen-plus' in result.content
+ assert runtime.llm.model == 'qwen3.7-plus'
+
+ @pytest.mark.asyncio
+ async def test_no_catalog_still_saves(self, monkeypatch):
+ from ms_agent.llm.model_discovery import CatalogProbe
+ from ms_agent.config.model_settings import ModelSettingsManager
+ from ms_agent.project.paths import global_home
+ monkeypatch.setattr(
+ 'ms_agent.llm.model_discovery.probe_model_catalog',
+ lambda *_a, **_k: CatalogProbe('no_catalog', 404, []))
+ _keyed('openai')
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx('/model gpt-4o', runtime=runtime))
+ assert result.type == CommandResultType.MUTATE_STATE
+ assert '没有返回模型列表' in result.content
+ assert runtime.llm.model == 'gpt-4o'
+ assert ModelSettingsManager(global_home()).get_default_model() == (
+ 'openai/gpt-4o')
+
+ @pytest.mark.asyncio
+ async def test_model_list_names_non_chat_models(self):
+ from ms_agent.llm.model_discovery import LIST_FOOTER
+ result = await make_router().dispatch(
+ make_ctx('/model list', runtime=MockRuntime()))
+ assert LIST_FOOTER in result.content
diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py
new file mode 100644
index 000000000..00f6975a8
--- /dev/null
+++ b/tests/command/test_resource_cmds.py
@@ -0,0 +1,722 @@
+"""TUI /mcp and /skills write the same manager files WebUI uses."""
+from dataclasses import dataclass, field
+
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.command.builtin import register_builtin_commands
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import CommandContext, CommandResultType
+from ms_agent.config import MCPConfigManager
+from ms_agent.config.skills_manager import SkillsConfigManager
+
+
+def make_router():
+ router = CommandRouter()
+ register_builtin_commands(router)
+ return router
+
+
+def make_ctx(text, runtime=None, extra=None):
+ router = make_router()
+ cmd, args = CommandRouter.parse_input(text)
+ payload = {'router': router}
+ if extra:
+ payload.update(extra)
+ return CommandContext(
+ raw_input=text,
+ command_name=cmd,
+ args=args,
+ source='cli',
+ runtime=runtime,
+ extra=payload,
+ )
+
+
+class _Failure:
+ def __init__(self, status, last_error):
+ self.status = status
+ self.last_error = last_error
+ self.enabled = True
+
+
+class _FailureRuntime:
+ """In-memory connect result. Listing it must not open a connection."""
+
+ def __init__(self, states):
+ self._states = states
+ self.connects = []
+
+ def get_server(self, name):
+ return self._states.get(name)
+
+ async def connect_single_server(self, *args, **kwargs):
+ self.connects.append(args)
+ raise AssertionError('list/get must not connect')
+
+
+@dataclass
+class MockRuntime:
+ config: object = field(default_factory=lambda: OmegaConf.create({}))
+ tool_manager: object = None
+ _skill_runtime: object = None
+
+
+@pytest.fixture(autouse=True)
+def isolate_home(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ return home
+
+
+class TestMcpCommand:
+ @pytest.mark.asyncio
+ async def test_help(self):
+ result = await make_router().dispatch(make_ctx('/mcp'))
+ assert result.type == CommandResultType.MESSAGE
+ assert result.content.startswith('No MCP servers.')
+ assert '/mcp list' in result.content
+
+ @pytest.mark.asyncio
+ async def test_help_flag_is_usage_only(self):
+ result = await make_router().dispatch(make_ctx('/mcp help'))
+ assert result.content.startswith('usage:')
+ assert 'No MCP servers' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_missing_args_is_focused(self):
+ result = await make_router().dispatch(
+ make_ctx('/mcp json', MockRuntime()))
+ assert 'Need: /mcp json ' in result.content
+ assert 'Got: /mcp json' in result.content
+ assert '/mcp add' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_add_list_disable_global(self, isolate_home):
+ runtime = MockRuntime()
+ router = make_router()
+ added = await router.dispatch(
+ make_ctx(
+ '/mcp add docs global url=https://example/mcp',
+ runtime=runtime))
+ assert 'Added docs' in added.content
+ assert 'Settings' not in added.content
+
+ listed = await router.dispatch(make_ctx('/mcp list global', runtime))
+ assert 'docs' in listed.content
+ assert 'https://example/mcp' in listed.content
+
+ mgr = MCPConfigManager(str(isolate_home), None)
+ assert 'docs' in mgr.list('global')
+
+ disabled = await router.dispatch(
+ make_ctx('/mcp disable docs global', runtime))
+ assert 'disable docs' in disabled.content
+ listed = await router.dispatch(make_ctx('/mcp list global', runtime))
+ assert '[off] docs' in listed.content
+
+ @pytest.mark.asyncio
+ async def test_add_project_needs_work_dir(self):
+ result = await make_router().dispatch(
+ make_ctx('/mcp add local command=npx', MockRuntime()))
+ assert 'work dir' in result.content
+
+ @pytest.mark.asyncio
+ async def test_add_project_writes_mcp_json(self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx('/mcp add local command=npx', runtime))
+ assert 'Added local' in result.content
+ assert '(project)' in result.content
+ assert 'Saved for this folder' in result.content
+ assert 'WebUI' not in result.content
+ mgr = MCPConfigManager(str(isolate_home), str(work))
+ entry = mgr.list('project')['local']
+ assert entry['command'] == 'npx'
+ assert entry['args'] == []
+
+ @pytest.mark.asyncio
+ async def test_add_sees_work_dir_on_agent_runtime(self, tmp_path,
+ isolate_home):
+ """Live TUI passes Runtime(llm=...), not a fake agent with .config."""
+ from types import SimpleNamespace
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = SimpleNamespace(
+ llm=SimpleNamespace(
+ config=OmegaConf.create({'output_dir': str(work)})))
+ result = await make_router().dispatch(
+ make_ctx('/mcp add live url=https://example/mcp', runtime))
+ assert 'Added live (project)' in result.content
+ mgr = MCPConfigManager(str(isolate_home), str(work))
+ assert 'live' in mgr.list('project')
+
+ @pytest.mark.asyncio
+ async def test_add_splits_stdio_command_line(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx(
+ '/mcp add fetch global command="npx -y @mcp/server-fetch"',
+ MockRuntime()))
+ assert 'Added fetch' in result.content
+ entry = MCPConfigManager(str(isolate_home), None).list('global')['fetch']
+ assert entry['command'] == 'npx'
+ assert entry['args'] == ['-y', '@mcp/server-fetch']
+
+ @pytest.mark.asyncio
+ async def test_list_without_work_dir_does_not_crash(self, isolate_home):
+ await make_router().dispatch(
+ make_ctx('/mcp add docs global url=https://example/mcp', MockRuntime()))
+ result = await make_router().dispatch(
+ make_ctx('/mcp list', MockRuntime()))
+ assert 'docs' in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_rejects_an_extra_name(self, isolate_home):
+ await make_router().dispatch(
+ make_ctx(
+ '/mcp add docs global url=https://example/mcp',
+ MockRuntime()))
+ result = await make_router().dispatch(
+ make_ctx('/mcp list mem1', MockRuntime()))
+ assert 'Unknown list option.' in result.content
+ assert 'Need: /mcp list [global|project]' in result.content
+ assert 'Got: /mcp list mem1' in result.content
+ assert 'docs' not in result.content
+ assert 'https://example/mcp' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_shows_a_recorded_failure_but_not_by_name(
+ self, isolate_home):
+ runtime = MockRuntime()
+ runtime._mcp_runtime = _FailureRuntime({
+ 'slow': _Failure('error', '6秒内没有连上'),
+ })
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/mcp add slow global url=http://10.255.255.1/mcp',
+ runtime))
+ listed = await router.dispatch(make_ctx('/mcp list', runtime))
+ assert ('[on] slow http://10.255.255.1/mcp 未连接 '
+ '6秒内没有连上') in listed.content
+ by_name = await router.dispatch(make_ctx('/mcp list slow', runtime))
+ assert 'Unknown list option.' in by_name.content
+ assert '6秒内没有连上' not in by_name.content
+ assert '10.255.255.1' not in by_name.content
+ assert runtime._mcp_runtime.connects == []
+
+ @pytest.mark.asyncio
+ async def test_get_shows_the_skip_line_without_connecting(
+ self, isolate_home):
+ runtime = MockRuntime()
+ runtime._mcp_runtime = _FailureRuntime({
+ 'slow': _Failure('error', '6秒内没有连上'),
+ })
+ await make_router().dispatch(
+ make_ctx(
+ '/mcp add slow global url=http://10.255.255.1/mcp',
+ runtime))
+ result = await make_router().dispatch(
+ make_ctx('/mcp get slow', runtime))
+ assert 'url: http://10.255.255.1/mcp' in result.content
+ assert 'status: 未连接' in result.content
+ assert 'last_error: 6秒内没有连上' in result.content
+ assert '本轮先不用' not in result.content
+ assert result.content.index('status:') > result.content.index('url:')
+ assert runtime._mcp_runtime.connects == []
+
+ @pytest.mark.asyncio
+ async def test_list_and_get_show_a_dead_process(self, isolate_home):
+ state = _Failure('connecting', '进程已退出')
+ state.process_exited = True
+ state.tool_count = 0
+ state.config = {'command': 'npx'}
+ runtime = MockRuntime()
+ runtime._mcp_runtime = _FailureRuntime({'mem': state})
+ await make_router().dispatch(
+ make_ctx('/mcp add mem global command=npx', runtime))
+ listed = await make_router().dispatch(make_ctx('/mcp list', runtime))
+ assert '[on] mem npx 进程已退出' in listed.content
+ assert '本轮先不用' not in listed.content
+ got = await make_router().dispatch(make_ctx('/mcp get mem', runtime))
+ assert 'status: 进程已退出' in got.content
+ assert 'tools: 0' in got.content
+ assert 'process: 不在' in got.content
+ assert '本轮先不用' not in got.content
+
+ @pytest.mark.asyncio
+ async def test_retry_reports_status_and_does_not_talk_in_chat(
+ self, isolate_home):
+ state = _Failure('error', '6秒内没有连上')
+ state.tool_count = 0
+
+ class _Retry(_FailureRuntime):
+
+ async def retry_server(self, name):
+ self.connects.append(name)
+ current = self._states[name]
+ current.status = 'connecting'
+ current.last_error = None
+ return current
+
+ runtime = MockRuntime()
+ runtime._mcp_runtime = _Retry({'slow': state})
+ await make_router().dispatch(
+ make_ctx(
+ '/mcp add slow global url=http://10.255.255.1/mcp',
+ runtime))
+ result = await make_router().dispatch(
+ make_ctx('/mcp retry slow', runtime))
+ assert result.content == 'slow: 启动中'
+ assert runtime._mcp_runtime.connects == ['slow']
+
+ @pytest.mark.asyncio
+ async def test_list_scope_word_names_the_other_copy(
+ self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ await make_router().dispatch(
+ make_ctx(
+ '/mcp add project global url=https://example/mcp',
+ runtime))
+ listed = await make_router().dispatch(
+ make_ctx('/mcp list project', runtime))
+ assert listed.content.startswith('名叫 project 的服务器不在这份清单里。')
+ assert 'https://example/mcp' not in listed.content
+
+ @pytest.mark.asyncio
+ async def test_get_shows_one_server(self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ mgr = MCPConfigManager(str(isolate_home), str(work))
+ mgr.add(
+ 'docs',
+ {
+ 'url': 'https://sk-THIS-IS-A-REAL-KEY-99@example/mcp',
+ 'headers': {
+ 'Authorization': 'Bearer sk-THIS-IS-A-REAL-KEY-99',
+ 'Content-Type': 'application/json',
+ },
+ 'env': {
+ 'PATH': '/usr/bin',
+ 'API_KEY': 'sk-THIS-IS-A-REAL-KEY-99',
+ },
+ },
+ scope='global',
+ )
+ mgr.add(
+ 'docs',
+ {'command': 'npx', 'args': ['-y', 'server']},
+ scope='project',
+ )
+ result = await make_router().dispatch(
+ make_ctx('/mcp get docs', runtime))
+ assert 'docs (global):' in result.content
+ assert 'docs (project):' in result.content
+ assert 'enabled: on' in result.content
+ assert 'url: https://[REDACTED:api_key]@example/mcp' in result.content
+ assert 'Content-Type: application/json' in result.content
+ assert 'PATH: /usr/bin' in result.content
+ assert 'command: npx' in result.content
+ assert 'args: -y server' in result.content
+ assert 'sk-THIS-IS-A-REAL-KEY-99' not in result.content
+ assert 'Authorization: ***' in result.content
+ assert 'API_KEY: ***' in result.content
+
+ @pytest.mark.asyncio
+ async def test_get_one_scope_and_a_missing_name(self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ await make_router().dispatch(
+ make_ctx(
+ '/mcp add project global url=https://example/mcp',
+ runtime))
+ one = await make_router().dispatch(
+ make_ctx('/mcp get project global', runtime))
+ assert 'project (global):' in one.content
+ assert 'url: https://example/mcp' in one.content
+ assert 'project (project):' not in one.content
+ missing = await make_router().dispatch(
+ make_ctx('/mcp get mem1', runtime))
+ assert 'No MCP server named mem1.' in missing.content
+ assert 'Need: /mcp get [global|project]' in missing.content
+
+ @pytest.mark.asyncio
+ async def test_update_changes_url(self, isolate_home):
+ runtime = MockRuntime()
+ router = make_router()
+ await router.dispatch(
+ make_ctx(
+ '/mcp add docs global url=https://example/mcp',
+ runtime=runtime))
+ result = await router.dispatch(
+ make_ctx(
+ '/mcp update docs global url=https://example/v2',
+ runtime=runtime))
+ assert 'Updated docs' in result.content
+ entry = MCPConfigManager(str(isolate_home), None).list('global')['docs']
+ assert entry['url'] == 'https://example/v2'
+
+
+class TestSkillsCommand:
+ @pytest.mark.asyncio
+ async def test_help(self):
+ result = await make_router().dispatch(make_ctx('/skills'))
+ assert result.content.startswith('No skills.')
+ assert '/skills add' in result.content
+
+ @pytest.mark.asyncio
+ async def test_add_copies_into_live_tree(self, tmp_path, isolate_home):
+ src = tmp_path / 'my-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# Hello\n')
+ runtime = MockRuntime()
+ result = await make_router().dispatch(
+ make_ctx(f'/skills add {src} global', runtime))
+ assert 'Imported: my-skill' in result.content
+ dest = SkillsConfigManager(str(isolate_home)).global_skills_tree()
+ assert (dest / 'my-skill' / 'SKILL.md').is_file()
+
+ @pytest.mark.asyncio
+ async def test_remove_deletes_managed_copy(self, tmp_path, isolate_home):
+ src = tmp_path / 'my-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# Hello\n')
+ runtime = MockRuntime()
+ router = make_router()
+ await router.dispatch(make_ctx(f'/skills add {src} global', runtime))
+ result = await router.dispatch(
+ make_ctx('/skills remove my-skill global', runtime))
+ assert 'Removed managed skill my-skill' in result.content
+ dest = SkillsConfigManager(str(isolate_home)).global_skills_tree()
+ assert not (dest / 'my-skill').exists()
+ # Original import source is untouched.
+ assert (src / 'SKILL.md').is_file()
+
+ @pytest.mark.asyncio
+ async def test_remove_non_managed_does_not_rmtree(self):
+ result = await make_router().dispatch(
+ make_ctx('/skills remove ghost global', MockRuntime()))
+ assert 'not a managed skill' in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_uses_the_loaded_runtime_before_disk(self, isolate_home):
+ class Loaded:
+ def list_all(self):
+ return [{
+ 'skill_id': 'from-session',
+ 'name': 'Loaded',
+ 'enabled': True,
+ }]
+
+ runtime = MockRuntime(_skill_runtime=Loaded())
+ result = await make_router().dispatch(
+ make_ctx('/skills list', runtime))
+ assert '[on] from-session Loaded' in result.content
+ assert 'No skills' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_scans_disk_before_skills_are_loaded(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx('/skills list', MockRuntime()))
+ assert 'from-session' not in result.content
+
+ def test_loaded_skills_are_published_on_the_session(self):
+ from types import SimpleNamespace
+
+ from ms_agent.agent.llm_agent import LLMAgent
+ agent = LLMAgent.__new__(LLMAgent)
+ agent.runtime = SimpleNamespace()
+ agent._skill_runtime = SimpleNamespace(marker=1)
+ agent._skill_catalog = SimpleNamespace()
+ agent._command_router = None
+ agent._plugin_runtime = None
+ agent._publish_skill_runtime()
+ assert agent.runtime._skill_runtime is agent._skill_runtime
+ agent.runtime = None
+ agent._publish_skill_runtime()
+
+ @pytest.mark.asyncio
+ async def test_disable_writes_skills_json_even_with_runtime(
+ self, isolate_home):
+ class FakeRuntime:
+ def toggle(self, skill_id, enabled):
+ raise AssertionError('must persist via SkillsConfigManager')
+
+ def list_all(self):
+ return []
+
+ def sync_with_config(self, _cfg):
+ pass
+
+ def reload_all(self):
+ pass
+
+ runtime = MockRuntime(_skill_runtime=FakeRuntime())
+ await make_router().dispatch(
+ make_ctx('/skills disable demo global', runtime))
+ data = SkillsConfigManager(str(isolate_home)).load_global()
+ assert 'demo' in data.get('disabled', [])
+
+ @pytest.mark.asyncio
+ async def test_add_without_scope_lands_on_this_machine(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'ask-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# ask\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx(f'/skills add {src}', runtime))
+ assert 'Imported: ask-skill (global)' in result.content
+ assert (isolate_home / 'skills' / 'ask-skill' / 'SKILL.md').is_file()
+ assert not (work / '.ms_agent' / 'skills' / 'ask-skill').exists()
+
+ @pytest.mark.asyncio
+ async def test_add_menu_can_place_the_skill_in_this_folder(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'ask-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# ask\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+
+ def choose(labels, header):
+ assert labels == ['这台机器', '这个文件夹']
+ assert header == '新技能放到哪里?'
+ return '这个文件夹'
+
+ result = await make_router().dispatch(
+ make_ctx(
+ f'/skills add {src}',
+ runtime,
+ extra={'choose_scope': choose}))
+ assert 'Imported: ask-skill (project)' in result.content
+ assert (work / '.ms_agent' / 'skills' / 'ask-skill' / 'SKILL.md').is_file()
+ assert not (isolate_home / 'skills' / 'ask-skill').exists()
+
+ @pytest.mark.asyncio
+ async def test_add_on_production_runtime_does_not_raise(
+ self, tmp_path, isolate_home):
+ from ms_agent.agent.runtime import Runtime
+ src = tmp_path / 'demo-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# demo\n')
+ result = await make_router().dispatch(
+ make_ctx(f'/skills add {src} global', Runtime()))
+ assert 'Imported: demo-skill (global)' in result.content
+ dest = SkillsConfigManager(str(isolate_home)).global_skills_tree()
+ assert (dest / 'demo-skill' / 'SKILL.md').is_file()
+
+ @pytest.mark.asyncio
+ async def test_add_without_work_dir_defaults_global(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'solo-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# solo\n')
+ result = await make_router().dispatch(
+ make_ctx(f'/skills add {src}', MockRuntime()))
+ assert 'Imported: solo-skill (global)' in result.content
+ dest = SkillsConfigManager(str(isolate_home)).global_skills_tree()
+ assert (dest / 'solo-skill' / 'SKILL.md').is_file()
+
+ @pytest.mark.asyncio
+ async def test_disable_without_scope_follows_the_added_copy(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'only-here'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# only\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ router = make_router()
+ await router.dispatch(
+ make_ctx(f'/skills add {src}', runtime))
+ result = await router.dispatch(
+ make_ctx('/skills disable only-here', runtime))
+ assert 'disable only-here (global)' in result.content
+ glob = SkillsConfigManager(str(isolate_home)).load_global()
+ assert 'only-here' in glob.get('disabled', [])
+ proj = SkillsConfigManager(str(isolate_home)).load_project(str(work))
+ assert 'only-here' not in proj.get('disabled', [])
+
+ @pytest.mark.asyncio
+ async def test_list_without_runtime_shows_live_tree_ids(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'shown-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# Shown\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ router = make_router()
+ await router.dispatch(
+ make_ctx(f'/skills add {src} global', runtime))
+ listed = await router.dispatch(make_ctx('/skills list', runtime))
+ assert 'shown-skill' in listed.content
+ assert listed.content.strip().startswith('Skills:')
+ assert 'Sources:' not in listed.content
+
+ @pytest.mark.asyncio
+ async def test_disable_skill_named_project_is_not_a_scope(
+ self, tmp_path, isolate_home):
+ src = tmp_path / 'project'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# project\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ router = make_router()
+ await router.dispatch(
+ make_ctx(f'/skills add {src} project', runtime))
+ result = await router.dispatch(
+ make_ctx('/skills disable project', runtime))
+ assert 'disable project (project)' in result.content
+ proj = SkillsConfigManager(str(isolate_home)).load_project(str(work))
+ assert 'project' in proj.get('disabled', [])
+
+ @pytest.mark.asyncio
+ async def test_disable_without_scope_follows_a_folder_copy(
+ self, tmp_path, isolate_home):
+ mgr = SkillsConfigManager(str(isolate_home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ skill = mgr.project_skills_tree(str(work)) / 'folder-only'
+ skill.mkdir(parents=True)
+ (skill / 'SKILL.md').write_text('# folder\n')
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx('/skills disable folder-only', runtime))
+ assert 'disable folder-only (project)' in result.content
+ project = mgr.load_project(str(work))
+ assert 'folder-only' in (project.get('disabled') or [])
+ assert 'folder-only' not in (mgr.load_global().get('disabled') or [])
+ assert not (mgr.global_skills_tree() / 'folder-only').exists()
+
+ @pytest.mark.asyncio
+ async def test_enable_without_scope_uses_existing_global_copy(
+ self, tmp_path, isolate_home):
+ mgr = SkillsConfigManager(str(isolate_home))
+ skill = mgr.global_skills_tree() / 'g-only'
+ skill.mkdir(parents=True)
+ (skill / 'SKILL.md').write_text('# G\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ router = make_router()
+ disabled = await router.dispatch(
+ make_ctx('/skills disable g-only global', runtime))
+ assert 'disable g-only (global)' in disabled.content
+ enabled = await router.dispatch(
+ make_ctx('/skills enable g-only', runtime))
+ assert 'enable g-only (global)' in enabled.content
+ assert 'g-only' not in (mgr.load_global().get('disabled') or [])
+ project = mgr.load_project(str(work))
+ assert 'g-only' not in (project.get('disabled') or [])
+
+ @pytest.mark.asyncio
+ async def test_remove_without_scope_deletes_existing_global_copy(
+ self, tmp_path, isolate_home):
+ mgr = SkillsConfigManager(str(isolate_home))
+ skill = mgr.global_skills_tree() / 'g-only'
+ skill.mkdir(parents=True)
+ (skill / 'SKILL.md').write_text('# G\n')
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx('/skills remove g-only', runtime))
+ assert 'Removed managed skill g-only' in result.content
+ assert 'not a managed skill' not in result.content
+ assert not skill.exists()
+
+ @pytest.mark.asyncio
+ async def test_enable_without_scope_asks_when_both_copies_exist(
+ self, tmp_path, isolate_home):
+ mgr = SkillsConfigManager(str(isolate_home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ for root in (
+ mgr.global_skills_tree(),
+ mgr.project_skills_tree(str(work)),
+ ):
+ skill = root / 'g-only'
+ skill.mkdir(parents=True)
+ (skill / 'SKILL.md').write_text('# G\n')
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx('/skills enable g-only', runtime))
+ assert 'both global and project' in result.content
+ assert 'Pass global or project.' in result.content
+ assert 'g-only' not in (mgr.load_global().get('disabled') or [])
+ project = mgr.load_project(str(work))
+ assert 'g-only' not in (project.get('disabled') or [])
+
+ @pytest.mark.asyncio
+ async def test_both_copies_menu_changes_only_the_chosen_one(
+ self, tmp_path, isolate_home):
+ mgr = SkillsConfigManager(str(isolate_home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ for root in (
+ mgr.global_skills_tree(),
+ mgr.project_skills_tree(str(work)),
+ ):
+ skill = root / 'g-only'
+ skill.mkdir(parents=True)
+ (skill / 'SKILL.md').write_text('# G\n')
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+
+ def choose(labels, header):
+ assert labels == ['这台机器', '这个文件夹']
+ assert '两处都有' in header
+ return '这个文件夹'
+
+ result = await make_router().dispatch(
+ make_ctx(
+ '/skills disable g-only',
+ runtime,
+ extra={'choose_scope': choose}))
+ assert 'disable g-only (project)' in result.content
+ assert 'g-only' not in (mgr.load_global().get('disabled') or [])
+ project = mgr.load_project(str(work))
+ assert 'g-only' in (project.get('disabled') or [])
+
+ @pytest.mark.asyncio
+ async def test_mcp_add_server_named_project(self, tmp_path, isolate_home):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ runtime = MockRuntime(
+ config=OmegaConf.create({'output_dir': str(work)}))
+ result = await make_router().dispatch(
+ make_ctx(
+ '/mcp add project url=https://example.invalid/mcp', runtime))
+ assert 'Added project (project)' in result.content
+ assert 'Need:' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_alias_skill_mgr(self):
+ result = await make_router().dispatch(make_ctx('/skill-mgr'))
+ assert '/skills add' in result.content
diff --git a/tests/command/test_scope.py b/tests/command/test_scope.py
new file mode 100644
index 000000000..2e045df7a
--- /dev/null
+++ b/tests/command/test_scope.py
@@ -0,0 +1,36 @@
+"""Scope helper: omit-token parsing and work-dir lookup."""
+from types import SimpleNamespace
+
+from omegaconf import OmegaConf
+
+from ms_agent.command.scope import parse_optional_scope, work_dir_of
+from ms_agent.command.types import CommandContext
+
+
+def test_parse_optional_scope_needs_a_name_before_the_token():
+ assert parse_optional_scope(['project']) == (None, ['project'])
+ assert parse_optional_scope(['demo', 'project']) == ('project', ['demo'])
+ assert parse_optional_scope(['/tmp/foo']) == (None, ['/tmp/foo'])
+ assert parse_optional_scope(['/tmp/foo', 'global']) == (
+ 'global', ['/tmp/foo'])
+
+
+def test_work_dir_prefers_llm_config():
+ ctx = CommandContext(
+ raw_input='/mcp',
+ command_name='mcp',
+ runtime=SimpleNamespace(
+ llm=SimpleNamespace(
+ config=OmegaConf.create({'output_dir': '/work'}))),
+ )
+ assert work_dir_of(ctx) == '/work'
+
+
+def test_work_dir_accepts_fake_agent_config():
+ ctx = CommandContext(
+ raw_input='/mcp',
+ command_name='mcp',
+ runtime=SimpleNamespace(
+ config=OmegaConf.create({'output_dir': '/agent'})),
+ )
+ assert work_dir_of(ctx) == '/agent'
diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py
new file mode 100644
index 000000000..8ec512fdd
--- /dev/null
+++ b/tests/command/test_search_cmds.py
@@ -0,0 +1,147 @@
+"""TUI /search writes the same tools.web_search block WebUI uses."""
+import json
+
+import pytest
+
+from ms_agent.command.builtin import register_builtin_commands
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import CommandContext
+
+
+def make_router():
+ router = CommandRouter()
+ register_builtin_commands(router)
+ return router
+
+
+def make_ctx(text, runtime=None):
+ router = make_router()
+ cmd, args = CommandRouter.parse_input(text)
+ return CommandContext(
+ raw_input=text,
+ command_name=cmd,
+ args=args,
+ source='cli',
+ runtime=runtime,
+ extra={'router': router},
+ )
+
+
+@pytest.fixture(autouse=True)
+def isolate_home(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ return home
+
+
+class TestSearchCommand:
+ @pytest.mark.asyncio
+ async def test_default_status(self, isolate_home):
+ result = await make_router().dispatch(make_ctx('/search'))
+ assert 'Engine: tavily' in result.content
+ assert '/search engine' in result.content
+ assert str(isolate_home) in result.content
+ assert 'WebUI' not in result.content
+ assert 'MS_AGENT_HOME' not in result.content
+
+ @pytest.mark.asyncio
+ async def test_list_marks_current(self):
+ result = await make_router().dispatch(make_ctx('/search list'))
+ assert '* tavily' in result.content
+ assert 'arxiv' in result.content
+
+ @pytest.mark.asyncio
+ async def test_engine_writes_settings_json(self, isolate_home):
+ result = await make_router().dispatch(
+ make_ctx('/search engine arxiv'))
+ assert 'Engine → arxiv' in result.content
+ assert '/new' not in result.content
+ assert 'restart' not in result.content
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['tools']['web_search']['engine'] == 'arxiv'
+
+ @pytest.mark.asyncio
+ async def test_key_is_per_engine(self, isolate_home):
+ await make_router().dispatch(make_ctx('/search engine exa'))
+ result = await make_router().dispatch(
+ make_ctx('/search key sk-exa-test'))
+ assert 'saved' in result.content
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['tools']['web_search']['exa_api_key'] == 'sk-exa-test'
+ await make_router().dispatch(make_ctx('/search engine tavily'))
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['tools']['web_search']['engine'] == 'tavily'
+ assert data['tools']['web_search']['exa_api_key'] == 'sk-exa-test'
+
+ @pytest.mark.asyncio
+ async def test_key_clear(self, isolate_home):
+ await make_router().dispatch(make_ctx('/search engine exa'))
+ await make_router().dispatch(make_ctx('/search key abc'))
+ result = await make_router().dispatch(make_ctx('/search key clear'))
+ assert 'cleared' in result.content
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert 'exa_api_key' not in data['tools']['web_search']
+
+ @pytest.mark.asyncio
+ async def test_arxiv_rejects_key(self):
+ await make_router().dispatch(make_ctx('/search engine arxiv'))
+ result = await make_router().dispatch(make_ctx('/search key nope'))
+ assert 'does not use an API key' in result.content
+
+ @pytest.mark.asyncio
+ async def test_disable(self, isolate_home):
+ result = await make_router().dispatch(make_ctx('/search disable'))
+ assert 'disabled' in result.content
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['tools']['web_search']['enabled'] is False
+
+ @pytest.mark.asyncio
+ async def test_missing_package_stays_keyless_and_says_how_to_install(
+ self, monkeypatch):
+ def missing(engine):
+ return {'arxiv': 'arxiv', 'exa': 'exa-py'}.get(engine)
+
+ monkeypatch.setattr(
+ 'ms_agent.tools.search.availability._missing_pip', missing)
+ await make_router().dispatch(make_ctx('/search engine arxiv'))
+ status = await make_router().dispatch(make_ctx('/search'))
+ sentence = (
+ 'UNAVAILABLE: Package `arxiv` is not installed. '
+ 'Run: pip install arxiv, then restart ms-agent.')
+ assert 'API key: not required' in status.content
+ assert sentence in status.content
+ head = status.content.split('usage:')[0].splitlines()
+ assert head[3] == sentence
+
+ listed = await make_router().dispatch(make_ctx('/search list'))
+ arxiv_row = next(line for line in listed.content.splitlines()
+ if ' arXiv ' in line)
+ assert 'key=n/a' in arxiv_row
+ assert 'keyless-ok' in arxiv_row
+ assert sentence in arxiv_row
+ tavily_row = next(line for line in listed.content.splitlines()
+ if ' Tavily ' in line)
+ assert 'keyless-ok' in tavily_row
+ assert 'UNAVAILABLE' not in tavily_row
+
+ await make_router().dispatch(make_ctx('/search engine exa'))
+ bare = await make_router().dispatch(make_ctx('/search'))
+ assert 'UNAVAILABLE' not in bare.content
+ await make_router().dispatch(make_ctx('/search key sk-test'))
+ keyed = await make_router().dispatch(make_ctx('/search'))
+ assert ('UNAVAILABLE: Package `exa-py` is not installed. '
+ 'Run: pip install exa-py, then restart ms-agent.'
+ ) in keyed.content
+
+ @pytest.mark.asyncio
+ async def test_engine_switch_does_not_need_runtime_config(
+ self, isolate_home):
+ from ms_agent.agent.runtime import Runtime
+ runtime = Runtime()
+ result = await make_router().dispatch(
+ make_ctx('/search engine serpapi', runtime))
+ assert 'Engine → serpapi' in result.content
+ data = json.loads((isolate_home / 'settings.json').read_text())
+ assert data['tools']['web_search']['engine'] == 'serpapi'
+ assert not hasattr(runtime, 'config')
diff --git a/tests/command/test_usage.py b/tests/command/test_usage.py
new file mode 100644
index 000000000..836f299de
--- /dev/null
+++ b/tests/command/test_usage.py
@@ -0,0 +1,30 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+from ms_agent.command.types import CommandContext
+from ms_agent.command.usage import arg_error
+
+
+def test_arg_error_is_one_syntax_line():
+ result = arg_error(
+ '/model catalog remove ',
+ reason='Unknown catalog action \'foo\'',
+ note='drop = remove',
+ got='/model catalog foo bar',
+ )
+ text = result.content
+ assert text.startswith("Unknown catalog action 'foo'.")
+ assert 'Need: /model catalog remove ' in text
+ assert 'Got: /model catalog foo bar' in text
+ assert 'drop = remove' in text
+ assert 'provider add' not in text
+
+
+def test_arg_error_fills_got_from_ctx():
+ ctx = CommandContext(
+ raw_input='/model provider key',
+ command_name='model',
+ args='provider key',
+ )
+ result = arg_error(
+ '/model provider key |clear', ctx=ctx)
+ assert 'Need: /model provider key |clear' in result.content
+ assert 'Got: /model provider key' in result.content
diff --git a/tests/config/test_model_settings.py b/tests/config/test_model_settings.py
index 2baabd3d2..cb2138d5a 100644
--- a/tests/config/test_model_settings.py
+++ b/tests/config/test_model_settings.py
@@ -25,8 +25,14 @@ def test_models_and_default(tmp_path):
assert 'a-2' in m.list_custom_providers()['acme']['models']
m.set_default_model('a-2', provider='acme')
assert m.get_default_model() == 'acme/a-2'
+ data = json.loads((tmp_path / 'settings.json').read_text())
+ assert data['llm']['provider'] == 'acme'
+ assert data['llm']['model'] == 'a-2'
+ m.set_default_model('acme glued-id', provider='acme')
+ assert m.get_default_model() == 'acme/glued-id'
m.remove_model('acme', 'a-2')
assert 'a-2' not in m.list_custom_providers()['acme']['models']
+ assert m.remove_model('acme', 'missing') is False
def test_add_model_materializes_a_provider_without_inventing_metadata(tmp_path):
@@ -52,13 +58,50 @@ def test_preserves_other_sections(tmp_path):
assert 'acme' in data['providers']
+def test_patch_provider_does_not_reset_protocol(tmp_path):
+ m = ModelSettingsManager(global_dir=str(tmp_path))
+ m.add_provider(
+ 'acme', protocol='anthropic', api_key='old',
+ base_url='https://old/v1')
+ m.patch_provider('acme', api_key='new')
+ entry = m.list_custom_providers()['acme']
+ assert entry['protocol'] == 'anthropic'
+ assert entry['api_key'] == 'new'
+ assert entry['base_url'] == 'https://old/v1'
+ m.patch_provider('acme', clear_api_key=True)
+ assert 'api_key' not in m.list_custom_providers()['acme']
+
+
def test_resolver_consumes_default_model():
from ms_agent.config.resolver import ConfigResolver
cfg = ConfigResolver._settings_to_agent_config(
{'default_model': 'deepseek/deepseek-chat'})
assert cfg.llm.service == 'deepseek'
assert cfg.llm.model == 'deepseek-chat'
+ glued = ConfigResolver._settings_to_agent_config(
+ {'default_model': 'minimax/minimax MiniMax-M2.1'})
+ assert glued.llm.service == 'minimax'
+ assert glued.llm.model == 'MiniMax-M2.1'
# explicit llm.model wins over default_model
cfg2 = ConfigResolver._settings_to_agent_config(
{'llm': {'model': 'pinned'}, 'default_model': 'deepseek/x'})
assert cfg2.llm.model == 'pinned'
+
+
+def test_resolver_copies_provider_catalog_credentials():
+ from ms_agent.config.resolver import ConfigResolver
+ cfg = ConfigResolver._settings_to_agent_config({
+ 'default_model': 'openai/qwen3.7-plus',
+ 'providers': {
+ 'openai': {
+ 'api_key': 'sk-cat',
+ 'base_url': 'https://example.invalid/v1',
+ 'protocol': 'openai',
+ },
+ },
+ })
+ assert cfg.llm.service == 'openai'
+ assert cfg.llm.model == 'qwen3.7-plus'
+ assert cfg.llm.openai_api_key == 'sk-cat'
+ assert cfg.llm.openai_base_url == 'https://example.invalid/v1'
+ assert cfg.llm.protocol == 'openai'
diff --git a/tests/config/test_search_settings.py b/tests/config/test_search_settings.py
new file mode 100644
index 000000000..1430d6054
--- /dev/null
+++ b/tests/config/test_search_settings.py
@@ -0,0 +1,73 @@
+import json
+
+from ms_agent.config.search_settings import SearchSettingsManager
+
+
+def test_default_engine_when_empty(tmp_path):
+ m = SearchSettingsManager(tmp_path)
+ cur = m.get()
+ assert cur.engine == 'tavily'
+ assert cur.enabled is True
+ assert cur.has_key is False
+ assert cur.supports_keyless is True
+
+
+def test_set_engine_preserves_other_keys(tmp_path):
+ m = SearchSettingsManager(tmp_path)
+ m.set_engine('exa')
+ m.set_api_key('exa-secret')
+ m.set_engine('tavily')
+ data = json.loads((tmp_path / 'settings.json').read_text())
+ block = data['tools']['web_search']
+ assert block['engine'] == 'tavily'
+ assert block['exa_api_key'] == 'exa-secret'
+ assert block.get('mcp') is False
+
+
+def test_clear_exa_key_drops_legacy_aliases(tmp_path):
+ p = tmp_path / 'settings.json'
+ p.write_text(json.dumps({
+ 'tools': {
+ 'web_search': {
+ 'engine': 'exa',
+ 'exa_api_key': 'new',
+ 'api_key': 'legacy',
+ }
+ }
+ }))
+ m = SearchSettingsManager(tmp_path)
+ assert m.get().has_key is True
+ m.set_api_key(None)
+ block = json.loads(p.read_text())['tools']['web_search']
+ assert 'exa_api_key' not in block
+ assert 'api_key' not in block
+ assert m.get().has_key is False
+
+
+def test_arxiv_rejects_key(tmp_path):
+ m = SearchSettingsManager(tmp_path)
+ m.set_engine('arxiv')
+ try:
+ m.set_api_key('x')
+ raise AssertionError('expected ValueError')
+ except ValueError as exc:
+ assert 'arxiv' in str(exc)
+
+
+def test_unknown_engine_rejected(tmp_path):
+ m = SearchSettingsManager(tmp_path)
+ try:
+ m.set_engine('bing')
+ raise AssertionError('expected ValueError')
+ except ValueError as exc:
+ assert 'Unknown' in str(exc)
+
+
+def test_preserves_unrelated_settings(tmp_path):
+ p = tmp_path / 'settings.json'
+ p.write_text(json.dumps({'theme': 'dark', 'llm': {'model': 'x'}}))
+ m = SearchSettingsManager(tmp_path)
+ m.set_engine('arxiv')
+ data = json.loads(p.read_text())
+ assert data['theme'] == 'dark'
+ assert data['llm']['model'] == 'x'
diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/tests/e2e/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 000000000..b06cd5691
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,139 @@
+"""Shared fixtures for TUI / WebUI ledger e2e tests.
+
+Both sides must share one ``MS_AGENT_HOME``. These tests never touch
+``~/.ms_agent``.
+"""
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.prompting import workspace_files as wf
+
+from tests.e2e.helpers import DummyLLM
+
+SDK_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_WEBUI_BACKEND = Path(
+ os.environ.get(
+ 'MS_AGENT_WEBUI_BACKEND',
+ '/Users/luyan/workspace/ms-agent-webui-feat-tui-align/backend',
+ ))
+
+try:
+ from dotenv import load_dotenv
+ load_dotenv(SDK_ROOT / '.env')
+except Exception:
+ pass
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ 'markers',
+ 'live: hits a real model / search provider (needs API key in .env)',
+ )
+ config.addinivalue_line(
+ 'markers',
+ 'usability: user/developer-facing contract that may xfail on product bugs',
+ )
+
+
+@pytest.fixture
+def isolated_home(tmp_path, monkeypatch):
+ home = tmp_path / 'ms_agent_home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ monkeypatch.delenv('MS_AGENT_LLM_MODEL', raising=False)
+ monkeypatch.delenv('MS_AGENT_LLM_PROVIDER', raising=False)
+ wf.reset_cache()
+ yield home
+ wf.reset_cache()
+
+
+@pytest.fixture
+def work_dir(tmp_path):
+ work = tmp_path / 'align-work'
+ work.mkdir()
+ return work
+
+
+@pytest.fixture
+def stub_llm_rebuild(monkeypatch):
+ """Let ``/model `` persist without talking to a real gateway."""
+
+ def _from_config(config):
+ return DummyLLM(
+ config,
+ str(OmegaConf.select(config, 'llm.model', default='') or ''),
+ )
+
+ monkeypatch.setattr('ms_agent.llm.LLM.from_config', _from_config)
+ return _from_config
+
+
+def webui_backend() -> Path:
+ if not DEFAULT_WEBUI_BACKEND.is_dir():
+ pytest.skip(f'WebUI backend not found: {DEFAULT_WEBUI_BACKEND}')
+ return DEFAULT_WEBUI_BACKEND
+
+
+def _ensure_webui_on_path() -> Path:
+ backend = webui_backend()
+ inserted = str(backend)
+ if inserted not in sys.path:
+ sys.path.insert(0, inserted)
+ return backend
+
+
+@pytest.fixture
+def webui(isolated_home):
+ """Import GitLab WebUI adapters against the isolated home.
+
+ Adapters read ``MS_AGENT_HOME`` on each call, so they see the same
+ ledger TUI slash commands write.
+ """
+ _ensure_webui_on_path()
+ from app.backends.ms_agent import (
+ agent_settings,
+ instructions,
+ mcps,
+ profile,
+ projects,
+ providers,
+ search,
+ sessions,
+ skills,
+ )
+ return SimpleNamespace(
+ agent_settings=agent_settings,
+ instructions=instructions,
+ mcps=mcps,
+ profile=profile,
+ projects=projects,
+ providers=providers,
+ search=search,
+ sessions=sessions,
+ skills=skills,
+ )
+
+
+@pytest.fixture
+def webui_client(isolated_home):
+ """HTTP TestClient for the GitLab WebUI backend (same isolated home)."""
+ _ensure_webui_on_path()
+ pytest.importorskip('fastapi')
+ from fastapi.testclient import TestClient
+
+ from app.main import create_app
+
+ with TestClient(create_app()) as client:
+ yield client
+ try:
+ from app.backends.ms_agent.skill_index import skill_index
+ skill_index.stop()
+ except Exception:
+ pass
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
new file mode 100644
index 000000000..adb0473ea
--- /dev/null
+++ b/tests/e2e/helpers.py
@@ -0,0 +1,89 @@
+"""Helpers for TUI / WebUI ledger e2e tests (imported by tests, not conftest)."""
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import SimpleNamespace
+
+from omegaconf import OmegaConf
+
+from ms_agent.command.builtin import register_builtin_commands
+from ms_agent.command.router import CommandRouter
+from ms_agent.command.types import CommandContext
+
+
+def make_router() -> CommandRouter:
+ router = CommandRouter()
+ register_builtin_commands(router)
+ return router
+
+
+def make_ctx(text: str, runtime=None) -> CommandContext:
+ router = make_router()
+ cmd, args = CommandRouter.parse_input(text)
+ return CommandContext(
+ raw_input=text,
+ command_name=cmd,
+ args=args,
+ source='tui',
+ runtime=runtime,
+ extra={'router': router},
+ )
+
+
+async def slash(text: str, runtime=None):
+ ctx = make_ctx(text, runtime)
+ return await ctx.extra['router'].dispatch(ctx)
+
+
+def run_slash(text: str, runtime=None):
+ """Sync wrapper so e2e tests do not require pytest-asyncio."""
+ return asyncio.run(slash(text, runtime))
+
+
+def runtime_for(work: Path):
+ return SimpleNamespace(
+ config=OmegaConf.create({'output_dir': str(work)}),
+ tool_manager=None,
+ _skill_runtime=None,
+ memory_tools=[],
+ llm=None,
+ )
+
+
+@dataclass
+class DummyLLM:
+ config: object
+ model: str = ''
+
+ def __post_init__(self):
+ if not self.model:
+ self.model = str(
+ OmegaConf.select(self.config, 'llm.model', default='') or '')
+
+
+@dataclass
+class ModelRuntime:
+ work: Path
+ model: str = 'qwen3.7-plus'
+ service: str = 'openai'
+ api_key: str = 'sk-test-not-real'
+ memory_tools: list = field(default_factory=list)
+ tool_manager: object = None
+ _skill_runtime: object = None
+
+ def __post_init__(self):
+ self.config = OmegaConf.create({
+ 'output_dir': str(self.work),
+ 'llm': {
+ 'service': self.service,
+ 'model': self.model,
+ 'openai_api_key': self.api_key,
+ 'use_provider_router': True,
+ },
+ })
+ self.llm = DummyLLM(self.config, self.model)
+
+ async def load_memory(self):
+ self.memory_tools.append('loaded')
diff --git a/tests/e2e/test_tui_webui_align.py b/tests/e2e/test_tui_webui_align.py
new file mode 100644
index 000000000..65baebaf9
--- /dev/null
+++ b/tests/e2e/test_tui_webui_align.py
@@ -0,0 +1,571 @@
+"""TUI / WebUI settings-ledger e2e (docs/tui-webui-align-e2e.md).
+
+Alignment means both surfaces read and write the same files under one
+``MS_AGENT_HOME``, not identical UIs. Disk is the source of truth; WebUI
+adapters / HTTP are the other reader.
+
+These tests drive the same slash router the TUI uses, then assert the ledger
+and (when present) the GitLab WebUI backend.
+"""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.config.config import Config
+from ms_agent.project import SessionManager
+from ms_agent.tui.app import TuiApp
+
+from tests.e2e.helpers import ModelRuntime, runtime_for, run_slash
+
+
+def _settings(home: Path) -> dict:
+ path = home / 'settings.json'
+ if not path.is_file():
+ return {}
+ return json.loads(path.read_text(encoding='utf-8'))
+
+
+# ---------------------------------------------------------------------------
+# A / period 0 — same folder, same project, same sessions
+# ---------------------------------------------------------------------------
+
+
+class TestProjectAndSession:
+ def test_a1_same_folder_is_one_project(self, isolated_home, work_dir,
+ webui):
+ from ms_agent.project.manager import ProjectManager
+
+ created = webui.projects.create_project(
+ __import__(
+ 'app.schemas.project', fromlist=['ProjectCreate']
+ ).ProjectCreate(name='from-webui', local_path=str(work_dir)))
+ tui = TuiApp._open_project(str(work_dir))
+ assert tui.id == created.id
+ listed = ProjectManager(base_dir=str(isolated_home)).get(tui.id)
+ assert listed is not None
+ assert Path(listed.path).resolve() == work_dir.resolve()
+
+ def test_a2_webui_session_is_resumable_in_tui(self, isolated_home,
+ work_dir, webui):
+ from app.schemas.project import ProjectCreate
+ from app.schemas.session import SessionCreate
+
+ project = webui.projects.create_project(
+ ProjectCreate(name='shared', local_path=str(work_dir)))
+ session = webui.sessions.create_session(
+ SessionCreate(title='web chat', project_id=project.id))
+ sdk = SessionManager(TuiApp._open_project(str(work_dir)))
+ sdk.get_session_log(sdk.get(session.id)).append(
+ {'role': 'user', 'content': 'hello from web'})
+
+ tui_proj = TuiApp._open_project(str(work_dir))
+ tui_sm = SessionManager(tui_proj)
+ assert session.id in {s.id for s in tui_sm.list()}
+ msgs = tui_sm.get_session_log(tui_sm.get(session.id)).get_all_messages()
+ assert any(m.get('content') == 'hello from web' for m in msgs)
+
+ app = TuiApp.__new__(TuiApp)
+ app._sm = tui_sm
+ app.session = None
+ found = app._resume_target(session.id)
+ assert found is not None and found.id == session.id
+ by_index = app._resume_target('0')
+ assert by_index is not None and by_index.id == session.id
+
+ def test_a3_tui_session_shows_up_in_webui(self, isolated_home, work_dir,
+ webui):
+ from app.schemas.project import ProjectCreate
+
+ tui_proj = TuiApp._open_project(str(work_dir))
+ sess = SessionManager(tui_proj).create(name='from tui')
+ SessionManager(tui_proj).get_session_log(sess).append(
+ {'role': 'user', 'content': 'hello from tui first'})
+
+ projects = webui.projects.list_projects()
+ assert any(p.id == tui_proj.id for p in projects)
+ # Opening the same folder through WebUI must not mint a second id.
+ again = webui.projects.create_project(
+ ProjectCreate(name='reopen', local_path=str(work_dir)))
+ assert again.id == tui_proj.id
+ ids = [s.id for s in webui.sessions.list_sessions(tui_proj.id)]
+ assert sess.id in ids
+
+ def test_a4_empty_home_does_not_mcp_todo_list(self, isolated_home,
+ work_dir):
+ cfg = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work_dir), explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work_dir))
+ TuiApp._bind_todo_list_session(cfg, str(work_dir / 'sess'))
+ assert cfg.tools.todo_list.mcp is False
+ servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers']
+ assert 'todo_list' not in servers
+
+ def test_a5_default_tui_uses_webui_default_model(self, isolated_home,
+ work_dir, webui):
+ from app.backends.ms_agent import model_link
+
+ model_link.set_active_model('openai', 'qwen3.7-plus')
+ data = _settings(isolated_home)
+ assert data.get('default_model') == 'openai/qwen3.7-plus'
+ cfg = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work_dir), explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work_dir))
+ assert cfg.llm.service == 'openai'
+ assert cfg.llm.model == 'qwen3.7-plus'
+
+ def test_a6_explicit_config_not_overridden_by_settings(
+ self, isolated_home, work_dir, tmp_path):
+ (isolated_home / 'settings.json').write_text(
+ json.dumps({
+ 'default_model': 'openai/qwen3.7-plus',
+ 'llm': {
+ 'provider': 'openai',
+ 'model': 'qwen3.7-plus',
+ },
+ }),
+ encoding='utf-8')
+ yaml_path = tmp_path / 'custom.yaml'
+ yaml_path.write_text(
+ 'llm:\n service: modelscope\n model: from-yaml\n'
+ 'tools:\n file_system:\n mcp: false\n',
+ encoding='utf-8')
+ cfg = TuiApp._load_runtime_config(
+ str(yaml_path), str(work_dir), explicit_config=True)
+ assert cfg.llm.model == 'from-yaml'
+ assert cfg.llm.service == 'modelscope'
+
+ @pytest.mark.live
+ def test_a5_live_first_turn_uses_seeded_model(self, isolated_home,
+ work_dir):
+ key = os.environ.get('DASHSCOPE_API_KEY', '').strip()
+ if not key:
+ pytest.skip('DASHSCOPE_API_KEY missing')
+ (isolated_home / 'settings.json').write_text(
+ json.dumps({
+ 'default_model': 'dashscope/qwen3.7-plus',
+ 'llm': {
+ 'provider': 'dashscope',
+ 'model': 'qwen3.7-plus',
+ },
+ 'providers': {
+ 'dashscope': {
+ 'api_key': key,
+ 'protocol': 'openai',
+ },
+ },
+ }),
+ encoding='utf-8')
+ cfg = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work_dir), explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work_dir))
+ assert cfg.llm.model == 'qwen3.7-plus'
+ assert cfg.llm.service == 'dashscope'
+ OmegaConf.update(cfg, 'generation_config.stream', False, merge=True)
+ OmegaConf.update(cfg, 'generation_config.max_tokens', 32, merge=True)
+ from ms_agent.llm import LLM
+ from ms_agent.llm.utils import Message
+
+ llm = LLM.from_config(cfg)
+ assert getattr(llm, 'model', '') in ('qwen3.7-plus', cfg.llm.model)
+ reply = llm.generate(
+ messages=[
+ Message(role='user', content='Reply with exactly: pong'),
+ ],
+ tools=None)
+ if hasattr(reply, '__iter__') and not hasattr(reply, 'content'):
+ chunk = None
+ for chunk in reply:
+ pass
+ reply = chunk
+ assert reply is not None and reply.content
+ assert 'sk-' not in (reply.content or '')
+
+
+# ---------------------------------------------------------------------------
+# B / period 3 — model providers
+# ---------------------------------------------------------------------------
+
+
+class TestModelLedger:
+ def test_b1_list_hides_plaintext_key(self, isolated_home, work_dir,
+ stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-secret-plaintext '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ listed = run_slash('/model list', rt)
+ assert 'acme' in listed.content
+ assert 'sk-secret-plaintext' not in listed.content
+ assert 'key=sk-s****text' in listed.content
+ settings = _settings(isolated_home)
+ assert settings['providers']['acme']['api_key'] == 'sk-secret-plaintext'
+
+ def test_b2_switch_updates_default_model(self, isolated_home,
+ work_dir,
+ stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-acme '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ run_slash('/model catalog add acme a-1', rt)
+ result = run_slash('/model acme a-1', rt)
+ assert 'a-1' in result.content
+ data = _settings(isolated_home)
+ assert data.get('default_model') == 'acme/a-1'
+ shown = run_slash('/model', rt)
+ assert 'a-1' in shown.content
+
+ def test_b3_b7_provider_and_catalog_visible_to_webui(
+ self, isolated_home, work_dir, stub_llm_rebuild, webui):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-test-acme '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ run_slash('/model catalog add acme a-1', rt)
+ rows = {p.id: p for p in webui.providers.list_providers()}
+ assert 'acme' in rows
+ assert rows['acme'].base_url == 'https://example.invalid/v1'
+ assert 'sk-test-acme' not in (rows['acme'].api_key_masked or '')
+ data = _settings(isolated_home)
+ assert data['providers']['acme']['models'] == ['a-1']
+
+ def test_b4_key_update_and_b6_patch_preserves_other_fields(
+ self, isolated_home, work_dir, stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-old '
+ 'url=https://example.invalid/v1 protocol=openai name=Acme',
+ rt)
+ run_slash('/model provider key acme sk-new', rt)
+ run_slash('/model provider set acme protocol=anthropic', rt)
+ entry = _settings(isolated_home)['providers']['acme']
+ assert entry['api_key'] == 'sk-new'
+ assert entry['base_url'] == 'https://example.invalid/v1'
+ assert entry['protocol'] == 'anthropic'
+ assert entry.get('name') == 'Acme'
+
+ def test_b8_cannot_delete_builtin_openai(self, isolated_home,
+ work_dir,
+ stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ result = run_slash('/model provider remove openai', rt)
+ assert 'Cannot remove' in result.content
+ listed = run_slash('/model list', rt)
+ assert 'openai' in listed.content
+ run_slash('/model provider key openai sk-override', rt)
+ cleared = run_slash('/model provider remove openai', rt)
+ assert 'Builtin catalog remains' in cleared.content
+ listed = run_slash('/model list', rt)
+ assert 'openai' in listed.content
+ assert 'sk-override' not in listed.content
+
+
+# ---------------------------------------------------------------------------
+# C / period 1 — search
+# ---------------------------------------------------------------------------
+
+
+class TestSearchLedger:
+ def test_c1_default_engine_tavily(self, isolated_home):
+ status = run_slash('/search')
+ assert 'Engine: tavily' in status.content
+ listed = run_slash('/search list')
+ assert '* tavily' in listed.content
+
+ def test_c2_c4_engine_switch_keeps_other_keys(self, isolated_home,
+ webui):
+ run_slash('/search engine exa')
+ run_slash('/search key sk-test-exa')
+ run_slash('/search engine tavily')
+ block = _settings(isolated_home)['tools']['web_search']
+ assert block['engine'] == 'tavily'
+ assert block['exa_api_key'] == 'sk-test-exa'
+ ui = webui.search.get_settings()
+ assert ui.provider == 'tavily'
+
+ from app.schemas.search import SearchSettingsUpdate
+ webui.search.update_settings(
+ SearchSettingsUpdate(enabled=True, provider='exa'))
+ status = run_slash('/search')
+ assert 'Engine: exa' in status.content
+ listed = run_slash('/search list')
+ assert '* exa' in listed.content
+ assert 'key=set' in listed.content
+
+ def test_c3_arxiv_rejects_key(self, isolated_home, webui):
+ run_slash('/search engine arxiv')
+ result = run_slash('/search key nope')
+ assert 'does not use an API key' in result.content
+ block = _settings(isolated_home)['tools']['web_search']
+ assert 'arxiv_api_key' not in block
+ providers = {p.id: p for p in webui.search.list_providers()}
+ assert providers['arxiv'].requires_key is False
+ ui = webui.search.get_settings()
+ assert ui.provider == 'arxiv'
+
+ def test_c5_enable_disable(self, isolated_home, webui):
+ run_slash('/search disable')
+ assert _settings(isolated_home)['tools']['web_search']['enabled'] is False
+ assert webui.search.get_settings().enabled is False
+ run_slash('/search enable')
+ assert webui.search.get_settings().enabled is True
+
+ def test_c6_webui_to_tui_engine(self, isolated_home, webui):
+ from app.schemas.search import SearchSettingsUpdate
+ webui.search.update_settings(
+ SearchSettingsUpdate(enabled=True, provider='serpapi'))
+ status = run_slash('/search')
+ assert 'Engine: serpapi' in status.content
+ listed = run_slash('/search list')
+ assert '* serpapi' in listed.content
+
+
+# ---------------------------------------------------------------------------
+# D / period 2 — instructions + profile
+# ---------------------------------------------------------------------------
+
+
+class TestInstructionAndProfile:
+ def test_d1_d2_global_instruction_shared(self, isolated_home, webui):
+ result = run_slash('/instruction global Always answer in French.')
+ assert 'saved' in result.content.lower()
+ text = (isolated_home / 'AGENTS.md').read_text(encoding='utf-8')
+ assert 'Always answer in French.' in text
+ assert text.lstrip().startswith('---')
+ from app.schemas.instruction import InstructionUpsert
+ ui = webui.instructions.get_instruction('global')
+ assert 'Always answer in French.' in ui.content
+ webui.instructions.upsert_instruction(
+ 'global', InstructionUpsert(content='Be terse.'))
+ shown = run_slash('/instruction global')
+ assert 'Be terse.' in shown.content
+
+ def test_d3_project_instruction_never_writes_repo_root(
+ self, isolated_home, work_dir, webui):
+ root = work_dir / 'AGENTS.md'
+ root.write_text('# team\nkeep me at root\n', encoding='utf-8')
+ rt = runtime_for(work_dir)
+ TuiApp._open_project(str(work_dir))
+ run_slash('/instruction project This project uses FastAPI.', rt)
+ private = work_dir / '.ms_agent' / 'AGENTS.md'
+ assert private.read_text(encoding='utf-8').strip() == (
+ 'This project uses FastAPI.')
+ assert root.read_text(encoding='utf-8') == '# team\nkeep me at root\n'
+ shown = run_slash('/instruction', rt)
+ assert 'FastAPI' in shown.content
+ assert 'never writes' in shown.content.lower() or 'Repo-root' in shown.content
+
+ def test_d4_d6_profile_fields_independent(self, isolated_home, webui):
+ run_slash('/profile callme Alice')
+ run_slash('/profile about I work on agents.')
+ shown = run_slash('/profile')
+ assert 'Alice' in shown.content
+ assert 'I work on agents.' in shown.content
+ ui = webui.profile.get_profile()
+ assert ui.agent_calls_user == 'Alice'
+ assert 'I work on agents.' in (ui.description or '')
+ run_slash('/profile callme clear')
+ call_text = (isolated_home / 'PROFILE.md').read_text(encoding='utf-8')
+ assert 'Alice' not in call_text
+ assert 'I work on agents.' in call_text
+ ui = webui.profile.get_profile()
+ assert not ui.agent_calls_user
+ assert 'I work on agents.' in (ui.description or '')
+
+
+# ---------------------------------------------------------------------------
+# E / period 4 — MCP
+# ---------------------------------------------------------------------------
+
+
+class TestMcpLedger:
+ def test_e1_e6_http_crud_matches_webui(self, isolated_home, webui):
+ rt = runtime_for(Path('.'))
+ added = run_slash(
+ '/mcp add docs global url=https://example.invalid/mcp', rt)
+ assert 'Added docs' in added.content
+ listed = run_slash('/mcp list global', rt)
+ assert 'docs' in listed.content
+ assert 'https://example.invalid/mcp' in listed.content
+ ui = {m.name: m for m in webui.mcps.list_mcps('global')}
+ assert ui['docs'].endpoint == 'https://example.invalid/mcp'
+
+ updated = run_slash(
+ '/mcp update docs global url=https://example.invalid/v2', rt)
+ assert 'Updated docs' in updated.content
+ mcp_json = json.loads(
+ (isolated_home / 'mcp.json').read_text(encoding='utf-8'))
+ servers = mcp_json.get('mcpServers') or mcp_json
+ assert servers['docs']['url'] == 'https://example.invalid/v2'
+ ui = {m.name: m for m in webui.mcps.list_mcps('global')}
+ assert ui['docs'].endpoint == 'https://example.invalid/v2'
+ assert len(ui) == 1
+
+ run_slash('/mcp disable docs global', rt)
+ listed = run_slash('/mcp list global', rt)
+ assert '[off] docs' in listed.content
+ run_slash('/mcp remove docs global', rt)
+ ui = {m.name: m for m in webui.mcps.list_mcps('global')}
+ assert 'docs' not in ui
+
+ def test_e3_stdio_splits_command_args(self, isolated_home):
+ rt = runtime_for(Path('.'))
+ run_slash(
+ '/mcp add fetch global command="npx -y @mcp/server-fetch"', rt)
+ mcp_json = json.loads(
+ (isolated_home / 'mcp.json').read_text(encoding='utf-8'))
+ servers = mcp_json.get('mcpServers') or mcp_json
+ assert servers['fetch']['command'] == 'npx'
+ assert servers['fetch']['args'] == ['-y', '@mcp/server-fetch']
+
+
+# ---------------------------------------------------------------------------
+# F / period 4 — skills
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsLedger:
+ def test_f1_f4_import_remove_keeps_source(self, isolated_home,
+ tmp_path, webui):
+ src = tmp_path / 'demo-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text(
+ '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8')
+ rt = runtime_for(tmp_path / 'work')
+ (tmp_path / 'work').mkdir()
+ added = run_slash(f'/skills add {src} global', rt)
+ assert 'Imported' in added.content
+ dest = isolated_home / 'skills' / 'demo-skill' / 'SKILL.md'
+ assert dest.is_file()
+ assert (src / 'SKILL.md').is_file()
+ ui_names = {s.name for s in webui.skills.list_skills('global')}
+ assert 'demo-skill' in ui_names
+ removed = run_slash('/skills remove demo-skill global', rt)
+ assert 'Removed managed skill' in removed.content
+ assert not dest.parent.exists()
+ assert (src / 'SKILL.md').is_file()
+
+ def test_f5_remove_auto_discovered_refuses_rmtree(
+ self, isolated_home, tmp_path, work_dir):
+ discovered = work_dir / '.agents' / 'skills' / 'local-skill'
+ discovered.mkdir(parents=True)
+ (discovered / 'SKILL.md').write_text('# local\n', encoding='utf-8')
+ rt = runtime_for(work_dir)
+ result = run_slash('/skills remove local-skill global', rt)
+ assert 'not a managed skill' in result.content.lower()
+ assert (discovered / 'SKILL.md').is_file()
+
+
+# ---------------------------------------------------------------------------
+# G / period 5 — memory
+# ---------------------------------------------------------------------------
+
+
+class TestMemoryLedger:
+ def test_g1_g3_global_default_inherits_on_new_folder(
+ self, isolated_home, tmp_path, work_dir):
+ status = run_slash('/memory')
+ assert 'Global default:' in status.content
+ assert 'Project:' in status.content
+ run_slash('/memory global on')
+ data = _settings(isolated_home)
+ assert data['personalization']['memory_enabled'] is True
+ existing = TuiApp._open_project(str(work_dir))
+ run_slash('/memory global off')
+ # Already-registered project must not flip with the global default.
+ from ms_agent.project.manager import ProjectManager
+ still = ProjectManager(base_dir=str(isolated_home)).get(existing.id)
+ assert still.memory_enabled == existing.memory_enabled
+ run_slash('/memory global on')
+ fresh = tmp_path / 'brand-new'
+ fresh.mkdir()
+ inherited = TuiApp._open_project(str(fresh))
+ assert inherited.memory_enabled is True
+
+ def test_g2_g4_project_toggle_webui_and_config(
+ self, isolated_home, work_dir, webui):
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ result = run_slash('/memory on', rt)
+ assert 'Project memory → on' in result.content
+ from ms_agent.project.manager import ProjectManager
+ project = ProjectManager(base_dir=str(isolated_home)).find_by_path(
+ str(work_dir))
+ assert project.memory_enabled is True
+ assert rt._memory_pending is True
+ assert OmegaConf.select(
+ rt.config, 'memory.unified_memory', default=None) is None
+ ui = webui.projects.get_project(project.id)
+ assert ui.memory_enabled is True
+
+ def test_g5_vector_does_not_silent_file_fallback(
+ self, isolated_home, work_dir):
+ from ms_agent.project.manager import ProjectManager
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ run_slash('/memory backend vector', rt)
+ data = _settings(isolated_home)
+ assert data['personalization']['memory_backend'] == 'vector'
+ pm = ProjectManager(base_dir=str(isolated_home))
+ project = pm.find_by_path(str(work_dir))
+ assert project.memory_backend != 'vector'
+ run_slash('/memory project backend vector', rt)
+ project = pm.find_by_path(str(work_dir))
+ assert project.memory_backend == 'vector'
+ run_slash('/memory on', rt)
+ assert OmegaConf.select(rt.config, 'memory', default=None) is None
+ memory_md = work_dir / '.ms_agent' / 'memory' / 'MEMORY.md'
+ assert not memory_md.exists()
+
+ def test_g6_webui_enables_project_memory_for_tui(
+ self, isolated_home, work_dir, webui):
+ from app.schemas.project import ProjectCreate, ProjectUpdate
+ created = webui.projects.create_project(
+ ProjectCreate(
+ name='mem',
+ local_path=str(work_dir),
+ memory_enabled=True,
+ memory_backend='file',
+ ))
+ webui.projects.update_project(
+ created.id, ProjectUpdate(memory_enabled=True))
+ tui = TuiApp._open_project(str(work_dir))
+ assert tui.id == created.id
+ assert tui.memory_enabled is True
+ status = run_slash('/memory', runtime_for(work_dir))
+ assert 'Project: on' in status.content
+
+ def test_g7_disable_arms_the_next_message(self, isolated_home, work_dir):
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ rt.memory_tools = ['already-loaded']
+ result = run_slash('/memory off', rt)
+ assert 'Project memory → off' in result.content
+ assert '/new' not in result.content
+ assert rt._memory_pending is True
+
+
+class TestHttpEnvelope:
+ def test_tui_mcp_is_visible_over_webui_http(self, isolated_home,
+ webui_client):
+ run_slash(
+ '/mcp add docs global url=https://example.invalid/mcp',
+ runtime_for(Path('.')))
+ resp = webui_client.get('/api/mcps', params={'scope': 'global'})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body['code'] == 0
+ names = [row['name'] for row in body['data']]
+ assert 'docs' in names
+ docs = next(row for row in body['data'] if row['name'] == 'docs')
+ assert docs['endpoint'] == 'https://example.invalid/mcp'
+ dumped = json.dumps(body)
+ assert 'sk-' not in dumped
diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py
new file mode 100644
index 000000000..6028e80b5
--- /dev/null
+++ b/tests/e2e/test_tui_webui_usability.py
@@ -0,0 +1,365 @@
+"""Usability e2e: prompts, defaults, and acceptance-doc conflicts.
+
+These tests are written from a user/developer seat, not from the
+implementation. A case marked ``xfail`` is a product issue we want fixed;
+unmarked failures mean a regression. Comments that mention ``e2e.md`` call
+out pass criteria that are themselves misleading.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from ms_agent.project.manager import ProjectManager
+from ms_agent.tui.app import TuiApp
+
+from tests.e2e.helpers import ModelRuntime, runtime_for, run_slash
+
+
+def _assert_live_home(content: str, home) -> None:
+ """Help shows the resolved path, not a hardcoded home or another product."""
+ home = str(home)
+ assert '~/.ms_agent' not in content, (
+ f'help still says ~/.ms_agent while the live home is {home}')
+ assert home in content, (
+ f'help should show the live path {home} so a person can open the file')
+ assert 'WebUI' not in content
+ assert 'MS_AGENT_HOME' not in content
+
+
+class TestHelpTextLiesAboutHome:
+ """Slash help shows the live config path, not ``~/.ms_agent`` or WebUI."""
+
+ @pytest.mark.usability
+ def test_search_help_mentions_effective_home(self, isolated_home):
+ _assert_live_home(run_slash('/search').content, isolated_home)
+
+ @pytest.mark.usability
+ def test_instruction_help_mentions_effective_home(
+ self, isolated_home, work_dir):
+ _assert_live_home(
+ run_slash('/instruction', runtime_for(work_dir)).content,
+ isolated_home)
+
+ @pytest.mark.usability
+ def test_profile_help_mentions_effective_home(self, isolated_home):
+ _assert_live_home(run_slash('/profile').content, isolated_home)
+
+ @pytest.mark.usability
+ def test_mcp_help_mentions_effective_home(self, isolated_home):
+ _assert_live_home(run_slash('/mcp help').content, isolated_home)
+
+ @pytest.mark.usability
+ def test_skills_help_mentions_effective_home(self, isolated_home):
+ _assert_live_home(run_slash('/skills help').content, isolated_home)
+
+ @pytest.mark.usability
+ def test_model_help_mentions_effective_home(
+ self, isolated_home, work_dir):
+ _assert_live_home(
+ run_slash('/model help', ModelRuntime(work_dir)).content,
+ isolated_home)
+
+
+class TestBareCommandShape:
+ """Bare ``/cmd`` is the current status, then the usage sheet.
+
+ ``/cmd help`` is usage only. Action commands (/new, /quit, /stop,
+ /compact) and dumps (/help, /config) stay as they are.
+ """
+
+ @pytest.mark.usability
+ def test_bare_skills_is_status_then_usage(self, isolated_home):
+ result = run_slash('/skills')
+ assert result.content.strip().startswith('No skills.') or result.content.strip().startswith('Skills:')
+ assert '/skills list' in result.content
+ assert '/skills add' in result.content
+ assert result.content.lower().index('usage:') > 0
+
+ @pytest.mark.usability
+ def test_bare_mcp_is_status_then_usage(self, isolated_home):
+ result = run_slash('/mcp')
+ assert result.content.strip().startswith('No MCP servers.') or result.content.startswith('MCP servers')
+ assert '/mcp list' in result.content
+ assert result.content.lower().index('usage:') > 0
+
+ @pytest.mark.usability
+ def test_skills_list_without_runtime_shows_imported_ids(
+ self, isolated_home, tmp_path, work_dir):
+ src = tmp_path / 'shown-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# Shown\n', encoding='utf-8')
+ rt = runtime_for(work_dir)
+ run_slash(f'/skills add {src} global', rt)
+ listed = run_slash('/skills list', rt)
+ assert 'shown-skill' in listed.content
+ assert 'Sources:' not in listed.content
+
+
+class TestDefaultScopeFootgun:
+ """``/mcp add`` omits to this folder (project) and says so.
+
+ ``global`` is every folder on this machine. A new ``/skills add`` lands
+ on this machine; enable, disable, and remove follow the copy that
+ already exists.
+ """
+
+ @pytest.mark.usability
+ def test_mcp_add_without_scope_is_project_not_global(
+ self, isolated_home, work_dir, webui):
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ result = run_slash(
+ '/mcp add docs url=https://example.invalid/mcp', rt)
+ assert 'Added docs' in result.content
+ assert '(project)' in result.content
+ global_names = {m.name for m in webui.mcps.list_mcps('global')}
+ assert 'docs' not in global_names
+ project = ProjectManager(base_dir=str(isolated_home)).find_by_path(
+ str(work_dir))
+ project_names = {
+ m.name
+ for m in webui.mcps.list_mcps(f'project:{project.id}')
+ }
+ assert 'docs' in project_names
+
+ @pytest.mark.usability
+ def test_mcp_add_without_scope_warns_about_settings_page(
+ self, isolated_home, work_dir):
+ TuiApp._open_project(str(work_dir))
+ result = run_slash(
+ '/mcp add docs url=https://example.invalid/mcp',
+ runtime_for(work_dir))
+ lowered = result.content.lower()
+ assert 'this folder' in lowered
+ assert 'global' in lowered
+ assert 'webui' not in lowered
+
+ @pytest.mark.usability
+ def test_skills_add_lands_on_this_machine_and_disable_follows_it(
+ self, isolated_home, work_dir):
+ """A new slash skill is global. Later commands follow that copy."""
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ src = work_dir / 'scoped-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# scoped\n', encoding='utf-8')
+ added = run_slash(f'/skills add {src}', rt)
+ assert 'Imported' in added.content
+ assert '(global)' in added.content
+ assert (isolated_home / 'skills' / 'scoped-skill').is_dir()
+ assert not (work_dir / '.ms_agent' / 'skills' / 'scoped-skill').exists()
+ disabled = run_slash('/skills disable scoped-skill', rt)
+ assert 'disable scoped-skill (global)' in disabled.content
+
+
+class TestModelSwitchShadowsWebuiDefault:
+ """e2e.md A5 / 0.5 tell testers to delete ``/.ms_agent/config.yaml``
+ before checking that TUI follows the WebUI default. That file is created
+ by the documented way to switch models (B2 ``/model ``).
+
+ The pass criterion is therefore self-defeating: exercising B2 makes A5
+ fail on the same work dir. Users who switch once in TUI can never pick
+ up a later WebUI default for that folder.
+ """
+
+ @pytest.mark.usability
+ def test_later_webui_default_wins_over_old_tui_switch(
+ self, isolated_home, work_dir, stub_llm_rebuild, webui):
+ rt = ModelRuntime(work_dir)
+ TuiApp._open_project(str(work_dir))
+ run_slash(
+ '/model provider add acme key=sk-acme '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ run_slash('/model catalog add acme pinned-in-tui', rt)
+ switched = run_slash('/model acme pinned-in-tui', rt)
+ assert 'pinned-in-tui' in switched.content
+ patch = work_dir / '.ms_agent' / 'config.yaml'
+ assert not patch.is_file()
+
+ from app.backends.ms_agent import model_link
+ model_link.set_active_model('openai', 'qwen3.7-plus')
+ cfg = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work_dir), explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work_dir))
+ assert cfg.llm.model == 'qwen3.7-plus', (
+ f'TUI still pinned to {cfg.llm.model} via {patch} after WebUI '
+ 'changed the default')
+ assert cfg.llm.service == 'openai'
+
+ @pytest.mark.usability
+ def test_switch_does_not_write_a_project_patch(
+ self, isolated_home, work_dir, stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-acme '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ run_slash('/model catalog add acme a-1', rt)
+ result = run_slash('/model acme a-1', rt)
+ assert 'a-1' in result.content
+ assert 'Saved as the default' not in result.content
+ assert '下一条消息使用它' not in result.content
+ assert 'project patch' not in result.content.lower()
+ assert not (work_dir / '.ms_agent' / 'config.yaml').exists()
+
+
+class TestBareShowsStatusThenUsage:
+ """Bare ``/model`` / ``/search`` show the current value first, then usage."""
+
+ @pytest.mark.usability
+ def test_bare_model_is_status_then_usage(self, isolated_home,
+ work_dir,
+ stub_llm_rebuild):
+ result = run_slash('/model', ModelRuntime(work_dir))
+ text = result.content
+ assert 'Provider:' in text or 'Model:' in text
+ assert 'usage:' in text.lower()
+ status_at = min(
+ i for i in (text.find('Provider:'), text.find('Model:')) if i >= 0)
+ assert status_at < text.lower().index('usage:')
+
+ @pytest.mark.usability
+ def test_bare_search_is_status_then_usage(self, isolated_home):
+ result = run_slash('/search')
+ text = result.content
+ assert 'Engine:' in text
+ assert 'usage:' in text.lower()
+ assert text.index('Engine:') < text.lower().index('usage:')
+
+
+class TestMemoryBackendSemantics:
+ """``/memory global on`` does not touch the current project.
+ ``/memory backend`` is the same: omit-scope writes only the global
+ default. The current folder needs ``/memory project backend``.
+ """
+
+ @pytest.mark.usability
+ def test_backend_without_scope_does_not_rewrite_existing_project(
+ self, isolated_home, work_dir):
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ run_slash('/memory project backend file', rt)
+ # Same command family, still in the project: omit-scope should only
+ # change the global default for *new* folders, like `/memory global on`.
+ result = run_slash('/memory backend vector', rt)
+ assert 'this project is unchanged' in result.content
+ project = ProjectManager(base_dir=str(isolated_home)).find_by_path(
+ str(work_dir))
+ data = json.loads((isolated_home / 'settings.json').read_text())
+ assert data['personalization']['memory_backend'] == 'vector'
+ assert project.memory_backend == 'file'
+
+ @pytest.mark.usability
+ def test_project_backend_updates_only_this_folder(
+ self, isolated_home, work_dir):
+ TuiApp._open_project(str(work_dir))
+ rt = runtime_for(work_dir)
+ run_slash('/memory backend file', rt)
+ result = run_slash('/memory project backend vector', rt)
+ assert 'Project memory backend → vector' in result.content
+ project = ProjectManager(base_dir=str(isolated_home)).find_by_path(
+ str(work_dir))
+ data = json.loads((isolated_home / 'settings.json').read_text())
+ assert data['personalization']['memory_backend'] == 'file'
+ assert project.memory_backend == 'vector'
+
+
+class TestWebuiPartialPut:
+ """PUT /api/agent-settings uses a full AgentSettings model whose
+ ``default_memory_enabled`` defaults to True. A partial PUT that only
+ changes the default model would silently turn memory on.
+ """
+
+ @pytest.mark.usability
+ def test_partial_agent_settings_put_does_not_enable_memory(
+ self, isolated_home, webui_client):
+ before = webui_client.get('/api/agent-settings').json()['data']
+ assert before['default_memory_enabled'] is False
+ # Only send a model field. A safe API must treat omitted booleans
+ # as "leave unchanged", not "schema default".
+ resp = webui_client.put('/api/agent-settings', json={})
+ assert resp.status_code == 200
+ after = resp.json()['data']
+ assert after['default_memory_enabled'] is False
+
+
+class TestWebuiKeyMaskMatchesTui:
+ """A configured key uses the WebUI rule on both sides: eight characters
+ or fewer are ``****``, longer keys keep the first and last four.
+ """
+
+ @pytest.mark.usability
+ def test_webui_provider_mask_matches_tui_list(
+ self, isolated_home, work_dir, stub_llm_rebuild, webui):
+ secret = 'sk-test-acme'
+ run_slash(
+ f'/model provider add acme key={secret} '
+ 'url=https://example.invalid/v1 protocol=openai',
+ ModelRuntime(work_dir))
+ listed = run_slash('/model list', ModelRuntime(work_dir))
+ row = next(p for p in webui.providers.list_providers() if p.id == 'acme')
+ masked = row.api_key_masked or ''
+ assert secret not in masked
+ assert secret not in listed.content
+ assert masked == 'sk-t****acme'
+ assert 'key=sk-t****acme' in listed.content
+
+
+class TestResumeIndexVsMarker:
+ """Session #0 is rendered as ➤ when it is current, so the usage line
+ ``/resume <#|id>`` does not match what the user sees in the table.
+ Index 0 must still work.
+ """
+
+ @pytest.mark.usability
+ def test_resume_zero_still_selects_first_session(self, isolated_home,
+ work_dir):
+ from ms_agent.project import SessionManager
+
+ project = TuiApp._open_project(str(work_dir))
+ sm = SessionManager(project)
+ first = sm.create(name='alpha')
+ second = sm.create(name='beta')
+ app = TuiApp.__new__(TuiApp)
+ app._sm = sm
+ app.session = first
+ listed = sm.list() # newest first
+ assert listed[0].id == second.id
+ assert app._resume_target('0').id == listed[0].id
+ assert app._resume_target('1').id == listed[1].id
+ assert app._resume_target(first.id).id == first.id
+
+
+class TestMissingKeyPrompt:
+ def test_setup_text_names_the_provider_and_one_command(self):
+ from ms_agent.llm.credentials import missing_api_key_setup_text
+ text = missing_api_key_setup_text(
+ ValueError('No API key found for provider "openai"'))
+ assert text == ('还没有 openai 的密钥。\n\n'
+ ' /model provider key openai <密钥>')
+
+
+class TestDocZeroFiveIsUnreasonable:
+ """Period 0.5 used to tell testers to move
+ ``/.ms_agent/config.yaml`` out of the way. That file is no longer
+ written by ``/model``; a later WebUI default is what the next TUI launch
+ uses (no --config).
+ """
+
+ @pytest.mark.usability
+ def test_doc_should_not_require_deleting_the_file_the_product_writes(
+ self, isolated_home, work_dir, stub_llm_rebuild):
+ rt = ModelRuntime(work_dir)
+ run_slash(
+ '/model provider add acme key=sk-acme '
+ 'url=https://example.invalid/v1 protocol=openai',
+ rt)
+ run_slash('/model catalog add acme pinned', rt)
+ run_slash('/model acme pinned', rt)
+ assert not (work_dir / '.ms_agent' / 'config.yaml').exists()
+ cfg2 = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work_dir), explicit_config=False)
+ assert cfg2.llm.model == 'pinned'
diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py
new file mode 100644
index 000000000..ccd9c6510
--- /dev/null
+++ b/tests/llm/test_model_discovery.py
@@ -0,0 +1,236 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Unit tests for /models discovery and chat-oriented filtering (no network)."""
+from unittest.mock import MagicMock, patch
+
+from ms_agent.llm.model_discovery import (
+ fetch_model_ids,
+ filter_chat_model_ids,
+ format_id_list,
+ is_non_chat_model,
+ parse_model_ids,
+ wire_protocol,
+)
+
+
+def test_parse_model_ids_openai_shape():
+ ids = parse_model_ids({
+ 'data': [
+ {'id': 'qwen-plus'},
+ {'id': 'qwen-plus'},
+ {'object': 'model'},
+ 'skip',
+ ]
+ })
+ assert ids == ['qwen-plus']
+
+
+def test_parse_model_ids_garbage():
+ assert parse_model_ids(None) == []
+ assert parse_model_ids({'data': 'nope'}) == []
+
+
+def test_filter_keeps_chat_and_vision_drops_media():
+ raw = [
+ 'qwen-plus',
+ 'qwen3.8-flash',
+ 'qwen-vl-max',
+ 'glm-4v',
+ 'gpt-4o',
+ 'wanx-v1',
+ 'wan2.1-t2v-plus',
+ 'qwen-image-plus',
+ 'text-embedding-v3',
+ 'tts-1',
+ 'dall-e-3',
+ 'video-01',
+ ]
+ kept, dropped = filter_chat_model_ids(raw)
+ assert kept == [
+ 'qwen-plus',
+ 'qwen3.8-flash',
+ 'qwen-vl-max',
+ 'glm-4v',
+ 'gpt-4o',
+ ]
+ assert dropped == 7
+ assert not is_non_chat_model('qwen-vl-plus')
+ assert is_non_chat_model('cogview-3')
+ assert is_non_chat_model('MiniMax/speech-02-hd')
+
+
+def test_format_live_model_lines_groups_by_family_and_owner():
+ from ms_agent.llm.model_discovery import format_live_model_lines
+ lines = format_live_model_lines(
+ [
+ 'MiniMax-M2.1',
+ 'MiniMax-M2.5',
+ 'MiniMax/MiniMax-M2.1',
+ 'ZHIPU/GLM-5',
+ 'deepseek-r1',
+ 'deepseek-v3',
+ 'gui-plus',
+ 'qwen-plus',
+ 'qwen3.8-flash',
+ 'qwen-vl-max',
+ ],
+ indent=' ',
+ )
+ text = '\n'.join(lines)
+ assert ' MiniMax (2)' in lines or ' MiniMax (2)' in text
+ assert ' MiniMax-M2.1' in lines
+ assert ' MiniMax/ (1)' in lines
+ assert ' MiniMax/MiniMax-M2.1' in lines
+ assert ' ZHIPU/ (1)' in lines
+ assert ' ZHIPU/GLM-5' in lines
+ assert ' qwen (3)' in lines
+ assert ' qwen-plus' in lines
+ assert ' qwen3.8-flash' in lines
+ assert ' gui-plus' in lines
+ assert ' deepseek (2)' in lines
+ # One id per line — no comma-joined blob.
+ assert not any(',' in line and 'qwen-plus' in line for line in lines)
+
+
+def test_format_id_list_caps():
+ ids = [f'm{i}' for i in range(5)]
+ assert format_id_list(ids, limit=3) == 'm0, m1, m2 … +2 more'
+ assert format_id_list([], limit=3) == '(none)'
+
+
+def test_wire_protocol():
+ assert wire_protocol('anthropic_messages') == 'anthropic'
+ assert wire_protocol('openai_compat') == 'openai'
+
+
+def test_fetch_model_ids_ok():
+ payload = {'data': [{'id': 'a'}, {'id': 'b'}]}
+ resp = MagicMock(status_code=200)
+ resp.json.return_value = payload
+ client = MagicMock()
+ client.get.return_value = resp
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ ids = fetch_model_ids('https://example.invalid/v1', 'openai', 'sk')
+ assert ids == ['a', 'b']
+ client.get.assert_called_once()
+ args, kwargs = client.get.call_args
+ assert args[0] == 'https://example.invalid/v1/models'
+ assert kwargs['headers']['Authorization'] == 'Bearer sk'
+
+
+def test_probe_405_and_404_are_no_catalog():
+ from ms_agent.llm.model_discovery import probe_model_catalog
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ for code in (404, 405):
+ client.get.return_value = MagicMock(status_code=code)
+ probe = probe_model_catalog(
+ 'https://example.invalid/v1', 'openai', 'sk')
+ assert probe.outcome == 'no_catalog'
+ assert probe.status == code
+ assert probe.ids == []
+
+
+def test_probe_keeps_auth_status():
+ from ms_agent.llm.model_discovery import probe_model_catalog
+ resp = MagicMock(status_code=401)
+ client = MagicMock()
+ client.get.return_value = resp
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk')
+ assert probe.outcome == 'auth'
+ assert probe.status == 401
+ assert probe.ids == []
+
+
+def test_probe_empty_body_is_not_a_missing_id():
+ from ms_agent.llm.model_discovery import probe_model_catalog
+ resp = MagicMock(status_code=200)
+ resp.json.return_value = {'unexpected': True}
+ client = MagicMock()
+ client.get.return_value = resp
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk')
+ assert probe.outcome == 'no_catalog'
+ assert probe.status == 200
+
+
+def test_probe_timeout_is_unreachable():
+ import httpx
+ from ms_agent.llm.model_discovery import probe_model_catalog
+ client = MagicMock()
+ client.get.side_effect = httpx.ConnectError('nope')
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk')
+ assert probe.outcome == 'unreachable'
+ assert probe.status is None
+
+
+def test_chat_unsupported_stays_on_modality_phrases():
+ from ms_agent.llm.model_discovery import chat_failed_because_unsupported
+ missed = (
+ 'gemini-2.5-flash-image-preview',
+ 'lumina-image-2.0',
+ 'ideogram-v3',
+ 'midjourney-v7',
+ 'recraft-v3',
+ 'playground-v2.5-1024px-aesthetic',
+ 'hidream-i1-full',
+ 'seedream-4.0',
+ 'nano-banana',
+ 'veo3',
+ 'luma-dream-machine',
+ 'runway-gen4',
+ 'pika-2.2',
+ 'minimax-hailuo-02',
+ 'z-image-turbo',
+ 'unisound-u2',
+ 'chattts',
+ 'bge-m3',
+ 'm3e-base',
+ )
+ for name in missed:
+ assert not chat_failed_because_unsupported(name, '')
+ invalid = "Error code: 400 - {'error':{'message':'Invalid model id'}}"
+ assert not chat_failed_because_unsupported('z-image-turbo', invalid)
+ assert not chat_failed_because_unsupported('qwen-vl-max', invalid)
+ assert not chat_failed_because_unsupported('qwen-plus', '401 Unauthorized')
+ assert chat_failed_because_unsupported(
+ 'qwen-plus', 'This endpoint only supports embeddings')
+ assert chat_failed_because_unsupported('wanx-v1', '')
+
+
+def test_rejected_model_id_is_separate_from_cannot_chat():
+ from ms_agent.llm.model_discovery import model_id_rejected
+ assert model_id_rejected(
+ "Error code: 400 - {'error':{'message':'Invalid model id'}}")
+ assert model_id_rejected('Error code: 400 - Model does not exist')
+ assert model_id_rejected('Error code: 404 - The model `x` does not exist')
+ assert model_id_rejected('model_not_supported')
+ assert model_id_rejected(
+ "Error code: 400 - {'error': {'message': "
+ "'Model id : Qwen/old , has no provider supported'}}")
+ assert not model_id_rejected('401 Unauthorized')
+ assert not model_id_rejected('403 Forbidden')
+ assert not model_id_rejected('does not exist')
+ assert not model_id_rejected('This endpoint only supports embeddings')
+ assert not model_id_rejected('')
+
+
+def test_fetch_model_ids_non_2xx():
+ resp = MagicMock(status_code=401)
+ client = MagicMock()
+ client.get.return_value = resp
+ client.__enter__.return_value = client
+ client.__exit__.return_value = False
+ with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client):
+ assert fetch_model_ids('https://example.invalid/v1', 'openai', 'sk') == []
diff --git a/tests/llm/test_provider_router_live.py b/tests/llm/test_provider_router_live.py
index ba92e51ab..7888f458e 100644
--- a/tests/llm/test_provider_router_live.py
+++ b/tests/llm/test_provider_router_live.py
@@ -47,7 +47,7 @@
# service -> (default model, env key that must be present)
PROVIDERS = {
- 'modelscope': ('Qwen/Qwen3-235B-A22B-Instruct-2507', 'MODELSCOPE_API_KEY'),
+ 'modelscope': ('Qwen/Qwen3.8-Flash-Next', 'MODELSCOPE_API_KEY'),
'dashscope': ('qwen3.7-plus', 'DASHSCOPE_API_KEY'),
'deepseek': ('deepseek-chat', 'DEEPSEEK_API_KEY'),
'zhipu': ('glm-4.5', 'GLM_API_KEY'),
diff --git a/tests/mcp/test_mcp_runtime.py b/tests/mcp/test_mcp_runtime.py
index 27911909f..b08d22e0d 100644
--- a/tests/mcp/test_mcp_runtime.py
+++ b/tests/mcp/test_mcp_runtime.py
@@ -37,14 +37,15 @@ def is_connected(self, server_name: str) -> bool:
def list_connected_servers(self) -> list[str]:
return list(self.sessions.keys())
- async def connect_single_server(self, server_name: str, server_config: dict):
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
self.connect_calls.append(server_name)
if server_config.get('fail_connect') or server_config.get('command') == 'x':
raise ConnectionError(f'connect failed: {server_name}')
self.sessions[server_name] = object()
return server_name
- async def disconnect_server(self, server_name: str):
+ async def disconnect_server(self, server_name: str, **kwargs):
self.sessions.pop(server_name, None)
async def get_tools_for_server(self, server_name: str) -> List[Tool]:
@@ -73,6 +74,10 @@ async def get_tools(self) -> Dict[str, List[Tool]]:
async def call_tool(self, server_name: str, tool_name: str, tool_args: dict):
self.call_tool_calls += 1
+ if getattr(self, 'call_cancel', False):
+ raise asyncio.CancelledError()
+ if getattr(self, 'call_group', False):
+ raise ExceptionGroup('mcp', [ConnectionError('broken pipe')])
if getattr(self, 'call_raises', False):
raise ConnectionError('broken pipe')
if getattr(self, 'call_timeout', False):
@@ -435,6 +440,116 @@ def test_classify_mcp_failure():
assert classify_mcp_failure(ConnectionError('x')) == 'hard'
assert classify_mcp_failure(BrokenPipeError()) == 'hard'
assert classify_mcp_failure(ValueError('bad arg')) == 'none'
+ grouped = ExceptionGroup('mcp', [ConnectionError('broken pipe')])
+ assert classify_mcp_failure(grouped) == 'hard'
+
+
+@pytest.mark.asyncio
+async def test_dead_server_group_is_a_tool_error_not_a_failed_turn():
+ client = FakeMCPClient()
+ client.call_group = True
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('fetch', {'command': 'echo'})),
+ owns_client=False,
+ )
+ tm = _make_tool_manager(client, runtime)
+ await tm.connect()
+ runtime.bind_tool_manager(tm)
+ await runtime.start()
+ await runtime.sync_tools()
+
+ result = await tm.single_call_tool({
+ 'tool_name': 'fetch---demo_tool',
+ 'arguments': {},
+ })
+ assert result['is_error'] is True
+ assert runtime.get_server('fetch').status == 'degraded'
+
+
+@pytest.mark.asyncio
+async def test_cancelled_tool_call_still_propagates():
+ client = FakeMCPClient()
+ client.call_cancel = True
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('fetch', {'command': 'echo'})),
+ owns_client=False,
+ )
+ tm = _make_tool_manager(client, runtime)
+ await tm.connect()
+ runtime.bind_tool_manager(tm)
+ await runtime.start()
+ await runtime.sync_tools()
+
+ with pytest.raises(asyncio.CancelledError):
+ await tm.single_call_tool({
+ 'tool_name': 'fetch---demo_tool',
+ 'arguments': {},
+ })
+
+
+@pytest.mark.asyncio
+async def test_disconnect_notice_once_when_server_becomes_unusable():
+ from ms_agent.agent.llm_agent import LLMAgent
+
+ client = FakeMCPClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('fetch', {'command': 'echo'})),
+ owns_client=False,
+ )
+ await runtime.start()
+ agent = LLMAgent.__new__(LLMAgent)
+ agent.mcp_runtime = runtime
+
+ class _Sink:
+ def __init__(self):
+ self.texts = []
+
+ def emit(self, event):
+ self.texts.append(event.text)
+
+ agent._event_sink = _Sink()
+ exc = ConnectionError('connection closed')
+ await agent._on_mcp_failure(
+ 'fetch', 'call_tool', 'connection closed', exc=exc)
+ await agent._on_mcp_failure(
+ 'fetch', 'call_tool', 'connection closed', exc=exc)
+ assert agent._event_sink.texts == []
+ assert agent._mcp_fp is None
+ assert 'fetch' in agent._mcp_reconnect_names
+
+
+@pytest.mark.asyncio
+async def test_timeout_notice_waits_for_the_degrade_threshold():
+ from ms_agent.agent.llm_agent import LLMAgent
+
+ client = FakeMCPClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('fetch', {'command': 'echo'})),
+ owns_client=False,
+ )
+ await runtime.start()
+ agent = LLMAgent.__new__(LLMAgent)
+ agent.mcp_runtime = runtime
+
+ class _Sink:
+ def __init__(self):
+ self.texts = []
+
+ def emit(self, event):
+ self.texts.append(event.text)
+
+ agent._event_sink = _Sink()
+ for _ in range(DEGRADED_FAILURE_THRESHOLD - 1):
+ await agent._on_mcp_failure(
+ 'fetch', 'call_tool', 'timeout', exc=TimeoutError('timeout'))
+ assert agent._event_sink.texts == []
+ await agent._on_mcp_failure(
+ 'fetch', 'call_tool', 'timeout', exc=TimeoutError('timeout'))
+ assert agent._event_sink.texts == []
def test_is_connection_error():
@@ -578,3 +693,336 @@ class _Config:
)
tm._skip_mcp_reindex = True
return tm
+
+
+@pytest.mark.asyncio
+async def test_start_probes_servers_together(monkeypatch):
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_PROBE_TIMEOUT', 1)
+
+ class SlowClient(FakeMCPClient):
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ await asyncio.sleep(0.2)
+ return await super().connect_single_server(
+ server_name, server_config, **kwargs)
+
+ client = SlowClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(
+ ('a', {'command': 'echo'}),
+ ('b', {'command': 'echo'}),
+ ),
+ owns_client=False,
+ )
+ started = asyncio.get_running_loop().time()
+ await runtime.start()
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 0.35
+ assert runtime.get_server('a').status == 'connected'
+ assert runtime.get_server('b').status == 'connected'
+
+
+@pytest.mark.asyncio
+async def test_start_leaves_a_slow_stdio_server_running(monkeypatch):
+ """A cold stdio process must not hold startup, and is not a failure yet."""
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_TURN_WAIT', 0.05)
+
+ class HungClient(FakeMCPClient):
+
+ def __init__(self):
+ super().__init__()
+ self.budgets: dict[str, float] = {}
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ self.budgets[server_name] = kwargs.get('startup_timeout')
+ if server_name == 'slow':
+ await asyncio.Event().wait()
+ return await super().connect_single_server(
+ server_name, server_config, **kwargs)
+
+ from ms_agent.tools.mcp_client import DEFAULT_SERVER_STARTUP_TIMEOUT
+
+ client = HungClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(
+ ('slow', {'command': 'echo'}),
+ ('good', {'command': 'echo'}),
+ ),
+ owns_client=False,
+ )
+ started = asyncio.get_running_loop().time()
+ try:
+ await runtime.start()
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 0.4
+ assert runtime.get_server('good').status == 'connected'
+ slow = runtime.get_server('slow')
+ assert slow.status == 'connecting'
+ assert slow.last_error is None
+ assert not runtime.is_callable('slow')
+ assert client.is_connected('good')
+ assert not client.is_connected('slow')
+ assert client.budgets['slow'] == DEFAULT_SERVER_STARTUP_TIMEOUT
+ finally:
+ await runtime._cancel_probes()
+
+
+@pytest.mark.asyncio
+async def test_apply_config_does_not_reprobe_a_recorded_failure():
+ client = FakeMCPClient()
+ config = ResolvedMCPConfig(mcp_servers={
+ 'bad': {'command': 'x'},
+ 'good': {'command': 'echo'},
+ })
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=config,
+ connect_policy='skip',
+ owns_client=False,
+ )
+ await runtime.start()
+ assert client.connect_calls.count('bad') == 1
+ await runtime.apply_config(ResolvedMCPConfig(mcp_servers={
+ 'bad': {'command': 'x'},
+ 'good': {'command': 'echo'},
+ 'extra': {'command': 'echo'},
+ }))
+ assert client.connect_calls.count('bad') == 1
+ assert client.is_connected('extra')
+ assert runtime.get_server('bad').status == 'error'
+ assert runtime.get_server('bad').last_error == 'connect failed: bad'
+
+
+@pytest.mark.asyncio
+async def test_apply_config_reconnects_a_degraded_server():
+ client = FakeMCPClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('fetch', {'command': 'echo'})),
+ owns_client=False,
+ )
+ await runtime.start()
+ assert client.is_connected('fetch')
+ runtime.get_server('fetch').status = 'degraded'
+ before = len(client.connect_calls)
+ await runtime.apply_config(_resolved(('fetch', {'command': 'echo'})))
+ assert len(client.connect_calls) > before
+ assert runtime.get_server('fetch').status == 'connected'
+ assert client.is_connected('fetch')
+
+
+@pytest.mark.asyncio
+async def test_apply_config_leaves_a_healthy_server_connected():
+ client = FakeMCPClient()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(
+ ('fetch', {'command': 'echo'}),
+ ('docs', {'command': 'echo'}),
+ ),
+ owns_client=False,
+ )
+ await runtime.start()
+ docs_session = client.sessions['docs']
+ runtime.get_server('fetch').status = 'degraded'
+ await runtime.apply_config(
+ _resolved(
+ ('fetch', {'command': 'echo'}),
+ ('docs', {'command': 'echo'}),
+ ))
+ assert client.connect_calls.count('docs') == 1
+ assert client.sessions['docs'] is docs_session
+ assert client.connect_calls.count('fetch') == 2
+ assert runtime.get_server('fetch').status == 'connected'
+
+
+@pytest.mark.asyncio
+async def test_remote_handshake_still_stops_at_the_probe_cap(monkeypatch):
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_PROBE_TIMEOUT', 0.05)
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_TURN_WAIT', 1)
+
+ class HungRemote(FakeMCPClient):
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ self.connect_calls.append(server_name)
+ self.budget = kwargs.get('startup_timeout')
+ await asyncio.Event().wait()
+
+ client = HungRemote()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('remote', {'url': 'http://127.0.0.1:9/mcp'})),
+ owns_client=False,
+ )
+ started = asyncio.get_running_loop().time()
+ await runtime.start()
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 0.4
+ remote = runtime.get_server('remote')
+ assert remote.status == 'error'
+ assert remote.last_error == '0.05秒内没有连上'
+ assert client.budget == 0.05
+ assert not runtime.is_callable('remote')
+
+
+@pytest.mark.asyncio
+async def test_stdio_that_finishes_late_joins_the_next_turn(monkeypatch):
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_TURN_WAIT', 0.05)
+
+ class Delayed(FakeMCPClient):
+
+ def __init__(self):
+ super().__init__()
+ self.release = asyncio.Event()
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ if server_name == 'slow':
+ await self.release.wait()
+ return await super().connect_single_server(
+ server_name, server_config, **kwargs)
+
+ client = Delayed()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('slow', {'command': 'npx'})),
+ owns_client=False,
+ )
+ try:
+ await runtime.start()
+ assert runtime.get_server('slow').status == 'connecting'
+ assert not runtime.is_callable('slow')
+ client.release.set()
+ for _ in range(20):
+ if runtime.get_server('slow').status == 'connected':
+ break
+ await asyncio.sleep(0.01)
+ await runtime.refresh_for_turn(wait=False)
+ assert runtime.get_server('slow').status == 'connected'
+ assert runtime.is_callable('slow')
+ assert runtime.get_server('slow').process_exited is False
+ finally:
+ await runtime._cancel_probes()
+
+
+@pytest.mark.asyncio
+async def test_dead_stdio_drops_tools_and_does_not_retry_forever(monkeypatch):
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_TURN_WAIT', 0.05)
+
+ class ThenHang(FakeMCPClient):
+
+ def __init__(self):
+ super().__init__()
+ self.block = False
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ if self.block:
+ self.connect_calls.append(server_name)
+ await asyncio.Event().wait()
+ return await super().connect_single_server(
+ server_name, server_config, **kwargs)
+
+ client = ThenHang()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('box', {'command': 'npx'})),
+ owns_client=False,
+ )
+ try:
+ await runtime.start()
+ assert runtime.get_server('box').status == 'connected'
+ client.sessions.pop('box')
+ client.block = True
+ await runtime.refresh_for_turn()
+ state = runtime.get_server('box')
+ assert state.process_exited is True
+ assert state.status == 'connecting'
+ assert state.tool_count == 0
+ assert not runtime.is_callable('box')
+ started = client.connect_calls.count('box')
+ await runtime.refresh_for_turn()
+ assert client.connect_calls.count('box') == started
+ finally:
+ await runtime._cancel_probes()
+
+
+@pytest.mark.asyncio
+async def test_dead_stdio_is_noticed_while_the_session_is_still_registered():
+ """The child can be gone before the owner task drops the session."""
+
+ class StillRegistered(FakeMCPClient):
+
+ def __init__(self):
+ super().__init__()
+ self._dead = False
+
+ def stdio_process_exited(self, name):
+ del name
+ if self._dead:
+ self._dead = False
+ return True
+ return False
+
+ client = StillRegistered()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('box', {'command': 'npx'})),
+ owns_client=False,
+ )
+ await runtime.start()
+ assert client.is_connected('box')
+ client._dead = True
+ await runtime.refresh_for_turn()
+ assert client.connect_calls.count('box') == 2
+ assert runtime.get_server('box').status == 'connected'
+ await runtime.refresh_for_turn()
+ assert client.connect_calls.count('box') == 2
+
+
+@pytest.mark.asyncio
+async def test_a_failed_reconnect_stays_failed_until_retry():
+ class ThenFail(FakeMCPClient):
+
+ def __init__(self):
+ super().__init__()
+ self.fail = False
+
+ async def connect_single_server(self, server_name: str, server_config: dict,
+ **kwargs):
+ if self.fail:
+ self.connect_calls.append(server_name)
+ raise ConnectionError('gone')
+ return await super().connect_single_server(
+ server_name, server_config, **kwargs)
+
+ client = ThenFail()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=_resolved(('box', {'command': 'npx'})),
+ owns_client=False,
+ )
+ await runtime.start()
+ client.sessions.pop('box')
+ client.fail = True
+ await runtime.refresh_for_turn()
+ state = runtime.get_server('box')
+ assert state.status == 'error'
+ assert state.process_exited is True
+ assert state.last_error == 'gone'
+ assert not runtime.is_callable('box')
+ failed = client.connect_calls.count('box')
+ await runtime.refresh_for_turn()
+ assert client.connect_calls.count('box') == failed
+
+ client.fail = False
+ await runtime.retry_server('box')
+ assert runtime.get_server('box').status == 'connected'
+ assert runtime.get_server('box').process_exited is False
+ assert runtime.is_callable('box')
+ assert client.connect_calls.count('box') == failed + 1
diff --git a/tests/memory/test_file_storage_notice.py b/tests/memory/test_file_storage_notice.py
new file mode 100644
index 000000000..45cae8fed
--- /dev/null
+++ b/tests/memory/test_file_storage_notice.py
@@ -0,0 +1,80 @@
+"""Memory tool results the user can act on."""
+import errno
+
+import pytest
+
+from ms_agent.memory.unified.backends.file_based import FileBasedBackend
+from ms_agent.memory.unified.config import MemoryConfig
+from ms_agent.memory.unified.storage import file_storage as storage_mod
+from ms_agent.memory.unified.user_notice import (
+ CHAR_LIMIT_NOTICE,
+ DISK_NOTICE,
+ MISSING_DIR_NOTICE,
+ NO_SPACE_NOTICE,
+ PERMISSION_NOTICE,
+)
+
+
+@pytest.mark.asyncio
+async def test_char_limit_is_the_tool_result_and_does_not_stick(tmp_path):
+ backend = FileBasedBackend(
+ MemoryConfig(base_dir=str(tmp_path), char_limit=20))
+ over = await backend.handle_tool_call('memory', {
+ 'action': 'add',
+ 'content': 'x' * 40,
+ })
+ assert over == CHAR_LIMIT_NOTICE
+ assert not (tmp_path / 'MEMORY.md').exists()
+
+ missed = await backend.handle_tool_call('memory', {
+ 'action': 'replace',
+ 'content': 'missing',
+ 'new_content': 'y',
+ })
+ assert missed == '更新失败'
+ assert CHAR_LIMIT_NOTICE not in missed
+
+
+@pytest.mark.asyncio
+async def test_char_limit_is_logged_as_a_warning(tmp_path, monkeypatch):
+ seen = []
+
+ def _warning(msg, *args, **kwargs):
+ seen.append(msg % args if args else msg)
+
+ monkeypatch.setattr(storage_mod.logger, 'warning', _warning)
+ backend = FileBasedBackend(
+ MemoryConfig(base_dir=str(tmp_path), char_limit=20))
+ await backend.handle_tool_call('memory', {
+ 'action': 'add',
+ 'content': 'x' * 40,
+ })
+ assert any('char limit' in msg for msg in seen)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('exc,notice', [
+ (FileNotFoundError(errno.ENOENT, 'gone', 'MEMORY.md'), MISSING_DIR_NOTICE),
+ (PermissionError(errno.EACCES, 'denied', 'MEMORY.md'), PERMISSION_NOTICE),
+ (OSError(errno.ENOSPC, 'full', 'MEMORY.md'), NO_SPACE_NOTICE),
+ (OSError(errno.EIO, 'io', 'MEMORY.md'), DISK_NOTICE),
+])
+async def test_disk_error_sentence_matches_the_cause(tmp_path, monkeypatch,
+ exc, notice):
+
+ def boom(*_args, **_kwargs):
+ raise exc
+
+ monkeypatch.setattr(storage_mod, 'atomic_write_text', boom)
+ backend = FileBasedBackend(MemoryConfig(base_dir=str(tmp_path)))
+ added = await backend.handle_tool_call('memory', {
+ 'action': 'add',
+ 'content': 'remember this',
+ })
+ assert added == notice
+
+ removed = await backend.handle_tool_call('memory', {
+ 'action': 'remove',
+ 'content': 'remember this',
+ })
+ assert removed == notice
diff --git a/tests/memory/test_orchestrator_scheduling.py b/tests/memory/test_orchestrator_scheduling.py
index 01e70f929..6e0b1b1fe 100644
--- a/tests/memory/test_orchestrator_scheduling.py
+++ b/tests/memory/test_orchestrator_scheduling.py
@@ -21,6 +21,7 @@
from ms_agent.llm.utils import Message
from ms_agent.memory.unified.config import MemoryConfig
from ms_agent.memory.unified.orchestrator import MemoryOrchestrator
+from ms_agent.memory.unified.user_notice import MISSING_DIR_NOTICE
class RecordingBackend:
@@ -139,9 +140,16 @@ async def main():
asyncio.run(main())
-def test_failed_ingest_is_retried_on_the_next_turn(tmp_path):
+def test_failed_ingest_is_retried_on_the_next_turn(tmp_path, monkeypatch):
async def main():
+ logged = []
+
+ def _error(msg, *args, **kwargs):
+ logged.append(msg % args if args else msg)
+
+ monkeypatch.setattr(
+ 'ms_agent.memory.unified.orchestrator.logger.error', _error)
class Failing(RecordingBackend):
@@ -152,6 +160,8 @@ async def on_messages(self, messages, **kwargs):
await orch.add(_msgs('important fact', 'noted'))
assert orch.ingest_status['state'] == 'error'
assert 'provider down' in orch.ingest_status['error']
+ assert orch.user_notice is None
+ assert any('provider down' in line for line in logged)
# Hashes were NOT recorded, so the same messages come back as delta.
backend = RecordingBackend()
@@ -160,6 +170,36 @@ async def on_messages(self, messages, **kwargs):
assert [m['content'] for m in backend.batches[0]] == [
'important fact', 'noted'
]
+ assert orch.user_notice is None
+
+ asyncio.run(main())
+
+
+def test_missing_memory_dir_uses_its_own_sentence(tmp_path):
+
+ async def main():
+
+ class Missing(RecordingBackend):
+
+ async def on_messages(self, messages, **kwargs):
+ raise FileNotFoundError(2, 'gone', 'MEMORY.md')
+
+ orch = _orch(tmp_path, Missing())
+ await orch.add(_msgs('q', 'a'))
+ assert orch.user_notice == MISSING_DIR_NOTICE
+
+ asyncio.run(main())
+
+
+def test_later_success_clears_a_stale_notice(tmp_path):
+
+ async def main():
+ backend = RecordingBackend()
+ orch = _orch(tmp_path, backend)
+ await orch.add(_msgs('q', 'a'))
+ orch.user_notice = 'stale disk failure'
+ await orch.add(_msgs('q', 'a'))
+ assert orch.user_notice is None
asyncio.run(main())
diff --git a/tests/permission/test_memory.py b/tests/permission/test_memory.py
index d2af175a2..677a82cac 100644
--- a/tests/permission/test_memory.py
+++ b/tests/permission/test_memory.py
@@ -103,3 +103,12 @@ def test_corrupt_file(self, tmp_path):
mem = PermissionMemory(project_path=project_path)
assert mem.list_all() == []
+
+ def test_default_global_file_follows_ms_agent_home(self, tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ mem = PermissionMemory(project_path=None)
+ mem.add('web_search---*', scope='global')
+ assert (home / 'permission_memory.json').is_file()
+ reloaded = PermissionMemory(project_path=None)
+ assert reloaded.matches('web_search---fetch_page', {})
diff --git a/tests/project/test_manager.py b/tests/project/test_manager.py
index e1ded85f4..213c115e6 100644
--- a/tests/project/test_manager.py
+++ b/tests/project/test_manager.py
@@ -170,3 +170,30 @@ def test_open_folder_roundtrips_via_get(self, pm, tmp_path):
assert again is not None
assert again.path == project.path
assert again.instruction == 'be terse'
+
+ def test_find_by_path_returns_create_project(self, pm, tmp_path):
+ folder = tmp_path / 'webui-created'
+ folder.mkdir()
+ created = pm.create(name='From WebUI', path=str(folder),
+ init_workspace=False)
+ found = pm.find_by_path(str(folder))
+ assert found is not None
+ assert found.id == created.id
+
+ def test_open_folder_reuses_create_project_id(self, pm, tmp_path):
+ """TUI --work-dir on a WebUI-created project must not mint a path-key."""
+ from ms_agent.project.paths import project_key
+ folder = tmp_path / 'shared-workspace'
+ folder.mkdir()
+ created = pm.create(
+ name='WebUI', path=str(folder), init_workspace=False)
+ opened = pm.open_folder(str(folder))
+ assert opened.id == created.id
+ assert opened.id != project_key(str(folder))
+ matching = [p for p in pm.list() if p.path == str(folder.resolve())]
+ assert len(matching) == 1
+
+ def test_open_folder_on_default_project_path_keeps_default(self, pm):
+ default = pm.get_default_project()
+ opened = pm.open_folder(default.path)
+ assert opened.id == DEFAULT_PROJECT_ID
diff --git a/tests/prompting/test_workspace_files.py b/tests/prompting/test_workspace_files.py
index e7ec98ddb..7b7446f72 100644
--- a/tests/prompting/test_workspace_files.py
+++ b/tests/prompting/test_workspace_files.py
@@ -182,3 +182,89 @@ def test_plain_text_is_all_free_region():
r0, r1, r2 = wf.split_profile_regions('just some intro text\n')
assert r0 == '' and r1 == ''
assert r2 == 'just some intro text\n'
+
+
+# ── UI write API (TUI / WebUI) ───────────────────────────────────────────────
+
+
+def test_global_instruction_writes_user_region(home):
+ wf.write_global_instruction('Always answer in French.')
+ text = (home / 'AGENTS.md').read_text(encoding='utf-8')
+ assert text.lstrip().startswith('---')
+ assert 'Always answer in French.' in text
+ assert wf.read_global_instruction() == 'Always answer in French.'
+ block = wf.global_instructions_block()
+ assert 'Always answer in French.' in block
+ assert 'legacy:settings.json' not in block
+
+
+def test_global_instruction_migrates_settings_then_clears(home):
+ from ms_agent.personalization.settings import PersonalizationSettings
+ from ms_agent.personalization.types import PersonalizationConfig
+ PersonalizationSettings().save(
+ PersonalizationConfig(global_instruction='你的名字是小黑'))
+ assert wf.read_global_instruction() == '你的名字是小黑'
+ assert PersonalizationSettings().load().global_instruction == ''
+ assert '你的名字是小黑' in (home / 'AGENTS.md').read_text('utf-8')
+
+
+def test_project_instruction_writes_private_slot_only(home, tmp_path):
+ work = tmp_path / 'repo'
+ work.mkdir()
+ root = work / 'AGENTS.md'
+ root.write_text('# team file\nDo not touch.\n', encoding='utf-8')
+ wf.write_project_instruction(str(work), 'This project uses uv.')
+ private = work / '.ms_agent' / 'AGENTS.md'
+ assert private.read_text(encoding='utf-8').strip() == 'This project uses uv.'
+ assert root.read_text(encoding='utf-8') == '# team file\nDo not touch.\n'
+ assert wf.read_project_instruction(str(work)).strip() == 'This project uses uv.'
+ block = wf.project_instructions_block(str(work))
+ assert 'Do not touch' in block
+ assert 'This project uses uv.' in block
+ assert block.index('Do not touch') < block.index('This project uses uv.')
+
+
+def test_project_instruction_migrates_project_json(home, tmp_path):
+ from ms_agent.project.manager import ProjectManager
+ work = tmp_path / 'legacy-proj'
+ work.mkdir()
+ created = ProjectManager(base_dir=str(home)).create(
+ name='Legacy', path=str(work), instruction='老项目规矩',
+ init_workspace=False)
+ got = wf.read_project_instruction(str(work))
+ assert got.strip() == '老项目规矩'
+ assert (work / '.ms_agent' / 'AGENTS.md').read_text('utf-8').strip() == '老项目规矩'
+ assert (ProjectManager(base_dir=str(home)).get(created.id).instruction
+ or '') == ''
+ assert not (work / 'AGENTS.md').exists()
+
+
+def test_write_project_clears_stale_instruction_field(home, tmp_path):
+ from ms_agent.project.manager import ProjectManager
+ work = tmp_path / 'both'
+ work.mkdir()
+ pm = ProjectManager(base_dir=str(home))
+ created = pm.create(
+ name='Both', path=str(work), instruction='stale-json',
+ init_workspace=False)
+ (work / '.ms_agent').mkdir(parents=True)
+ (work / '.ms_agent' / 'AGENTS.md').write_text('from-file\n', encoding='utf-8')
+ wf.write_project_instruction(str(work), '')
+ assert (pm.get(created.id).instruction or '') == ''
+ assert wf.read_project_instruction(str(work)).strip() == ''
+
+
+def test_profile_write_call_me_and_about(home):
+ wf.write_profile(call_me='Alice', description='Mostly agent work.')
+ call_me, about = wf.read_profile()
+ assert call_me == 'Alice'
+ assert about == 'Mostly agent work.'
+ text = (home / 'PROFILE.md').read_text(encoding='utf-8')
+ assert '- Call me: Alice' in text
+ assert 'Mostly agent work.' in text
+ block = wf.profile_block()
+ assert 'Alice' in block and 'Mostly agent work.' in block
+ wf.write_profile(call_me='')
+ call_me, about = wf.read_profile()
+ assert call_me == ''
+ assert about == 'Mostly agent work.'
diff --git a/tests/prompting/test_workspace_internals_hint.py b/tests/prompting/test_workspace_internals_hint.py
index 6df513449..b58920492 100644
--- a/tests/prompting/test_workspace_internals_hint.py
+++ b/tests/prompting/test_workspace_internals_hint.py
@@ -120,3 +120,25 @@ def test_it_reaches_the_system_prompt(tmp_path):
agent.session_log = SessionLog(workspace / 'sessions/sid', 'session_sid')
agent.session_log.set_metadata_field('fork_after_seq', 6)
assert 'branched session with an independent plan' in agent._build_system_content()
+
+
+def test_system_prompt_does_not_dump_config_playbook(tmp_path, monkeypatch):
+ """86119007: the memory/MCP playbook stays out of the always-on system prompt."""
+ home = tmp_path / 'ms_home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ workspace = tmp_path / 'mounted'
+ workspace.mkdir()
+ records = tmp_path / 'data' / 'projects' / 'p1' / 'sessions' / 'sid'
+ records.mkdir(parents=True)
+
+ section = _agent_for(
+ workspace, session_id='sid',
+ log_dir=records)._build_workspace_internals_section()
+
+ assert '## Configuring memory and MCP' not in section
+ assert '/memory on' not in section
+ assert '/mcp add' not in section
+ assert 'Do not add memory settings to' not in section
+ memory_md = str(workspace.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md')
+ assert memory_md not in section
diff --git a/tests/skill/test_skill_discovery.py b/tests/skill/test_skill_discovery.py
index 9e0f7477e..e9af2afc6 100644
--- a/tests/skill/test_skill_discovery.py
+++ b/tests/skill/test_skill_discovery.py
@@ -81,6 +81,28 @@ def _read_text(path, *args, **kwargs):
assert reads == [tmp_path / 'alpha' / 'SKILL.md']
+def test_discover_lists_skill_without_description(tmp_path):
+ skill = tmp_path / 'demo-skill'
+ skill.mkdir()
+ (skill / 'SKILL.md').write_text(
+ '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8')
+ found = SkillLoader().discover_skills(str(tmp_path))
+ row = next(iter(found.values()))
+ assert row.name == 'demo-skill'
+ assert row.description == 'demo-skill'
+
+
+def test_parse_directory_fills_missing_description(tmp_path):
+ skill = tmp_path / 'demo-skill'
+ skill.mkdir()
+ (skill / 'SKILL.md').write_text(
+ '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8')
+ schema = SkillSchemaParser.parse_skill_directory(skill)
+ assert schema is not None
+ assert schema.name == 'demo-skill'
+ assert schema.description == 'demo-skill'
+
+
def test_full_parse_reuses_exact_legacy_files_signature(tmp_path):
skill = _make_skill(tmp_path, 'alpha')
(skill / 'z.txt').write_text('z', encoding='utf-8')
diff --git a/tests/skill/test_skills_manager.py b/tests/skill/test_skills_manager.py
index b50e0029b..9d963a1ba 100644
--- a/tests/skill/test_skills_manager.py
+++ b/tests/skill/test_skills_manager.py
@@ -102,3 +102,43 @@ def test_corrupt_file_is_preserved(self, mgr, tmp_path):
with pytest.raises(ValueError, match='valid JSON'):
mgr.add_source('new-source')
assert path.read_text() == 'not json{{{'
+
+ def test_import_from_path_copies_skill_dir(self, mgr, tmp_path):
+ src = tmp_path / 'pack' / 'demo-skill'
+ src.mkdir(parents=True)
+ (src / 'SKILL.md').write_text('# Demo\n')
+ names = mgr.import_from_path(str(src), scope='global')
+ assert names == ['demo-skill']
+ dest = mgr.global_skills_tree() / 'demo-skill' / 'SKILL.md'
+ assert dest.is_file()
+ assert dest.read_text() == '# Demo\n'
+
+ def test_import_from_path_project_scope(self, mgr, tmp_path):
+ proj = tmp_path / 'repo'
+ proj.mkdir()
+ src = tmp_path / 'local-skill'
+ src.mkdir()
+ (src / 'SKILL.md').write_text('# Local\n')
+ names = mgr.import_from_path(
+ str(src), scope='project', project_path=str(proj))
+ assert names == ['local-skill']
+ dest = mgr.project_skills_tree(str(proj)) / 'local-skill' / 'SKILL.md'
+ assert dest.is_file()
+
+ def test_remove_imported_deletes_live_tree_dir(self, mgr, tmp_path):
+ src = tmp_path / 'pack' / 'demo-skill'
+ src.mkdir(parents=True)
+ (src / 'SKILL.md').write_text('# Demo\n')
+ mgr.import_from_path(str(src), scope='global')
+ mgr.set_skill_enabled('demo-skill', False)
+ dest = mgr.remove_imported('demo-skill', scope='global')
+ assert not dest.exists()
+ assert 'demo-skill' not in mgr.load_global().get('disabled', [])
+
+ def test_remove_imported_rejects_non_managed(self, mgr):
+ with pytest.raises(FileNotFoundError, match='not a managed skill'):
+ mgr.remove_imported('ghost')
+
+ def test_import_from_path_missing_raises(self, mgr, tmp_path):
+ with pytest.raises(FileNotFoundError):
+ mgr.import_from_path(str(tmp_path / 'missing'))
diff --git a/tests/skill/test_update_notice.py b/tests/skill/test_update_notice.py
index b123c4e81..ee4ffa8aa 100644
--- a/tests/skill/test_update_notice.py
+++ b/tests/skill/test_update_notice.py
@@ -90,7 +90,8 @@ def test_update_notice_hint_present_when_enabled(self, tmp_path):
injector = SkillPromptInjector(cat, update_notice=True)
section = injector.build_skill_prompt_section()
assert 'skill-update notice' in section
- assert 'LATEST one is authoritative' in section
+ assert 'skills_list' in section
+ assert 'FULL' not in section
def test_hint_absent_by_default(self, tmp_path):
cat = _catalog(tmp_path)
diff --git a/tests/skills/test_update_config.py b/tests/skills/test_update_config.py
new file mode 100644
index 000000000..33b5137f5
--- /dev/null
+++ b/tests/skills/test_update_config.py
@@ -0,0 +1,48 @@
+"""The bundled update-config skill is not loaded.
+
+TUI still prepares the skill catalog so user and project skills work.
+"""
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+from omegaconf import OmegaConf
+
+from ms_agent.agent.llm_agent import LLMAgent
+from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR, SkillCatalog
+from ms_agent.skill.harness import fill_harness_placeholders
+from ms_agent.tui.app import TuiApp
+
+
+def test_update_config_is_not_bundled():
+ assert not (BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md').exists()
+ catalog = SkillCatalog(config=OmegaConf.create({}))
+ catalog.load_from_config(OmegaConf.create({}))
+ assert catalog.get_skill('update-config') is None
+
+
+def test_other_skills_are_not_rewritten():
+ body = 'Keep {memory_md} literal in user skills.'
+ out = fill_harness_placeholders(body, None, skill_id='some-user-skill')
+ assert out == body
+
+
+def test_tui_defaults_still_prepare_skills(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ cfg = TuiApp._load_runtime_config(
+ 'unused.yaml', str(work), explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work))
+ assert bool(getattr(cfg, 'skills', None))
+ assert OmegaConf.select(cfg, 'skills.prompt_injection') == 'all'
+
+ agent = LLMAgent(config=cfg, tag='tui-skills')
+ agent.tool_manager = MagicMock()
+ agent.tool_manager.index_extra_tool = AsyncMock()
+ asyncio.run(agent.prepare_skills())
+ assert agent._skill_catalog is not None
+ assert agent._skill_catalog.get_skill('update-config') is None
+ assert 'update-config' not in agent._build_system_content()
diff --git a/tests/tools/test_mcp_teardown.py b/tests/tools/test_mcp_teardown.py
new file mode 100644
index 000000000..653a946ca
--- /dev/null
+++ b/tests/tools/test_mcp_teardown.py
@@ -0,0 +1,275 @@
+"""86121430: MCP streamable_http / jupyter teardown must not dump stack on quit."""
+from __future__ import annotations
+
+import asyncio
+import base64
+import inspect
+import os
+from datetime import timedelta
+from types import SimpleNamespace
+
+import pytest
+from mcp.client.session import ClientSession
+
+from ms_agent.tools.mcp_client import (MCPClient, _is_teardown_noise,
+ _session_read_timeout,
+ _tool_call_is_error, _tool_input_schema)
+
+
+def test_session_read_timeout_matches_installed_client():
+ value = _session_read_timeout(12)
+ annotation = str(inspect.signature(
+ ClientSession.__init__).parameters['read_timeout_seconds'].annotation)
+ if 'timedelta' in annotation and 'float' not in annotation:
+ assert value == timedelta(seconds=12)
+ else:
+ assert value == 12.0
+
+
+def test_tool_call_error_reads_mcp2_name_then_mcp1_name():
+ assert _tool_call_is_error(SimpleNamespace(is_error=True))
+ assert not _tool_call_is_error(SimpleNamespace(is_error=False))
+ assert _tool_call_is_error(SimpleNamespace(isError=True))
+ assert not _tool_call_is_error(SimpleNamespace(isError=False))
+ both = SimpleNamespace(is_error=False, isError=True)
+ assert not _tool_call_is_error(both)
+
+
+def test_tool_schema_reads_mcp2_name_then_mcp1_name():
+ class _V2:
+ input_schema = {'type': 'object', 'properties': {'msg': {}}}
+
+ class _V1:
+ inputSchema = {'type': 'object', 'properties': {'q': {}}}
+
+ class _Neither:
+ pass
+
+ assert _tool_input_schema(_V2())['properties'] == {'msg': {}}
+ assert _tool_input_schema(_V1())['properties'] == {'q': {}}
+ both = _V2()
+ both.inputSchema = {'type': 'object', 'properties': {'old': {}}}
+ assert 'msg' in _tool_input_schema(both)['properties']
+ assert _tool_input_schema(_Neither()) == {
+ 'type': 'object',
+ 'properties': {},
+ }
+
+
+def test_is_teardown_noise_matches_sdk_errors():
+ assert _is_teardown_noise(
+ RuntimeError(
+ 'Attempted to exit cancel scope in a different task than it was entered in'
+ ))
+ assert _is_teardown_noise(
+ RuntimeError('athrow(): asynchronous generator is already running'))
+ assert not _is_teardown_noise(RuntimeError('connection refused'))
+
+
+@pytest.mark.asyncio
+async def test_stop_server_does_not_cancel_owner_when_waiter_is_cancelled():
+ client = MCPClient({'mcpServers': {}})
+ shutdown = asyncio.Event()
+ cancelled = asyncio.Event()
+ closed = asyncio.Event()
+
+ async def _owner():
+ try:
+ await shutdown.wait()
+ await asyncio.sleep(0.05)
+ except asyncio.CancelledError:
+ cancelled.set()
+ raise
+ finally:
+ closed.set()
+
+ task = asyncio.create_task(_owner())
+ client._server_shutdown['time'] = shutdown
+ client._server_tasks['time'] = task
+ await asyncio.sleep(0)
+
+ async def _waiter():
+ await client._stop_server('time', graceful=True)
+
+ waiter = asyncio.create_task(_waiter())
+ await asyncio.sleep(0)
+ waiter.cancel()
+ await waiter
+ await asyncio.wait_for(closed.wait(), timeout=1)
+ assert not cancelled.is_set()
+ assert task.done()
+ assert not task.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_stop_server_swallows_cancel_scope_runtimeerror():
+ client = MCPClient({'mcpServers': {}})
+ shutdown = asyncio.Event()
+
+ async def _owner():
+ await shutdown.wait()
+ raise RuntimeError(
+ 'Attempted to exit cancel scope in a different task than it was entered in'
+ )
+
+ client._server_shutdown['time'] = shutdown
+ client._server_tasks['time'] = asyncio.create_task(_owner())
+ await asyncio.sleep(0)
+ await client._stop_server('time', graceful=True)
+
+
+@pytest.mark.asyncio
+async def test_cleanup_tools_swallows_mcp_runtimeerror():
+ from ms_agent.agent.llm_agent import LLMAgent
+
+ agent = LLMAgent.__new__(LLMAgent)
+ agent._tools_cleaned = False
+ agent.task_manager = None
+ agent.tool_manager = None
+ agent.memory_tools = []
+
+ class _Boom:
+ async def stop(self):
+ raise RuntimeError(
+ 'Attempted to exit cancel scope in a different task than it was entered in'
+ )
+
+ agent.mcp_runtime = _Boom()
+ await agent.cleanup_tools()
+ await agent.cleanup_tools() # idempotent
+
+
+@pytest.mark.asyncio
+async def test_quit_during_connect_does_not_leave_a_cancelled_future():
+ import gc
+
+ loop = asyncio.get_running_loop()
+ logged: list[str] = []
+ previous = loop.get_exception_handler()
+
+ def _handler(_loop, context):
+ logged.append(str(context.get('message', '')))
+
+ loop.set_exception_handler(_handler)
+ client = MCPClient({'mcpServers': {}})
+
+ async def _hang(stack, server_name, **kwargs):
+ await asyncio.Event().wait()
+
+ client._open_session = _hang # type: ignore[method-assign]
+ connect = asyncio.create_task(
+ client.connect_to_server('slow', startup_timeout=5))
+ await asyncio.sleep(0.02)
+ connect.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await connect
+ try:
+ await client.cleanup()
+ gc.collect()
+ await asyncio.sleep(0)
+ assert all(task.done() for task in client._live_tasks)
+ assert not any('never retrieved' in message for message in logged)
+ finally:
+ loop.set_exception_handler(previous)
+ await client.cleanup()
+
+
+@pytest.mark.asyncio
+async def test_stdio_hook_records_the_child_process():
+ import mcp.client.stdio as stdio_mod
+
+ from ms_agent.tools.mcp_client import _install_stdio_child_hook, _stdio_child
+
+ class _Proc:
+ returncode = None
+
+ async def fake_create(*args, **kwargs):
+ del args, kwargs
+ return _Proc()
+
+ previous = stdio_mod._create_platform_compatible_process
+ stdio_mod._create_platform_compatible_process = fake_create
+ try:
+ _install_stdio_child_hook()
+ hooked = stdio_mod._create_platform_compatible_process
+ holder: list = []
+ token = _stdio_child.set(holder)
+ try:
+ process = await hooked('npx', ['-y'])
+ finally:
+ _stdio_child.reset(token)
+ assert holder == [process]
+ process.returncode = 1
+ client = MCPClient({'mcpServers': {}})
+ client._stdio_children['box'] = process
+ assert client.stdio_process_exited('box')
+ finally:
+ stdio_mod._create_platform_compatible_process = previous
+
+
+class _FakeSession:
+ def __init__(self, response):
+ self.response = response
+
+ async def call_tool(self, tool_name, tool_args):
+ return self.response
+
+
+@pytest.mark.asyncio
+async def test_image_audio_and_structured_results_reach_the_caller(tmp_path):
+ client = MCPClient()
+ client.output_dir = str(tmp_path)
+ png = base64.b64encode(b'\x89PNG').decode()
+ wav = base64.b64encode(b'RIFF').decode()
+ client.sessions['s'] = _FakeSession(
+ SimpleNamespace(
+ is_error=False,
+ content=[
+ SimpleNamespace(type='image', data=png, mime_type='image/png'),
+ SimpleNamespace(type='audio', data=wav, mimeType='audio/wav'),
+ ],
+ structured_content=None,
+ ))
+ result = await client.call_tool('s', 'shot', {})
+ assert result['attachments'][0]['type'] == 'image'
+ assert result['attachments'][0]['media_type'] == 'image/png'
+ assert os.path.isfile(os.path.join(tmp_path, result['attachments'][0]['path']))
+ assert 'Audio returned' in result['text']
+ assert 'saved at' in result['text']
+
+ client.sessions['s'] = _FakeSession(
+ SimpleNamespace(
+ isError=False,
+ content=[],
+ structuredContent={'ok': True},
+ ))
+ text = await client.call_tool('s', 'ping', {})
+ assert '"ok": true' in text
+
+
+@pytest.mark.asyncio
+async def test_resource_without_model_dump_json_still_returns():
+ client = MCPClient()
+ client.sessions['s'] = _FakeSession(
+ SimpleNamespace(
+ is_error=False,
+ content=[SimpleNamespace(type='resource', resource={'uri': 'file://a'})],
+ structured_content=None,
+ ))
+ result = await client.call_tool('s', 'read', {})
+ assert result['resources'] == [{'uri': 'file://a'}]
+
+
+def test_output_schema_is_appended_to_the_tool_description():
+ listed = SimpleNamespace(
+ tools=[
+ SimpleNamespace(
+ name='ping',
+ description='Ping.',
+ input_schema={'type': 'object'},
+ output_schema={'type': 'object', 'properties': {'msg': {}}},
+ )
+ ])
+ tools = MCPClient()._filter_session_tools('mini', listed)
+ assert 'Returns:' in tools[0]['description']
+ assert 'msg' in tools[0]['description']
diff --git a/tests/tools/test_shell_environment.py b/tests/tools/test_shell_environment.py
index 3d135d252..35388c2ab 100644
--- a/tests/tools/test_shell_environment.py
+++ b/tests/tools/test_shell_environment.py
@@ -42,6 +42,29 @@ async def run():
asyncio.run(run())
+def test_shell_call_ignores_unknown_description(tmp_path):
+
+ async def run():
+ tool = LocalCodeExecutionTool(
+ config(tmp_path, include=['shell_executor']))
+ await tool.connect()
+ try:
+ raw = await tool.call_tool(
+ 'code_executor',
+ tool_name='shell_executor',
+ tool_args={
+ 'command': 'echo shell-ready',
+ 'description': 'list the news',
+ })
+ finally:
+ await tool.cleanup()
+ assert 'unexpected keyword' not in raw
+ payload = json.loads(raw)
+ assert payload['success'] and 'shell-ready' in payload['output']
+
+ asyncio.run(run())
+
+
@pytest.mark.skipif(os.name == 'nt', reason='POSIX command quoting')
def test_real_shell_and_background_use_selected_python(tmp_path, monkeypatch):
project_env = tmp_path / 'task-env'
diff --git a/tests/tools/test_todolist_lock_dir.py b/tests/tools/test_todolist_lock_dir.py
index 3ef8728fe..f4a459288 100644
--- a/tests/tools/test_todolist_lock_dir.py
+++ b/tests/tools/test_todolist_lock_dir.py
@@ -51,6 +51,17 @@ def test_explicit_lock_subdir_still_wins(tmp_path):
assert tool._lock_dir() == os.path.join(str(tmp_path), '.mylocks')
+def test_absolute_plan_filename_is_not_joined_under_output_dir(tmp_path):
+ sess = tmp_path / 'sessions' / 'abc'
+ sess.mkdir(parents=True)
+ plan_json = str(sess / 'plan.json')
+ plan_md = str(sess / 'plan.md')
+ tool = _make(tmp_path, plan_filename=plan_json, plan_md_filename=plan_md)
+ paths = tool._paths()
+ assert paths.plan_json == plan_json
+ assert paths.plan_md == plan_md
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize('auto_render', [True, False])
async def test_session_plan_locations_match_tool_contract(tmp_path, auto_render):
diff --git a/tests/tools/test_tool_name_and_reread.py b/tests/tools/test_tool_name_and_reread.py
index 279ca24d7..6a2994e60 100644
--- a/tests/tools/test_tool_name_and_reread.py
+++ b/tests/tools/test_tool_name_and_reread.py
@@ -36,6 +36,33 @@ def test_ambiguous_shorthand_is_not_guessed():
assert resolved is None
+def test_unique_suffix_resolves_with_the_same_note():
+ manager = _manager_with(
+ ['skills---skills_list', 'code_executor---shell_executor'])
+ resolved, note = manager._resolve_tool_name('skills_list')
+ assert resolved == 'skills---skills_list'
+ assert 'resolved to' in note
+ assert 'skills---skills_list' in note
+
+
+def test_tool_segment_containing_separator_is_the_suffix():
+ manager = _manager_with(['srv---alpha---beta'])
+ resolved, note = manager._resolve_tool_name('alpha---beta')
+ assert resolved == 'srv---alpha---beta'
+ assert 'resolved to' in note
+ assert manager._resolve_tool_name('beta')[0] is None
+
+
+def test_shared_suffix_is_not_guessed():
+ manager = _manager_with(['a---skills_list', 'b---skills_list'])
+ assert manager._resolve_tool_name('skills_list')[0] is None
+
+
+def test_fuzzy_neighbour_is_not_renamed():
+ manager = _manager_with(['code_executor---shell_executor'])
+ assert manager._resolve_tool_name('shell_command')[0] is None
+
+
def test_unknown_name_reports_candidates_not_a_traceback():
manager = _manager_with(
['file_system---read_file', 'file_system---write_file'])
diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py
new file mode 100644
index 000000000..a7610b6f8
--- /dev/null
+++ b/tests/tui/test_live_config.py
@@ -0,0 +1,414 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Next-message config sync stays on the same agent."""
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from omegaconf import OmegaConf
+
+from ms_agent.tui.live_config import apply_live_config
+
+
+@pytest.mark.asyncio
+async def test_model_change_rebuilds_the_client_in_place():
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'gpt-4o',
+ },
+ 'generation_config': {
+ 'stream': True,
+ },
+ })
+ live = SimpleNamespace(model='old', config=config, _setup_stub=False)
+ agent = SimpleNamespace(
+ config=config,
+ llm=live,
+ runtime=SimpleNamespace(llm=live),
+ memory_tools=[],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ _llm_fp='stale',
+ _mcp_fp=None,
+ _memory_guidance='',
+ )
+
+ class Rebuilt:
+ def __init__(self):
+ self.model = 'gpt-4o'
+ self.config = config
+
+ with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()):
+ await apply_live_config(agent, None)
+ assert agent.llm.model == 'gpt-4o'
+ assert agent.runtime.llm.model == 'gpt-4o'
+ assert agent._llm_fp != 'stale'
+
+
+@pytest.mark.asyncio
+async def test_unchanged_token_does_not_rebuild():
+ from ms_agent.llm.runtime_token import llm_runtime_token
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'gpt-4o',
+ },
+ })
+ live = SimpleNamespace(model='gpt-4o', config=config, _setup_stub=False)
+ agent = SimpleNamespace(
+ config=config,
+ llm=live,
+ runtime=SimpleNamespace(llm=live),
+ memory_tools=[],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ _llm_fp=llm_runtime_token(config),
+ _mcp_fp=None,
+ )
+ with patch('ms_agent.llm.LLM.from_config') as rebuild:
+ await apply_live_config(agent, None)
+ rebuild.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_startup_stub_is_left_alone():
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ },
+ })
+ live = SimpleNamespace(config=config, model='Qwen/Qwen3.8-Flash-Next',
+ _setup_stub=True)
+ agent = SimpleNamespace(
+ config=config,
+ llm=live,
+ runtime=SimpleNamespace(llm=live),
+ memory_tools=[],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ _llm_fp=None,
+ )
+ with patch('ms_agent.llm.LLM.from_config') as rebuild:
+ await apply_live_config(agent, None)
+ rebuild.assert_not_called()
+ assert agent.llm is live
+
+
+@pytest.mark.asyncio
+async def test_cleared_key_drops_the_live_client_and_a_new_key_rebuilds(
+ tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ 'modelscope_api_key': 'sk-SENTINEL-OLD',
+ 'api_key': 'sk-SENTINEL-OLD',
+ },
+ })
+ live = SimpleNamespace(
+ config=config,
+ model='Qwen/Qwen3.8-Flash-Next',
+ client=SimpleNamespace(api_key='sk-SENTINEL-OLD'),
+ _setup_stub=False,
+ )
+ memory = SimpleNamespace(llm=live, set_llm=lambda llm: setattr(
+ memory, 'seen', llm))
+ agent = SimpleNamespace(
+ config=config,
+ llm=live,
+ runtime=SimpleNamespace(llm=live),
+ memory_tools=[memory],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ _llm_fp='stale',
+ )
+
+ class _KeyError(Exception):
+ pass
+
+ _KeyError.__name__ = 'OpenAIError'
+
+ class Rebuilt:
+ def __init__(self):
+ self.model = 'Qwen/Qwen3.8-Flash-Next'
+ self.config = config
+ self.client = SimpleNamespace(api_key='sk-SENTINEL-NEW')
+ self._setup_stub = False
+
+ with patch('ms_agent.llm.LLM.from_config',
+ side_effect=_KeyError(
+ 'The api_key client option must be set')):
+ await apply_live_config(agent, None)
+ assert agent.llm is not live
+ assert agent.llm._setup_stub is True
+ assert agent.runtime.llm is agent.llm
+ assert agent.llm.config is config
+ assert not hasattr(agent.llm, 'client')
+ assert memory.seen is None
+
+ config.llm.modelscope_api_key = 'sk-SENTINEL-NEW'
+ config.llm.api_key = 'sk-SENTINEL-NEW'
+ with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()):
+ await apply_live_config(agent, None)
+ assert agent.llm.client.api_key == 'sk-SENTINEL-NEW'
+ assert agent.runtime.llm.client.api_key == 'sk-SENTINEL-NEW'
+ assert memory.seen is agent.llm
+
+
+@pytest.mark.asyncio
+async def test_unrelated_rebuild_error_keeps_the_live_client(
+ tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ 'modelscope_api_key': 'sk-SENTINEL-OLD',
+ },
+ })
+ live = SimpleNamespace(
+ config=config,
+ client=SimpleNamespace(api_key='sk-SENTINEL-OLD'),
+ _setup_stub=False,
+ )
+ agent = SimpleNamespace(
+ config=config,
+ llm=live,
+ runtime=SimpleNamespace(llm=live),
+ memory_tools=[],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ _llm_fp='stale',
+ )
+ with patch('ms_agent.llm.LLM.from_config',
+ side_effect=RuntimeError('catalog down')):
+ await apply_live_config(agent, None)
+ assert agent.llm is live
+ assert agent.llm.client.api_key == 'sk-SENTINEL-OLD'
+
+
+def test_missing_key_error_is_the_setup_sentence():
+ from ms_agent.llm.credentials import (
+ client_rejected_missing_key, is_missing_api_key_error,
+ missing_api_key_error)
+ config = OmegaConf.create({'llm': {'service': 'modelscope'}})
+ err = missing_api_key_error(config)
+ assert is_missing_api_key_error(err)
+ assert 'modelscope' in str(err)
+
+ class _KeyError(Exception):
+ pass
+
+ _KeyError.__name__ = 'OpenAIError'
+ assert client_rejected_missing_key(
+ _KeyError('The api_key client option must be set'))
+ assert not client_rejected_missing_key(RuntimeError('api_key'))
+ assert not client_rejected_missing_key(_KeyError('connection reset'))
+
+
+def _quiet_agent(tmp_path, **extra):
+ config = OmegaConf.create({'output_dir': str(tmp_path)})
+ agent = SimpleNamespace(
+ config=config,
+ llm=None,
+ runtime=SimpleNamespace(),
+ memory_tools=[],
+ _skill_runtime=None,
+ mcp_runtime=None,
+ tool_manager=None,
+ _llm_fp=None,
+ _mcp_fp=None,
+ _search_fp=None,
+ _live_notice_keys=set(),
+ notices=[],
+ )
+ agent._emit_user_notice = agent.notices.append
+ for key, value in extra.items():
+ setattr(agent, key, value)
+ return agent
+
+
+@pytest.mark.asyncio
+async def test_bad_json_is_said_once(tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ (tmp_path / 'mcp.json').write_text('{', encoding='utf-8')
+ agent = _quiet_agent(tmp_path)
+ await apply_live_config(agent, None)
+ await apply_live_config(agent, None)
+ assert agent.notices == ['mcp.json 读不进去。请检查格式。']
+
+
+@pytest.mark.asyncio
+async def test_pending_mcp_reconnects_when_the_file_is_unchanged(
+ tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ from ms_agent.tui.live_config import _token
+
+ class Runtime:
+ def __init__(self):
+ self.calls = 0
+
+ async def apply_config(self, config):
+ del config
+ self.calls += 1
+
+ def get_server(self, name):
+ del name
+ return SimpleNamespace(status='connected')
+
+ runtime = Runtime()
+ agent = _quiet_agent(
+ tmp_path,
+ mcp_runtime=runtime,
+ _mcp_fp=_token({}),
+ _mcp_reconnect_names={'fetch'},
+ )
+ await apply_live_config(agent, None)
+ await apply_live_config(agent, None)
+ assert runtime.calls == 1
+ assert agent.notices == []
+ assert agent._mcp_reconnect_names == set()
+
+
+@pytest.mark.asyncio
+async def test_failed_mcp_reconnect_is_said_once(tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ from ms_agent.tui.live_config import _token
+
+ class Runtime:
+ def __init__(self):
+ self.calls = 0
+
+ async def apply_config(self, config):
+ del config
+ self.calls += 1
+
+ def get_server(self, name):
+ del name
+ return SimpleNamespace(status='error')
+
+ runtime = Runtime()
+ agent = _quiet_agent(
+ tmp_path,
+ mcp_runtime=runtime,
+ _mcp_fp=_token({}),
+ _mcp_reconnect_names={'fetch'},
+ )
+ await apply_live_config(agent, None)
+ await apply_live_config(agent, None)
+ assert runtime.calls == 1
+ assert agent.notices == []
+
+
+@pytest.mark.asyncio
+async def test_unreachable_server_is_skipped_before_the_turn(
+ tmp_path, monkeypatch):
+ import asyncio
+ import json
+
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ monkeypatch.setattr('ms_agent.mcp.runtime.MCP_TURN_WAIT', 0.05)
+ (tmp_path / 'mcp.json').write_text(
+ json.dumps({
+ 'mcpServers': {
+ 'slow': {'command': 'sleep', 'args': ['30']},
+ 'good': {'command': 'echo', 'args': ['hi']},
+ },
+ }),
+ encoding='utf-8',
+ )
+ from ms_agent.config.mcp_schema import ResolvedMCPConfig
+ from ms_agent.mcp.runtime import MCPRuntime
+
+ class Hung:
+ def __init__(self):
+ self.sessions = {}
+ self.connect_calls: list[str] = []
+
+ def is_connected(self, name):
+ return name in self.sessions
+
+ async def connect_single_server(self, server_name, server_config, **kwargs):
+ self.connect_calls.append(server_name)
+ if server_name == 'slow':
+ await asyncio.Event().wait()
+ self.sessions[server_name] = object()
+ return server_name
+
+ async def disconnect_server(self, server_name, **kwargs):
+ self.sessions.pop(server_name, None)
+
+ async def get_tools_for_server(self, server_name):
+ return []
+
+ client = Hung()
+ runtime = MCPRuntime(
+ mcp_client=client, # type: ignore[arg-type]
+ config=ResolvedMCPConfig(mcp_servers={}),
+ owns_client=False,
+ )
+ agent = _quiet_agent(tmp_path, mcp_runtime=runtime)
+ started = asyncio.get_running_loop().time()
+ try:
+ await apply_live_config(agent, None)
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 0.5
+ assert runtime.get_server('slow').status == 'connecting'
+ assert runtime.get_server('good').status == 'connected'
+ assert agent.notices == []
+ await apply_live_config(agent, None)
+ assert client.connect_calls.count('slow') == 1
+ assert agent.notices == []
+ finally:
+ await runtime._cancel_probes()
+
+
+@pytest.mark.asyncio
+async def test_memory_toggle_applies_on_the_next_message(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ from ms_agent.project import ProjectManager
+ ProjectManager(base_dir=str(home)).open_folder(
+ str(work), memory_enabled=True, memory_backend='file')
+ agent = _quiet_agent(work)
+ agent.runtime._memory_pending = True
+
+ async def load_memory():
+ agent.memory_tools.append(object())
+
+ agent.load_memory = load_memory
+ await apply_live_config(agent, None)
+ assert agent.config.memory.unified_memory.storage.backend == 'file'
+ assert agent.memory_tools
+ assert agent.runtime._memory_pending is False
+
+
+@pytest.mark.asyncio
+async def test_search_engine_reloads_on_the_next_message(tmp_path, monkeypatch):
+ monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path))
+ from ms_agent.config.search_settings import SearchSettingsManager
+
+ SearchSettingsManager(tmp_path).set_engine('arxiv')
+
+ class Tool:
+ SERVER_NAME = 'web_search'
+ _engine_types = ['tavily']
+ _api_keys = {}
+ _engines = {'tavily': object()}
+
+ def __init__(self):
+ self.reloaded = False
+
+ async def reload_from_config(self):
+ self.reloaded = True
+ self._engine_types = ['arxiv']
+
+ tool = Tool()
+ agent = _quiet_agent(tmp_path)
+ agent.tool_manager = SimpleNamespace(extra_tools=[tool])
+ await apply_live_config(agent, None)
+ assert tool.reloaded is True
diff --git a/tests/tui/test_live_terminal.py b/tests/tui/test_live_terminal.py
new file mode 100644
index 000000000..da4fb9e03
--- /dev/null
+++ b/tests/tui/test_live_terminal.py
@@ -0,0 +1,138 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""The streaming region and the prompt share a real terminal.
+
+A pipe cannot show this: Live has to redraw in place, and paste / arrow keys
+are only keys once prompt_toolkit owns the tty by itself.
+"""
+import os
+import pty
+import select
+import struct
+import subprocess
+import sys
+import termios
+import time
+
+import fcntl
+import pytest
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+CHILD = r'''
+import asyncio
+import sys
+
+from rich.console import Console
+
+from ms_agent.tui.input import PromptToolkitInput
+from ms_agent.tui.renderer import RichEventSink
+from ms_agent.tui.state import TuiState
+from ms_agent.ui.events import ContentDelta, ContentEnd
+
+
+async def main():
+ console = Console(force_terminal=True, width=40, height=12)
+ sink = RichEventSink(console, TuiState())
+ body = "\n".join("line %02d unique-token" % i for i in range(30))
+ sink.emit(ContentDelta(body))
+ await asyncio.sleep(0.45)
+ sink.emit(ContentEnd())
+ inp = PromptToolkitInput(TuiState(), console=console, renderer=sink)
+ if inp._session is None:
+ print("NO_SESSION", file=sys.stderr, flush=True)
+ return
+ inp._session.history.append_string("previous-command")
+ recalled = await inp.read_prompt()
+ pasted = await inp.read_prompt()
+ queued = await inp.read_prompt()
+ print("RESULTS %r %r %r" % (recalled, pasted, queued), file=sys.stderr, flush=True)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+'''
+
+
+def _read_pty(master, proc, buf, timeout):
+ """Pull pty bytes and stderr until timeout. Returns (buf, stderr_lines, why)."""
+ err_lines = []
+ end = time.time() + timeout
+ while time.time() < end:
+ watch = [master]
+ if proc.stderr is not None:
+ watch.append(proc.stderr)
+ ready, _, _ = select.select(watch, [], [], 0.1)
+ if master in ready:
+ try:
+ buf += os.read(master, 65536)
+ except OSError:
+ return buf, err_lines, 'closed'
+ if proc.stderr is not None and proc.stderr in ready:
+ line = proc.stderr.readline()
+ if line:
+ err_lines.append(line)
+ if line.startswith(b'RESULTS') or line.startswith(b'NO_SESSION'):
+ return buf, err_lines, 'done'
+ if proc.poll() is not None and not ready:
+ return buf, err_lines, 'exit'
+ return buf, err_lines, 'timeout'
+
+
+@pytest.mark.skipif(sys.platform == 'win32', reason='needs a posix pty')
+def test_one_screen_live_then_paste_and_history(tmp_path):
+ script = tmp_path / 'drive.py'
+ script.write_text(CHILD)
+ master, slave = pty.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0))
+ env = os.environ.copy()
+ env['PYTHONPATH'] = REPO
+ env['PYTHONUNBUFFERED'] = '1'
+ env['TERM'] = 'xterm-256color'
+ proc = subprocess.Popen(
+ [sys.executable, str(script)],
+ stdin=slave,
+ stdout=slave,
+ stderr=subprocess.PIPE,
+ env=env,
+ cwd=REPO,
+ )
+ os.close(slave)
+ buf = b''
+ err_lines = []
+ try:
+ prompt = '❯'.encode()
+ deadline = time.time() + 8
+ while time.time() < deadline and prompt not in buf:
+ buf, extra, why = _read_pty(master, proc, buf, 0.3)
+ err_lines.extend(extra)
+ if why in ('done', 'closed'):
+ break
+ assert prompt in buf, buf[-400:]
+ os.write(master, b'\x1b[A\r')
+
+ deadline = time.time() + 5
+ while time.time() < deadline and buf.count(prompt) < 2:
+ buf, extra, why = _read_pty(master, proc, buf, 0.3)
+ err_lines.extend(extra)
+ if why in ('done', 'closed'):
+ break
+ assert buf.count(prompt) >= 2, buf[-400:]
+ os.write(master, b'\x1b[200~/model qwen\nhello\x1b[201~\r')
+
+ buf, extra, why = _read_pty(master, proc, buf, 4)
+ err_lines.extend(extra)
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait(timeout=3)
+ os.close(master)
+
+ text = buf.decode('utf-8', 'replace')
+ err = b''.join(err_lines).decode()
+ # The first line of a tall reply is typeset once. The refresh loop only
+ # rewrites the last screen, in place.
+ assert text.count('line 00') == 1
+ assert text.count('Assistant') == 1
+ assert b'\x1b[1A' in buf
+ assert '^[[200~' not in text and '^[[201~' not in text
+ assert "RESULTS 'previous-command' '/model qwen' 'hello'" in err
diff --git a/tests/tui/test_renderer.py b/tests/tui/test_renderer.py
index 80f6a3bd7..2e4871eb1 100644
--- a/tests/tui/test_renderer.py
+++ b/tests/tui/test_renderer.py
@@ -2,7 +2,9 @@
"""RichEventSink: renders the AgentEvent stream and tracks status state."""
from io import StringIO
+import rich.live
from rich.console import Console
+from rich.markdown import Markdown
from ms_agent.tui.renderer import RichEventSink
from ms_agent.tui.state import TuiState
@@ -28,7 +30,7 @@ def test_content_stream_renders_text():
sink.emit(ContentDelta('Hello '))
sink.emit(ContentDelta('world'))
sink.emit(ContentEnd())
- assert 'Hello world' in _out(console)
+ assert _out(console).count('Hello world') == 1
def test_tool_call_and_result_render():
@@ -158,3 +160,132 @@ def test_nested_holds_flush_once_at_the_outermost_release():
sink.emit(Notice(level='success', text='inner'))
assert 'inner' not in _out(console) # inner exit must NOT flush
assert 'inner' in _out(console)
+
+
+def _rendered(renderable, width: int) -> str:
+ buf = StringIO()
+ Console(file=buf, force_terminal=False, width=width, height=50).print(
+ renderable)
+ return buf.getvalue()
+
+
+def test_unavailable_label_is_bold_and_the_rest_is_plain():
+ sink, console, _ = _sink()
+ captured = {}
+ original = console.print
+
+ def _spy(*args, **kwargs):
+ captured['args'] = args
+ return original(*args, **kwargs)
+
+ console.print = _spy
+ sink.emit(
+ Notice(
+ level='info',
+ text=('API key: not required\n'
+ 'UNAVAILABLE: Package `arxiv` is not installed.')))
+ body = captured['args'][0].renderable
+ assert body.plain.startswith('API key: not required')
+ assert '[bold]' not in body.plain
+ bold = [
+ span for span in body.spans
+ if getattr(span.style, 'bold', None) or span.style == 'bold'
+ ]
+ assert len(bold) == 1
+ assert body.plain[bold[0].start:bold[0].end] == 'UNAVAILABLE'
+
+
+def test_stream_window_is_only_the_last_screen():
+ console = Console(file=StringIO(), force_terminal=True, width=10, height=5)
+ sink = RichEventSink(console, TuiState())
+ window = sink._stream_window('HEAD' + ('x' * 200) + 'TAIL')
+ body = _rendered(window, 10).rstrip('\n')
+ assert 'TAIL' in body
+ assert 'HEAD' not in body
+ assert len(body.split('\n')) <= 4 # height - 1
+
+
+def test_live_refreshes_one_screen_then_typesets_once():
+ """A tall reply must not be the Live renderable, and the title is printed
+ once, after the refresh thread has been joined."""
+ events = []
+
+ class _Thread:
+ def join(self, timeout=None):
+ events.append('join')
+
+ class _Live:
+ def __init__(self, *args, **kwargs):
+ self.kwargs = kwargs
+ self.updates = []
+ self._refresh_thread = _Thread()
+ events.append(('live', kwargs))
+
+ def start(self):
+ events.append('start')
+
+ def update(self, renderable):
+ self.updates.append(renderable)
+ events.append(('update', renderable))
+
+ def stop(self):
+ events.append('stop')
+
+ console = Console(file=StringIO(), force_terminal=True, width=10, height=5)
+ sink = RichEventSink(console, TuiState())
+ original = console.print
+
+ def _spy(*args, **kwargs):
+ events.append(('print', args[0] if args else None))
+ return original(*args, **kwargs)
+
+ console.print = _spy
+ real = rich.live.Live
+ rich.live.Live = _Live
+ try:
+ sink.emit(ContentDelta('HEAD' + ('y' * 200) + 'TAIL'))
+ sink.emit(ContentEnd())
+ finally:
+ rich.live.Live = real
+
+ live = next(item[1] for item in events if item[0] == 'live')
+ assert live['vertical_overflow'] == 'crop'
+ assert live['transient'] is True
+ assert live['redirect_stdout'] is False
+ assert live['redirect_stderr'] is False
+ updated = next(item[1] for item in events if item[0] == 'update')
+ shown = _rendered(updated, 10)
+ assert 'TAIL' in shown and 'HEAD' not in shown
+ assert events.index('stop') < events.index('join')
+ markdown_at = next(
+ i for i, item in enumerate(events)
+ if item[0] == 'print' and isinstance(item[1], Markdown))
+ assert events.index('join') < markdown_at
+ titles = [
+ item[1] for item in events
+ if item[0] == 'print' and isinstance(item[1], str) and 'Assistant' in item[1]
+ ]
+ assert len(titles) == 1
+ assert sink._live is None
+
+
+def test_release_joins_refresh_thread_before_returning():
+ import sys
+ console = Console(file=StringIO(), force_terminal=True, width=20, height=8)
+ sink = RichEventSink(console, TuiState())
+ stdout = sys.stdout
+ try:
+ sink.emit(ContentDelta('partial reply'))
+ live = sink._live
+ thread = live._refresh_thread
+ assert thread is not None and thread.is_alive()
+ assert sys.stdout is stdout # Live must not steal stdout
+ sink.release_terminal()
+ assert not thread.is_alive()
+ assert sink._live is None
+ assert sys.stdout is stdout
+ finally:
+ sink.release_terminal()
+ out = _out(console)
+ assert out.count('Assistant') == 1
+ assert 'partial reply' in out
diff --git a/tests/tui/test_select.py b/tests/tui/test_select.py
index ff7b23e41..67f1c48c1 100644
--- a/tests/tui/test_select.py
+++ b/tests/tui/test_select.py
@@ -10,7 +10,7 @@
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
-from ms_agent.tui.select import _menu_async
+from ms_agent.tui.select import _menu_async, bind_sigint_rearm
OPTS = ['Allow once', 'Allow for this session', 'Always allow', 'Deny']
@@ -58,6 +58,42 @@ def test_ctrl_c_cancels_to_none():
assert _run('\x03') is None
+def test_menu_exit_rearms_sigint():
+ """The menu puts the caller's SIGINT handler back when it closes."""
+ import signal
+
+ hits = []
+
+ async def go():
+ loop = asyncio.get_running_loop()
+
+ def on_sigint():
+ hits.append(1)
+
+ loop.add_signal_handler(signal.SIGINT, on_sigint)
+ bind_sigint_rearm(
+ lambda: loop.add_signal_handler(signal.SIGINT, on_sigint))
+ try:
+ with create_pipe_input() as inp:
+ with create_app_session(input=inp, output=DummyOutput()):
+ inp.send_text('\x03')
+ result = await _menu_async(OPTS, 0)
+ before = len(hits)
+ signal.raise_signal(signal.SIGINT)
+ for _ in range(20):
+ if len(hits) > before:
+ break
+ await asyncio.sleep(0.02)
+ return result, len(hits) > before
+ finally:
+ bind_sigint_rearm(None)
+ loop.remove_signal_handler(signal.SIGINT)
+
+ result, alive = asyncio.run(go())
+ assert result is None
+ assert alive
+
+
def _run_with_header(keys: str, header: str):
async def go():
with create_pipe_input() as inp:
diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py
new file mode 100644
index 000000000..008363a70
--- /dev/null
+++ b/tests/tui/test_tui_config_align.py
@@ -0,0 +1,634 @@
+"""TUI must boot on the packaged yaml and run WebUI's default model.
+
+Tester gaps this file locks down:
+
+1. Writing ``tools.todo_list.plan_filename`` without ``mcp: false`` makes
+ ToolManager treat todo_list as an MCP server (``'url' or 'command'
+ parameter is required``). WebUI seeds settings.json; a fresh TUI home
+ does not.
+2. ``/model list`` already read settings.json, but the live LLM came from
+ Config.from_task(agent.yaml). Default TUI now goes through ConfigResolver
+ so ``default_model`` / ``llm`` is what the first turn actually uses.
+"""
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from omegaconf import OmegaConf
+from rich.console import Console
+
+from ms_agent.config.config import Config
+from ms_agent.config.resolver import ConfigResolver
+from ms_agent.project import SessionManager
+from ms_agent.tui.app import TUI_RESOLVER_DEFAULTS, TuiApp
+from ms_agent.tui.state import TuiState
+from ms_agent.tools.mcp_client import MCPClient
+from ms_agent.tools.tool_manager import ToolManager
+
+
+def _write_settings(home: Path, payload: dict) -> None:
+ home.mkdir(parents=True, exist_ok=True)
+ (home / 'settings.json').write_text(
+ json.dumps(payload), encoding='utf-8')
+
+
+def test_plan_filename_without_mcp_flag_is_treated_as_mcp():
+ """Document the crash the testers hit: update plan path, omit mcp:false."""
+ cfg = OmegaConf.create({})
+ OmegaConf.update(
+ cfg, 'tools.todo_list.plan_filename', '/tmp/plan.json', merge=True)
+ servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers']
+ assert 'todo_list' in servers
+ client = MCPClient(config=cfg)
+ assert 'todo_list' in client.mcp_config['mcpServers']
+
+ async def _boom():
+ try:
+ await client.connect()
+ finally:
+ await client.cleanup()
+
+ with pytest.raises(ValueError, match='url.*command|command.*url'):
+ asyncio.run(_boom())
+
+
+def test_bind_todo_list_session_sets_mcp_false(tmp_path):
+ cfg = OmegaConf.create({})
+ TuiApp._bind_todo_list_session(cfg, str(tmp_path / 'sess'))
+ assert cfg.tools.todo_list.mcp is False
+ assert cfg.tools.todo_list.plan_filename.endswith('plan.json')
+ servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers']
+ assert 'todo_list' not in servers
+ client = MCPClient(config=cfg)
+ assert 'todo_list' not in client.mcp_config['mcpServers']
+
+
+def test_fresh_home_default_yaml_does_not_mcp_connect_todo_list(
+ tmp_path, monkeypatch):
+ """No WebUI-seeded settings.json: default TUI still must not MCP todo_list."""
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work))
+ TuiApp._bind_todo_list_session(cfg, str(work / 'sess'))
+ assert cfg.tools.todo_list.mcp is False
+ servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers']
+ assert 'todo_list' not in servers
+
+ async def _connect():
+ # This is the call that raised "'url' or 'command' parameter is
+ # required" when todo_list lacked mcp:false.
+ client = MCPClient(config=cfg)
+ try:
+ await client.connect()
+ finally:
+ await client.cleanup()
+ OmegaConf.update(cfg, 'llm.api_key', 'sk-test', merge=True)
+ OmegaConf.update(cfg, 'llm.modelscope_api_key', 'sk-test', merge=True)
+ manager = ToolManager(cfg)
+ try:
+ await manager.connect()
+ names = {
+ getattr(t, 'SERVER_NAME', None)
+ for t in (manager.extra_tools or [])
+ }
+ assert 'todo_list' in names
+ assert 'web_search' in names
+ finally:
+ await manager.cleanup()
+
+ asyncio.run(_connect())
+
+
+def test_fresh_home_enables_web_search(tmp_path, monkeypatch):
+ """A home with no settings.json still resolves WebUI's search default."""
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ assert cfg.tools.web_search.enabled is True
+ assert cfg.tools.web_search.engine == 'tavily'
+ assert cfg.tools.web_search.mcp is False
+ assert not (home / 'settings.json').is_file()
+
+
+def test_settings_can_turn_web_search_off(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ _write_settings(home, {'tools': {'web_search': {'enabled': False}}})
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ assert cfg.tools.web_search.enabled is False
+
+
+def test_default_tui_disables_snapshots(tmp_path, monkeypatch):
+ """TUI has no rollback slash; auto-snapshot must not run on first turn."""
+ from ms_agent.agent.llm_agent import LLMAgent
+
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work))
+ assert LLMAgent.resolve_enable_snapshots(cfg) is False
+
+
+def test_explicit_config_can_reenable_snapshots(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ yaml_path = tmp_path / 'with-snaps.yaml'
+ yaml_path.write_text(
+ 'enable_snapshots: true\n'
+ 'llm:\n service: modelscope\n model: from-yaml\n'
+ 'tools:\n file_system:\n mcp: false\n',
+ encoding='utf-8')
+
+ cfg = TuiApp._load_runtime_config(
+ str(yaml_path), str(work), explicit_config=True)
+ from ms_agent.agent.llm_agent import LLMAgent
+ assert LLMAgent.resolve_enable_snapshots(cfg) is True
+
+
+def test_default_tui_uses_webui_default_model(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ _write_settings(home, {
+ 'default_model': 'openai/qwen3.7-plus',
+ 'llm': {
+ 'provider': 'openai',
+ 'model': 'qwen3.7-plus',
+ },
+ 'providers': {
+ 'openai': {
+ 'api_key': 'sk-from-catalog',
+ 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+ 'protocol': 'openai',
+ },
+ },
+ })
+
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work))
+ assert cfg.llm.service == 'openai'
+ assert cfg.llm.model == 'qwen3.7-plus'
+ assert cfg.llm.use_provider_router is True
+ assert cfg.llm.openai_api_key == 'sk-from-catalog'
+ assert cfg.llm.protocol == 'openai'
+
+
+def test_default_model_only_no_llm_block(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ _write_settings(home, {'default_model': 'openai/qwen3.7-plus'})
+
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ assert cfg.llm.service == 'openai'
+ assert cfg.llm.model == 'qwen3.7-plus'
+
+
+def test_project_patch_wins_over_settings_model(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ _write_settings(home, {
+ 'default_model': 'openai/qwen3.7-plus',
+ 'llm': {'provider': 'openai', 'model': 'qwen3.7-plus'},
+ })
+ patch_dir = work / '.ms_agent'
+ patch_dir.mkdir()
+ (patch_dir / 'config.yaml').write_text(
+ 'llm:\n service: modelscope\n model: patched-model\n',
+ encoding='utf-8')
+
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ assert cfg.llm.model == 'patched-model'
+ assert cfg.llm.service == 'modelscope'
+
+
+def test_explicit_config_yaml_wins_over_settings(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'work'
+ work.mkdir()
+ _write_settings(home, {
+ 'default_model': 'openai/qwen3.7-plus',
+ 'llm': {'provider': 'openai', 'model': 'qwen3.7-plus'},
+ })
+ yaml_path = tmp_path / 'custom.yaml'
+ yaml_path.write_text(
+ 'llm:\n service: modelscope\n model: from-yaml\n'
+ 'tools:\n file_system:\n mcp: false\n',
+ encoding='utf-8')
+
+ cfg = TuiApp._load_runtime_config(
+ str(yaml_path), str(work), explicit_config=True)
+ assert cfg.llm.model == 'from-yaml'
+ assert cfg.llm.service == 'modelscope'
+
+
+def test_resolver_defaults_keep_mcp_false_when_plan_paths_merge(tmp_path):
+ """WebUI-shaped overlay: plan filenames without repeating mcp:false."""
+ resolver = ConfigResolver(
+ global_dir=str(tmp_path / 'home'),
+ defaults=TUI_RESOLVER_DEFAULTS,
+ )
+ cfg = resolver.resolve(
+ session_overrides={
+ 'tools': {
+ 'todo_list': {
+ 'plan_filename': '/tmp/s/plan.json',
+ 'plan_md_filename': '/tmp/s/plan.md',
+ },
+ },
+ })
+ assert cfg.tools.todo_list.mcp is False
+ assert 'todo_list' not in Config.convert_mcp_servers_to_json(
+ cfg)['mcpServers']
+
+
+def test_apply_session_then_toolmanager_connect(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ session = app._sm.create(name='chat')
+ cfg = TuiApp._load_runtime_config('unused.yaml', str(work),
+ explicit_config=False)
+ cfg = TuiApp._prepare_config(cfg, None, str(work), app._project)
+ app.agent = SimpleNamespace(config=cfg, load_cache=False)
+ app.state = TuiState(model='m', perm='auto', work_dir=str(work))
+ app._apply_session(session, resume=False)
+
+ async def _connect():
+ client = MCPClient(config=app.agent.config)
+ try:
+ await client.connect()
+ assert 'todo_list' not in (
+ client.mcp_config.get('mcpServers') or {})
+ finally:
+ await client.cleanup()
+ OmegaConf.update(app.agent.config, 'llm.api_key', 'sk-test', merge=True)
+ OmegaConf.update(
+ app.agent.config, 'llm.modelscope_api_key', 'sk-test', merge=True)
+ manager = ToolManager(app.agent.config)
+ try:
+ await manager.connect()
+ servers = getattr(manager.servers, 'mcp_config', {}) or {}
+ assert 'todo_list' not in (servers.get('mcpServers') or {})
+ finally:
+ await manager.cleanup()
+
+ asyncio.run(_connect())
+
+
+def test_fill_provider_catalog_does_not_clobber_llm_keys():
+ cfg = ConfigResolver._settings_to_agent_config({
+ 'llm': {
+ 'provider': 'openai',
+ 'model': 'qwen3.7-plus',
+ 'api_key': 'sk-llm-block',
+ },
+ 'providers': {
+ 'openai': {
+ 'api_key': 'sk-catalog',
+ 'base_url': 'https://example.invalid/v1',
+ 'protocol': 'openai',
+ },
+ },
+ })
+ assert cfg.llm.openai_api_key == 'sk-llm-block'
+ assert cfg.llm.openai_base_url == 'https://example.invalid/v1'
+ assert cfg.llm.protocol == 'openai'
+
+
+def test_missing_api_key_is_classed_as_setup_not_fatal():
+ from ms_agent.llm.credentials import (
+ is_missing_api_key_error,
+ missing_api_key_setup_text,
+ )
+ err = ValueError('No API key found for provider "modelscope"')
+ assert is_missing_api_key_error(err)
+ assert not is_missing_api_key_error(ValueError('boom'))
+ assert not is_missing_api_key_error(RuntimeError('No API key found'))
+ text = missing_api_key_setup_text(err)
+ assert text == ('还没有 modelscope 的密钥。\n\n'
+ ' /model provider key modelscope <密钥>')
+
+
+@pytest.mark.asyncio
+async def test_serve_keeps_repl_after_turn_api_error():
+ """A provider 400 must not print bye and exit the TUI."""
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ buf = io.StringIO()
+ app.console = Console(file=buf, force_terminal=False, width=100)
+ app.renderer = MagicMock()
+ app._queued_query = None
+ app._pending_switch = None
+ app._model = 'm'
+ app.state = TuiState(model='m')
+ app._owned_session_ids = set()
+ session = SimpleNamespace(id='s1', name='Session 1')
+ app._sm = MagicMock()
+ app._sm.create.return_value = session
+ app.session = None
+ app._banner = lambda: None
+ applied = []
+
+ def _apply(sess, resume=False):
+ applied.append(resume)
+ app.session = sess
+
+ app._apply_session = _apply
+ app._name_session_from_log = lambda: None
+ app._prune_if_empty = lambda s: None
+
+ runs = {'n': 0}
+
+ async def fake_run(query=None, stream=True):
+ runs['n'] += 1
+ if runs['n'] == 1:
+ raise RuntimeError(
+ "Error code: 400 - {'error': {'message': "
+ "'The product is not activated'}}")
+ raise EOFError()
+
+ app.agent = SimpleNamespace(
+ run=fake_run,
+ config=OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'm',
+ },
+ }),
+ )
+
+ await app._serve()
+ out = buf.getvalue()
+ assert runs['n'] == 2
+ assert True in applied # failed turn resumed, not a fresh session
+ assert 'session still open' in out
+ assert '/quit to exit' in out
+ assert 'The product is not activated' not in out # no duplicate crash panel
+ assert out.strip().endswith('bye') or 'bye' in out
+
+
+@pytest.mark.asyncio
+async def test_serve_stays_open_on_cancel_scope_error():
+ """A leaked MCP cancel-scope error must not quit the TUI."""
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ buf = io.StringIO()
+ app.console = Console(file=buf, force_terminal=False, width=100)
+ app.renderer = MagicMock()
+ app._queued_query = None
+ app._pending_switch = None
+ app._model = 'm'
+ app.state = TuiState(model='m')
+ app._owned_session_ids = set()
+ session = SimpleNamespace(id='s1', name='Session 1')
+ app._sm = MagicMock()
+ app._sm.create.return_value = session
+ app.session = None
+ app._banner = lambda: None
+ app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess)
+ app._name_session_from_log = lambda: None
+ app._prune_if_empty = lambda s: None
+
+ runs = {'n': 0}
+
+ async def fake_run(query=None, stream=True):
+ runs['n'] += 1
+ if runs['n'] == 1:
+ raise RuntimeError(
+ 'Attempted to exit cancel scope in a different task')
+ raise EOFError()
+
+ app.agent = SimpleNamespace(
+ run=fake_run,
+ config=OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'm',
+ },
+ }),
+ runtime=SimpleNamespace(should_stop=False),
+ )
+
+ await app._serve()
+ out = buf.getvalue()
+ assert runs['n'] == 2
+ assert 'session still open' in out
+ assert 'bye' in out
+
+
+@pytest.mark.asyncio
+async def test_serve_stays_open_on_cancelled_error_that_is_not_quit():
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ buf = io.StringIO()
+ app.console = Console(file=buf, force_terminal=False, width=100)
+ app.renderer = MagicMock()
+ app._queued_query = None
+ app._pending_switch = None
+ app._model = 'm'
+ app.state = TuiState(model='m')
+ app._owned_session_ids = set()
+ session = SimpleNamespace(id='s1', name='Session 1')
+ app._sm = MagicMock()
+ app._sm.create.return_value = session
+ app.session = None
+ app._banner = lambda: None
+ app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess)
+ app._name_session_from_log = lambda: None
+ app._prune_if_empty = lambda s: None
+
+ runs = {'n': 0}
+
+ async def fake_run(query=None, stream=True):
+ runs['n'] += 1
+ if runs['n'] == 1:
+ raise asyncio.CancelledError()
+ raise EOFError()
+
+ app.agent = SimpleNamespace(
+ run=fake_run,
+ config=OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'm',
+ },
+ }),
+ runtime=SimpleNamespace(should_stop=False),
+ )
+
+ await app._serve()
+ out = buf.getvalue()
+ assert runs['n'] == 2
+ assert 'session still open' in out
+
+
+@pytest.mark.asyncio
+async def test_serve_exits_when_quit_cancels_the_turn():
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ buf = io.StringIO()
+ app.console = Console(file=buf, force_terminal=False, width=100)
+ app.renderer = MagicMock()
+ app._queued_query = None
+ app._pending_switch = None
+ app._model = 'm'
+ app.state = TuiState(model='m')
+ app._owned_session_ids = set()
+ session = SimpleNamespace(id='s1', name='Session 1')
+ app._sm = MagicMock()
+ app._sm.create.return_value = session
+ app.session = None
+ app._banner = lambda: None
+ app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess)
+ app._name_session_from_log = lambda: None
+ app._prune_if_empty = lambda s: None
+
+ runs = {'n': 0}
+ runtime = SimpleNamespace(should_stop=True)
+
+ async def fake_run(query=None, stream=True):
+ runs['n'] += 1
+ raise asyncio.CancelledError()
+
+ app.agent = SimpleNamespace(
+ run=fake_run,
+ config=OmegaConf.create({
+ 'llm': {
+ 'service': 'openai',
+ 'model': 'm',
+ },
+ }),
+ runtime=runtime,
+ )
+
+ await app._serve()
+ out = buf.getvalue()
+ assert runs['n'] == 1
+ assert 'session still open' not in out
+ assert 'bye' in out
+
+
+def _serve_app(buf):
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ app.console = Console(file=buf, force_terminal=False, width=100)
+ app.renderer = MagicMock()
+ app._queued_query = None
+ app._pending_switch = None
+ app._model = 'm'
+ app.state = TuiState(model='m')
+ app._owned_session_ids = set()
+ session = SimpleNamespace(id='s1', name='Session 1')
+ app._sm = MagicMock()
+ app._sm.create.return_value = session
+ app.session = None
+ app._banner = lambda: None
+ app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess)
+ app._name_session_from_log = lambda: None
+ app._prune_if_empty = lambda s: None
+ return app
+
+
+@pytest.mark.asyncio
+async def test_ctrl_c_during_stream_stays_in_repl():
+ """Ctrl-C while a turn is streaming stays in the REPL."""
+ buf = io.StringIO()
+ app = _serve_app(buf)
+ started = asyncio.Event()
+ runs = {'n': 0}
+
+ async def fake_run(query=None, stream=True):
+ runs['n'] += 1
+ if runs['n'] == 1:
+ started.set()
+ await asyncio.Event().wait()
+ raise EOFError()
+
+ app.agent = SimpleNamespace(
+ run=fake_run,
+ runtime=SimpleNamespace(should_stop=False),
+ )
+ serve = asyncio.create_task(app._serve())
+ await asyncio.wait_for(started.wait(), timeout=2)
+ app._on_turn_sigint()
+ await asyncio.wait_for(serve, timeout=2)
+ out = buf.getvalue()
+ assert runs['n'] == 2
+ assert 'interrupted — Ctrl-C or /quit to exit' in out
+ assert 'bye' in out
+
+
+def test_idle_sigint_does_not_cancel_a_turn():
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ app._streaming = False
+ app._sigint_turn = False
+ app._sigint_exit = False
+ app._turn_task = MagicMock()
+ app._turn_task.done.return_value = False
+ app._on_turn_sigint()
+ app._turn_task.cancel.assert_not_called()
+ assert app._sigint_exit is False
+
+
+def test_second_sigint_while_cancelling_returns_to_the_prompt():
+ """A second Ctrl-C during the same turn does not quit.
+
+ The turn is already cancelling. It returns to the input line, and
+ Ctrl-C there quits the TUI.
+ """
+ from unittest.mock import MagicMock
+
+ app = TuiApp.__new__(TuiApp)
+ app._streaming = True
+ app._sigint_turn = True
+ app._sigint_exit = False
+ app._turn_task = MagicMock()
+ app._turn_task.done.return_value = False
+ app._serve_task = MagicMock()
+ app._serve_task.done.return_value = False
+ app._on_turn_sigint()
+ assert app._sigint_exit is False
+ app._serve_task.cancel.assert_not_called()
+ app._turn_task.cancel.assert_not_called()
diff --git a/tests/tui/test_tui_input.py b/tests/tui/test_tui_input.py
index 589378d68..318de9d8e 100644
--- a/tests/tui/test_tui_input.py
+++ b/tests/tui/test_tui_input.py
@@ -4,7 +4,8 @@
from unittest.mock import patch
from ms_agent.command import CommandRouter, register_builtin_commands
-from ms_agent.tui.input import PromptToolkitInput, slash_matches
+from ms_agent.tui.input import (PromptToolkitInput, slash_matches,
+ split_prompt_lines)
from ms_agent.tui.state import TuiState
from ms_agent.ui.input import InputSource
@@ -57,3 +58,76 @@ def test_read_prompt_falls_back_when_not_tty():
stdin.isatty.return_value = False
result = asyncio.run(inp.read_prompt('> '))
assert result == 'hello'
+
+
+def test_split_prompt_lines_keeps_each_pasted_line():
+ assert split_prompt_lines('/model qwen\nhello') == ['/model qwen', 'hello']
+ # A trailing Enter is not an extra blank command.
+ assert split_prompt_lines('/model qwen\nhello\n') == ['/model qwen', 'hello']
+ assert split_prompt_lines('/model a\r\nsecond\r\n') == ['/model a', 'second']
+ assert split_prompt_lines('just one') == ['just one']
+ assert split_prompt_lines('\x1b[200~/model qwen\nhello\x1b[201~') == [
+ '/model qwen', 'hello'
+ ]
+ assert split_prompt_lines('^[[200~hello^[[201~') == ['hello']
+
+
+def test_cursor_report_does_not_become_the_command():
+ # Cooked echo and the raw CSI both have to come off the command.
+ assert split_prompt_lines('^[[8;8R/mcp list') == ['/mcp list']
+ assert split_prompt_lines('\x1b[8;8R/mcp list') == ['/mcp list']
+ assert split_prompt_lines('^[[8;8R\n/mcp list') == ['/mcp list']
+ assert split_prompt_lines('\x1b[200~^[[8;8R/mcp list\x1b[201~') == [
+ '/mcp list'
+ ]
+ # A report by itself is not a message. A real blank line in a paste stays.
+ assert split_prompt_lines('^[[8;8R') == ['']
+ assert split_prompt_lines('keep\n\nstill') == ['keep', '', 'still']
+
+
+def test_multiline_paste_is_one_prompt_then_queued_lines():
+ """The second line of a paste must come back as its own read, so /model
+ cannot swallow it into the model id. Live is released before the prompt
+ draws, and a queued line does not draw another divider."""
+ from io import StringIO
+
+ from rich.console import Console
+
+ order = []
+
+ class _Renderer:
+ def release_terminal(self):
+ order.append('release')
+
+ class _Session:
+ calls = 0
+
+ async def prompt_async(self, *args, **kwargs):
+ order.append('prompt')
+ _Session.calls += 1
+ return '/model qwen\nhello\n'
+
+ with patch('ms_agent.tui.input.sys.stdin') as stdin:
+ stdin.isatty.return_value = False
+ console = Console(file=StringIO(), force_terminal=False, width=40)
+ prints = []
+ original = console.print
+
+ def _spy(*args, **kwargs):
+ prints.append(args)
+ return original(*args, **kwargs)
+
+ console.print = _spy
+ inp = PromptToolkitInput(TuiState(), console=console, renderer=_Renderer())
+ inp._session = _Session()
+
+ with patch('ms_agent.tui.input.sys.stdin') as stdin:
+ stdin.isatty.return_value = True
+ first = asyncio.run(inp.read_prompt())
+ second = asyncio.run(inp.read_prompt())
+
+ assert first == '/model qwen'
+ assert second == 'hello'
+ assert _Session.calls == 1
+ assert order == ['release', 'prompt', 'release']
+ assert len(prints) == 1 # divider only for the real prompt
diff --git a/tests/tui/test_tui_project.py b/tests/tui/test_tui_project.py
new file mode 100644
index 000000000..4797492ef
--- /dev/null
+++ b/tests/tui/test_tui_project.py
@@ -0,0 +1,210 @@
+"""TUI binds a work dir through ProjectManager.open_folder (same as WebUI)."""
+import os
+from pathlib import Path
+from types import SimpleNamespace
+
+from omegaconf import OmegaConf
+
+from ms_agent.personalization.settings import PersonalizationSettings
+from ms_agent.personalization.types import PersonalizationConfig
+from ms_agent.project import SessionManager
+from ms_agent.project.manager import ProjectManager
+from ms_agent.project.paths import project_key
+from ms_agent.tui.app import TuiApp
+from ms_agent.tui.state import TuiState
+
+
+def test_tui_open_project_registers_path_key(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+
+ project = TuiApp._open_project(str(work))
+ assert project.id == project_key(str(work))
+ assert project.path == str(work.resolve())
+ listed = ProjectManager(base_dir=str(home)).get(project.id)
+ assert listed is not None
+ assert listed.path == project.path
+
+
+def test_tui_open_project_reuses_webui_create_id(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'from-webui'
+ work.mkdir()
+ created = ProjectManager(base_dir=str(home)).create(
+ name='WebUI project', path=str(work), init_workspace=False)
+
+ project = TuiApp._open_project(str(work))
+ assert project.id == created.id
+ assert project.id != project_key(str(work))
+ assert Path(project.path).resolve() == work.resolve()
+
+
+def test_tui_and_webui_share_session_tree(tmp_path, monkeypatch):
+ """Same folder, one project id: WebUI sessions show up in TUI /sessions."""
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'shared'
+ work.mkdir()
+ created = ProjectManager(base_dir=str(home)).create(
+ name='WebUI', path=str(work), init_workspace=False)
+ web_sm = SessionManager(created)
+ web_sess = web_sm.create(name='from webui')
+ web_sm.get_session_log(web_sess).append(
+ {'role': 'user', 'content': 'hello from web'})
+
+ tui_proj = TuiApp._open_project(str(work))
+ tui_sm = SessionManager(tui_proj)
+ assert tui_proj.id == created.id
+ assert web_sess.id in {s.id for s in tui_sm.list()}
+ msgs = tui_sm.get_session_log(web_sess).get_all_messages()
+ assert any(m.get('content') == 'hello from web' for m in msgs)
+
+ tui_sess = tui_sm.create(name='from tui')
+ tui_sm.get_session_log(tui_sess).append(
+ {'role': 'user', 'content': 'hello from tui'})
+ listed_again = SessionManager(created).list()
+ assert tui_sess.id in {s.id for s in listed_again}
+
+
+def test_prepare_config_merges_personalization(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ PersonalizationSettings().save(
+ PersonalizationConfig(global_instruction='Be brief.'))
+ cfg = OmegaConf.create({})
+ project = SimpleNamespace(instruction='Use FastAPI.')
+ out = TuiApp._prepare_config(cfg, None, str(tmp_path / 'work'), project)
+ assert out.personalization.project_instruction == 'Use FastAPI.'
+ assert out.personalization.global_instruction == 'Be brief.'
+
+
+def test_new_conversation_uses_default_not_the_live_model(tmp_path, monkeypatch):
+ """A new chat starts on the global default, even if the agent is still
+ sitting on an older conversation's model."""
+ from ms_agent.config.model_settings import ModelSettingsManager
+
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ ModelSettingsManager(home).set_default_model('new-model', provider='openai')
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ app.agent = SimpleNamespace(
+ config=OmegaConf.create({
+ 'llm': {'service': 'openai', 'model': 'old-model'},
+ }),
+ )
+ app.state = TuiState(model='old-model', perm='auto', work_dir=str(work))
+ session = app._open_conversation()
+ assert session.model == 'new-model'
+ assert session.model_provider == 'openai'
+ assert ModelSettingsManager(home).get_default_model() == 'openai/new-model'
+
+
+def test_apply_session_restores_model_without_changing_default(
+ tmp_path, monkeypatch):
+ from ms_agent.config.model_settings import ModelSettingsManager
+
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ ModelSettingsManager(home).set_default_model('new-model', provider='openai')
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ session = app._sm.create(
+ name='older', model='old-model', model_provider='other')
+ app.agent = SimpleNamespace(
+ config=OmegaConf.create({
+ 'llm': {'service': 'openai', 'model': 'new-model'},
+ }),
+ load_cache=False,
+ )
+ app.state = TuiState(model='new-model', perm='auto', work_dir=str(work))
+ app._apply_session(session, resume=True)
+ assert app.agent.config.llm.model == 'old-model'
+ assert app.agent.config.llm.service == 'other'
+ assert app.state.model == 'old-model'
+ assert ModelSettingsManager(home).get_default_model() == 'openai/new-model'
+ stored = app._sm.get(session.id)
+ assert stored.model == 'old-model'
+ assert stored.model_provider == 'other'
+
+
+def test_resume_without_stored_model_follows_default(tmp_path, monkeypatch):
+ from ms_agent.config.model_settings import ModelSettingsManager
+
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ ModelSettingsManager(home).set_default_model('new-model', provider='openai')
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ session = app._sm.create(name='legacy')
+ app.agent = SimpleNamespace(
+ config=OmegaConf.create({
+ 'llm': {'service': 'openai', 'model': 'previous-chat'},
+ }),
+ load_cache=False,
+ )
+ app.state = TuiState(
+ model='previous-chat', perm='auto', work_dir=str(work))
+ app._apply_session(session, resume=True)
+ assert app.agent.config.llm.model == 'new-model'
+ assert app.state.model == 'new-model'
+ assert app._sm.get(session.id).model is None
+
+
+def test_apply_session_binds_plan_to_session_dir(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ session = app._sm.create(name='chat')
+ app.agent = SimpleNamespace(
+ config=OmegaConf.create({}),
+ load_cache=False,
+ )
+ app.state = TuiState(model='m', perm='auto', work_dir=str(work))
+ app._apply_session(session, resume=False)
+ sess_dir = str(app._sm.sessions_dir / session.id)
+ plan_json = os.path.join(sess_dir, 'plan.json')
+ plan_md = os.path.join(sess_dir, 'plan.md')
+ assert app.agent.config.tools.todo_list.plan_filename == plan_json
+ assert app.agent.config.tools.todo_list.plan_md_filename == plan_md
+ assert app.agent.config.tools.todo_list.mcp is False
+ assert app.agent.config.session_log.dir == sess_dir
+ from ms_agent.config.config import Config
+ servers = Config.convert_mcp_servers_to_json(
+ app.agent.config)['mcpServers']
+ assert 'todo_list' not in servers
+
+
+def test_prune_empty_only_drops_owned_sessions(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ work = tmp_path / 'repo'
+ work.mkdir()
+ app = TuiApp.__new__(TuiApp)
+ app._project = TuiApp._open_project(str(work))
+ app._sm = SessionManager(app._project)
+ app._owned_session_ids = set()
+ web = app._sm.create(name='from webui')
+ owned = app._sm.create(name='tui leftover')
+ app._owned_session_ids.add(owned.id)
+ app._prune_if_empty(web)
+ app._prune_if_empty(owned)
+ ids = {s.id for s in app._sm.list()}
+ assert web.id in ids
+ assert owned.id not in ids
diff --git a/tests/utils/test_filesystem_tool_config.py b/tests/utils/test_filesystem_tool_config.py
index 2f68b63b6..e3e5a3ba9 100644
--- a/tests/utils/test_filesystem_tool_config.py
+++ b/tests/utils/test_filesystem_tool_config.py
@@ -108,3 +108,23 @@ async def _run():
assert result == 'Error: `path` is required.'
asyncio.run(_run())
+
+
+def test_init_does_not_require_api_key(tmp_path, monkeypatch):
+ monkeypatch.delenv('MODELSCOPE_API_KEY', raising=False)
+ monkeypatch.delenv('DASHSCOPE_API_KEY', raising=False)
+ cfg = OmegaConf.create({
+ 'output_dir': str(tmp_path),
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ 'use_provider_router': True,
+ },
+ 'tools': {
+ 'file_system': {
+ 'mcp': False,
+ },
+ },
+ })
+ fs = FileSystemTool(cfg)
+ assert fs.llm is None
diff --git a/tests/utils/test_history_secrets.py b/tests/utils/test_history_secrets.py
new file mode 100644
index 000000000..0f30743f2
--- /dev/null
+++ b/tests/utils/test_history_secrets.py
@@ -0,0 +1,120 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""History yaml must not keep API keys or MCP Authorization."""
+from __future__ import annotations
+
+import json
+
+from omegaconf import OmegaConf
+
+from ms_agent.llm.utils import Message
+from ms_agent.utils.constants import DEFAULT_MEMORY_DIR
+from ms_agent.utils.utils import read_history, save_history
+
+
+def _messages():
+ return [
+ Message(role='system', content='sys'),
+ Message(role='user', content='hello'),
+ ]
+
+
+def test_save_history_strips_keys_and_leaves_memory_intact(tmp_path):
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ 'api_key': 'sk-live-secret-value-1234',
+ 'base_url': 'https://api.example/v1?api_key=sk-query-secret',
+ },
+ '_merged_mcp': {
+ 'servers': {
+ 'remote': {
+ 'url': 'https://mcp.example/rpc',
+ 'headers': {
+ 'Authorization': 'Bearer secret-token',
+ 'Content-Type': 'application/json',
+ },
+ },
+ },
+ },
+ })
+ save_history(str(tmp_path), 'Agent-default', config, _messages())
+ text = (tmp_path / DEFAULT_MEMORY_DIR / 'Agent-default.yaml').read_text()
+ assert 'sk-live-secret-value-1234' not in text
+ assert 'secret-token' not in text
+ assert 'sk-query-secret' not in text
+ assert '***' not in text
+ assert 'Content-Type' in text
+ assert 'Qwen/Qwen3.8-Flash-Next' in text
+ assert config.llm.api_key == 'sk-live-secret-value-1234'
+ loaded, messages = read_history(str(tmp_path), 'Agent-default')
+ assert loaded.llm.api_key in (None, '')
+ assert messages[-1].content == 'hello'
+
+
+def test_legacy_read_refills_keys_from_settings_and_mcp(tmp_path, monkeypatch):
+ home = tmp_path / 'home'
+ home.mkdir()
+ monkeypatch.setenv('MS_AGENT_HOME', str(home))
+ (home / 'settings.json').write_text(
+ json.dumps({
+ 'providers': {
+ 'modelscope': {
+ 'api_key': 'sk-from-settings',
+ 'base_url': 'https://api.example/v1',
+ },
+ },
+ }))
+ project = tmp_path / 'proj'
+ mcp_dir = project / '.ms_agent'
+ mcp_dir.mkdir(parents=True)
+ (mcp_dir / 'mcp.json').write_text(
+ json.dumps({
+ 'mcpServers': {
+ 'remote': {
+ 'url': 'https://mcp.example/rpc',
+ 'headers': {
+ 'Authorization': 'Bearer from-mcp',
+ 'Content-Type': 'application/json',
+ },
+ },
+ },
+ }))
+ config = OmegaConf.create({
+ 'llm': {
+ 'service': 'modelscope',
+ 'model': 'Qwen/Qwen3.8-Flash-Next',
+ 'api_key': 'sk-live-secret-value-1234',
+ },
+ '_merged_mcp': {
+ 'servers': {
+ 'remote': {
+ 'headers': {
+ 'Authorization': 'Bearer secret-token',
+ },
+ },
+ },
+ },
+ 'output_dir': str(project),
+ })
+ save_history(str(project), 'Agent-default', config, _messages())
+ disk = (project / DEFAULT_MEMORY_DIR / 'Agent-default.yaml').read_text()
+ assert 'sk-from-settings' not in disk
+ assert 'from-mcp' not in disk
+
+ from ms_agent.agent.llm_agent import LLMAgent
+ agent = LLMAgent.__new__(LLMAgent)
+ agent.output_dir = str(project)
+ agent.tag = 'Agent-default'
+ agent.load_cache = True
+ agent.config = OmegaConf.create({'llm': {'service': 'other'}})
+ agent.runtime = None
+ agent.llm = None
+ agent.mcp_runtime = None
+ loaded, _runtime, messages = agent.read_history(_messages())
+ assert loaded.llm.api_key == 'sk-from-settings'
+ assert loaded.llm.model == 'Qwen/Qwen3.8-Flash-Next'
+ headers = loaded._merged_mcp.servers.remote.headers
+ assert headers.Authorization == 'Bearer from-mcp'
+ assert messages[-1].content == 'hello'
+ assert 'from-mcp' not in disk
diff --git a/tests/utils/test_snapshot_smoke.py b/tests/utils/test_snapshot_smoke.py
index 7a02ec899..a466caf77 100644
--- a/tests/utils/test_snapshot_smoke.py
+++ b/tests/utils/test_snapshot_smoke.py
@@ -356,9 +356,32 @@ def test_on_task_begin_auto_snapshots(self):
Message(role='user', content='do something useful'),
]
- # No explicit take_snapshot call — on_task_begin should do it
asyncio.run(agent.on_task_begin(messages))
+ snaps = list_snapshots(td)
+ self.assertEqual(len(snaps), 1)
+ self.assertIn('do something useful', snaps[0]['message'])
+ self.assertEqual(snaps[0]['message_count'], len(messages))
+ def test_on_task_begin_snapshots_when_enabled(self):
+ """enable_snapshots: true still takes a snapshot on task begin."""
+ import asyncio
+ from omegaconf import OmegaConf
+ from ms_agent.agent.llm_agent import LLMAgent
+ from ms_agent.llm.utils import Message
+
+ with tempfile.TemporaryDirectory() as td:
+ _write(os.path.join(td, 'work.txt'), 'v1')
+ cfg = OmegaConf.create({
+ 'llm': {'model': 'fake', 'api_key': 'fake', 'model_server': 'openai'},
+ 'output_dir': td,
+ 'enable_snapshots': True,
+ })
+ agent = LLMAgent(cfg, tag='smoke-test')
+ messages = [
+ Message(role='system', content='sys'),
+ Message(role='user', content='do something useful'),
+ ]
+ asyncio.run(agent.on_task_begin(messages))
snaps = list_snapshots(td)
self.assertEqual(len(snaps), 1)
self.assertIn('do something useful', snaps[0]['message'])
diff --git a/webui/README.md b/webui/README.md
index 717993e5d..47cab31db 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -113,7 +113,7 @@ printed in the terminal. Press **Ctrl-C** to stop the service.
For ModelScope, obtain an API key from the [access token page](https://modelscope.cn/my/myaccesstoken).
1. Open **Settings → Models**, select **ModelScope → Edit**, enter your API key, and save. Built-in providers already have an endpoint. For another OpenAI-compatible service, use **Add provider** to enter its Base URL and key.
-2. Click **Add model** under that provider and enter a model ID supported by the service, such as `Qwen/Qwen3-235B-A22B-Instruct-2507`. Availability and quotas depend on your provider account.
+2. Click **Add model** under that provider and enter a model ID supported by the service, such as `Qwen/Qwen3.8-Flash-Next`. If that id returns 400, enter another chat model the provider currently serves. Availability and quotas depend on your provider account.
3. Return to chat, use the default project or create one, select the new model below the input, and send your first message. Attach files or images when relevant.
4. Configure a project workspace, skills, and MCP tools as needed.
diff --git a/webui/README_ZH.md b/webui/README_ZH.md
index 0dd292afa..064a1648f 100644
--- a/webui/README_ZH.md
+++ b/webui/README_ZH.md
@@ -97,7 +97,7 @@ ms-agent ui
使用 ModelScope 时,可在[访问令牌页面](https://modelscope.cn/my/myaccesstoken)获取 API Key。
1. 打开 **设置 → 模型设置**,选择 **ModelScope → 编辑**,填写 API Key 并保存。内置服务商已配置接口地址;其他 OpenAI 兼容服务可通过“添加提供方”填写 Base URL 和 Key。
-2. 在该服务商下点击 **添加模型**,填写服务实际支持的模型 ID,例如 `Qwen/Qwen3-235B-A22B-Instruct-2507`。模型是否可用及调用额度以服务商账号为准。
+2. 在该服务商下点击 **添加模型**,填写服务实际支持的模型 ID,例如 `Qwen/Qwen3.8-Flash-Next`。如果这个 id 返回 400,请改填该服务商当前可用的对话模型。模型是否可用及调用额度以服务商账号为准。
3. 返回对话页,使用默认项目或新建项目,在输入框下方选择刚添加的模型,发送第一条消息;需要时可附上文件或图片。
4. 按需为项目配置工作目录、技能和 MCP 工具。
diff --git a/webui/backend/app/backends/ms_agent/defaults.py b/webui/backend/app/backends/ms_agent/defaults.py
index 9aa9e9b85..4b3b9408a 100644
--- a/webui/backend/app/backends/ms_agent/defaults.py
+++ b/webui/backend/app/backends/ms_agent/defaults.py
@@ -1,5 +1,7 @@
"""Default tool scope for WebUI sessions."""
+from ms_agent.config.search_settings import DEFAULT_WEB_SEARCH_BLOCK
+
# WebUI persists missing defaults at startup and before reading settings or
# starting a turn. The same defaults remain below user settings at runtime.
DEFAULT_TOOLS: dict = {
@@ -27,7 +29,7 @@
# silently having no engine until someone visits Settings -> Search. The
# keyless quota is a small sliding hourly bucket; adding a key lifts it, and
# a configured key always takes precedence.
- "web_search": {"mcp": False, "engine": "tavily", "enabled": True},
+ "web_search": dict(DEFAULT_WEB_SEARCH_BLOCK),
}
# task_control has no WebUI rendering component yet; strip it from any home that
diff --git a/webui/backend/app/schemas/agent_settings.py b/webui/backend/app/schemas/agent_settings.py
index aca76c2f2..704681768 100644
--- a/webui/backend/app/schemas/agent_settings.py
+++ b/webui/backend/app/schemas/agent_settings.py
@@ -18,20 +18,21 @@ class AgentSettings(BaseModel):
default_provider_id: str | None = None
default_model_id: str | None = None
- # Inherited by newly-created projects.
- default_memory_enabled: bool = True
- default_memory_backend: MemoryBackend = "file"
+ # Inherited by newly-created projects. None on PUT = leave unchanged
+ # (a missing bool must not fall back to True and silently turn memory on).
+ default_memory_enabled: bool | None = None
+ default_memory_backend: MemoryBackend | None = None
# Vector-memory model configuration. All None = follow the conversation
# model/provider — explicit values pin fact extraction / embeddings to a
# model of the user's choosing, independent of what chat uses.
memory_llm_provider_id: str | None = None
memory_llm_model: str | None = None
- memory_embed_mode: MemoryEmbedMode = "provider"
+ memory_embed_mode: MemoryEmbedMode | None = None
memory_embed_provider_id: str | None = None
memory_embed_model: str | None = None
memory_recall_top_k: int | None = None
# Global auto-attach masters — projects can override per-scope.
- global_mcp_auto_attach: bool = True
- global_skill_auto_attach: bool = True
+ global_mcp_auto_attach: bool | None = None
+ global_skill_auto_attach: bool | None = None
diff --git a/webui/backend/uv.lock b/webui/backend/uv.lock
index b033efa16..259696f7c 100644
--- a/webui/backend/uv.lock
+++ b/webui/backend/uv.lock
@@ -1695,9 +1695,9 @@ requires-dist = [
{ name = "markdown", marker = "extra == 'webui'", specifier = ">=3.10" },
{ name = "matplotlib" },
{ name = "matplotlib", marker = "extra == 'all'" },
- { name = "mcp", specifier = "<2" },
- { name = "mcp", marker = "extra == 'all'", specifier = "<2" },
- { name = "mcp", marker = "extra == 'research'", specifier = "<2" },
+ { name = "mcp" },
+ { name = "mcp", marker = "extra == 'all'" },
+ { name = "mcp", marker = "extra == 'research'" },
{ name = "mem0ai", marker = "extra == 'all'" },
{ name = "mem0ai", marker = "extra == 'all'", specifier = ">=2.0.12" },
{ name = "mem0ai", marker = "extra == 'code'" },