mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
refactor(plugins): minimize integration surface
This commit is contained in:
+2
-12
@@ -2309,18 +2309,8 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
|||||||
### Agent Plugins v1
|
### Agent Plugins v1
|
||||||
|
|
||||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
|
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
|
||||||
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may provide skills, MCP
|
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
|
||||||
servers, or both:
|
`skills/<name>/SKILL.md`, or both.
|
||||||
|
|
||||||
```text
|
|
||||||
plugins/
|
|
||||||
└── release-tools/
|
|
||||||
├── plugin.json
|
|
||||||
├── mcp.json
|
|
||||||
└── skills/
|
|
||||||
└── release-notes/
|
|
||||||
└── SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Directory presence means installed; activation is an explicit trust decision in **Apps**.
|
Directory presence means installed; activation is an explicit trust decision in **Apps**.
|
||||||
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
|
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
|
||||||
|
|||||||
+31
-55
@@ -29,15 +29,6 @@ _SETUP_TIMEOUT_SECONDS = 600
|
|||||||
_MAX_LOGO_BYTES = 256 * 1024
|
_MAX_LOGO_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentPluginSkill:
|
|
||||||
"""One skill supplied by a valid Agent Plugins v1 package."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
path: Path
|
|
||||||
plugin: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AgentPlugin:
|
class AgentPlugin:
|
||||||
"""A validated, locally installed Agent Plugins v1 package."""
|
"""A validated, locally installed Agent Plugins v1 package."""
|
||||||
@@ -71,14 +62,8 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
|||||||
root = _contained_directory(workspace / "plugins", workspace)
|
root = _contained_directory(workspace / "plugins", workspace)
|
||||||
if root is None:
|
if root is None:
|
||||||
return []
|
return []
|
||||||
try:
|
|
||||||
candidates = sorted(root.iterdir(), key=lambda path: path.name)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
plugins: list[AgentPlugin] = []
|
plugins: list[AgentPlugin] = []
|
||||||
for candidate in candidates:
|
for candidate in _children(root, "Agent Plugins directory"):
|
||||||
plugin_root = _contained_directory(candidate, root)
|
plugin_root = _contained_directory(candidate, root)
|
||||||
if plugin_root is None:
|
if plugin_root is None:
|
||||||
continue
|
continue
|
||||||
@@ -88,13 +73,14 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
|||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
def enabled_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
|
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||||
"""Return skills from plugins the user has explicitly enabled."""
|
"""Return skills from plugins the user has explicitly enabled."""
|
||||||
skills: list[AgentPluginSkill] = []
|
return [
|
||||||
for plugin in _discover_agent_plugins(workspace):
|
skill
|
||||||
if _enabled(workspace, plugin.name):
|
for plugin in _discover_agent_plugins(workspace)
|
||||||
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
|
if _enabled(workspace, plugin.name)
|
||||||
return skills
|
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||||
@@ -172,13 +158,12 @@ def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> Agent
|
|||||||
if plugin is None:
|
if plugin is None:
|
||||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
|
version = plugin.version or "unknown"
|
||||||
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
|
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
|
||||||
if enabled:
|
if enabled:
|
||||||
if plugin.install_command and _setup_version(workspace, plugin.name) != (
|
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
|
||||||
plugin.version or "unknown"
|
|
||||||
):
|
|
||||||
_run_install(plugin, data)
|
_run_install(plugin, data)
|
||||||
_write_state(data / "setup-version", plugin.version or "unknown")
|
_write_state(data / "setup-version", version)
|
||||||
_write_state(data / "enabled", "1")
|
_write_state(data / "enabled", "1")
|
||||||
else:
|
else:
|
||||||
(data / "enabled").unlink(missing_ok=True)
|
(data / "enabled").unlink(missing_ok=True)
|
||||||
@@ -209,12 +194,11 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
|||||||
try:
|
try:
|
||||||
data = logo.read_bytes() if logo is not None else b""
|
data = logo.read_bytes() if logo is not None else b""
|
||||||
suffix = logo.suffix.lower() if logo is not None else ""
|
suffix = logo.suffix.lower() if logo is not None else ""
|
||||||
valid = (
|
if len(data) <= _MAX_LOGO_BYTES and (
|
||||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||||
)
|
):
|
||||||
if valid and len(data) <= _MAX_LOGO_BYTES:
|
|
||||||
return logo
|
return logo
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -334,7 +318,7 @@ def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
|||||||
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||||
config_root = get_config_path().expanduser().resolve().parent
|
config_root = get_config_path().expanduser().resolve().parent
|
||||||
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
|
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
|
||||||
state_root = _private_directory(plugin_root / workspace_id, config_root, create=create)
|
state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
|
||||||
data = state_root / name
|
data = state_root / name
|
||||||
return _private_directory(data, state_root, create=True) if create else data
|
return _private_directory(data, state_root, create=True) if create else data
|
||||||
|
|
||||||
@@ -394,44 +378,36 @@ def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
|||||||
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||||
|
|
||||||
|
|
||||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
|
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||||
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
|
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
|
||||||
if skills_root is None:
|
if skills_root is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
skills: list[tuple[str, Path]] = []
|
||||||
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
|
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
skills: list[AgentPluginSkill] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
skill_root = _contained_directory(candidate, skills_root)
|
skill_root = _contained_directory(candidate, skills_root)
|
||||||
if skill_root is None:
|
if skill_root is None:
|
||||||
continue
|
continue
|
||||||
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
||||||
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
|
if skill_file is None:
|
||||||
continue
|
continue
|
||||||
skills.append(
|
try:
|
||||||
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
|
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||||
)
|
except (OSError, UnicodeError):
|
||||||
|
metadata = None
|
||||||
|
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||||
|
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||||
|
continue
|
||||||
|
skills.append((candidate.name, skill_file))
|
||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
|
||||||
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
|
def _children(root: Path, label: str) -> list[Path]:
|
||||||
try:
|
try:
|
||||||
content = path.read_text(encoding="utf-8")
|
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||||
except (OSError, UnicodeError):
|
except OSError as exc:
|
||||||
return False
|
logger.warning("Could not inspect {}: {}", label, exc)
|
||||||
metadata = parse_skill_metadata(content)
|
return []
|
||||||
if metadata is None:
|
|
||||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
|
||||||
return False
|
|
||||||
if not valid_skill_metadata(metadata, directory_name):
|
|
||||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _contained_directory(path: Path, root: Path) -> Path | None:
|
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||||
|
|||||||
@@ -104,18 +104,17 @@ class SkillsLoader:
|
|||||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||||
seen_names = {entry["name"] for entry in skills}
|
seen_names = {entry["name"] for entry in skills}
|
||||||
for plugin_skill in plugin_skills:
|
for name, path in plugin_skills:
|
||||||
if plugin_skill.name in seen_names:
|
if name in seen_names:
|
||||||
continue
|
continue
|
||||||
skills.append(
|
skills.append(
|
||||||
{
|
{
|
||||||
"name": plugin_skill.name,
|
"name": name,
|
||||||
"path": str(plugin_skill.path),
|
"path": str(path),
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"plugin": plugin_skill.plugin,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
seen_names.add(plugin_skill.name)
|
seen_names.add(name)
|
||||||
if self.builtin_skills and self.builtin_skills.exists():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
skills.extend(
|
||||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||||
|
|||||||
+12
-16
@@ -212,26 +212,22 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
|
|
||||||
return f"cli-app-{clean or 'app'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_skill_name(name: str) -> str:
|
|
||||||
"""Return the workspace skill name emitted before Agent Plugins support."""
|
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not legacy:
|
||||||
|
clean = clean.replace("_", "-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
def _plugin_skill_relative_path(name: str) -> str:
|
def _plugin_skill_relative_path(name: str) -> str:
|
||||||
skill_name = _safe_skill_name(name)
|
skill_name = _skill_name(name)
|
||||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||||
"""Return a CLI App's skill path, including the legacy location."""
|
"""Return a CLI App's skill path, including the legacy location."""
|
||||||
canonical = _plugin_skill_relative_path(name)
|
canonical = _plugin_skill_relative_path(name)
|
||||||
legacy = f"skills/{_legacy_skill_name(name)}/SKILL.md"
|
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||||
return legacy
|
return legacy
|
||||||
return canonical
|
return canonical
|
||||||
@@ -733,7 +729,7 @@ class CliAppManager:
|
|||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
skill_path = _plugin_skill_relative_path(name)
|
skill_path = _plugin_skill_relative_path(name)
|
||||||
plugin_path = f"plugins/{_safe_skill_name(name)}"
|
plugin_path = f"plugins/{_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -1054,7 +1050,7 @@ class CliAppManager:
|
|||||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||||
return f"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_skill_name(name)}
|
||||||
description: {json.dumps(description, ensure_ascii=False)}
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1096,18 +1092,18 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
path = self.workspace / _plugin_skill_relative_path(name)
|
path = self.workspace / _plugin_skill_relative_path(name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||||
content = normalize_skill_document(content, _safe_skill_name(name)) or self._fallback_skill(app)
|
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
|
||||||
content = self._with_nanobot_skill_note(content, app)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
plugin_root = path.parents[2]
|
plugin_root = path.parents[2]
|
||||||
manifest = compact_dict({
|
manifest = compact_dict({
|
||||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
"name": _safe_skill_name(str(app["name"])),
|
"name": _skill_name(str(app["name"])),
|
||||||
"version": str(app.get("version") or ""),
|
"version": str(app.get("version") or ""),
|
||||||
"description": _catalog_description(app),
|
"description": _catalog_description(app),
|
||||||
})
|
})
|
||||||
_write_json(plugin_root / "plugin.json", manifest)
|
_write_json(plugin_root / "plugin.json", manifest)
|
||||||
legacy_dir = self.workspace / "skills" / _legacy_skill_name(str(app["name"]))
|
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||||
if legacy_dir.is_dir():
|
if legacy_dir.is_dir():
|
||||||
shutil.rmtree(legacy_dir)
|
shutil.rmtree(legacy_dir)
|
||||||
return path
|
return path
|
||||||
@@ -1116,7 +1112,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||||
if plugin_root.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(plugin_root)
|
shutil.rmtree(plugin_root)
|
||||||
legacy_dir = self.workspace / "skills" / _legacy_skill_name(name)
|
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||||
if legacy_dir.is_dir():
|
if legacy_dir.is_dir():
|
||||||
shutil.rmtree(legacy_dir)
|
shutil.rmtree(legacy_dir)
|
||||||
|
|
||||||
@@ -1128,7 +1124,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
installed[str(app["name"])] = entry
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
set_agent_plugin_enabled(self.workspace, _safe_skill_name(str(app["name"])), True)
|
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -878,8 +878,6 @@ def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
|||||||
"brand_color": plugin.accent_color,
|
"brand_color": plugin.accent_color,
|
||||||
"required_fields": [],
|
"required_fields": [],
|
||||||
"connection_summary": ", ".join(state.mcp_servers),
|
"connection_summary": ", ".join(state.mcp_servers),
|
||||||
"enabled_tools": ["*"],
|
|
||||||
"tool_names": [],
|
|
||||||
"source": "agent-plugin",
|
"source": "agent-plugin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,16 +900,12 @@ def mcp_presets_payload(
|
|||||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||||
if name not in known
|
if name not in known
|
||||||
]
|
]
|
||||||
plugin_states = [
|
|
||||||
state
|
|
||||||
for state in discover_agent_plugin_states(config.workspace_path)
|
|
||||||
if state.mcp_servers or state.plugin.install_command
|
|
||||||
]
|
|
||||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||||
plugin_rows = [
|
plugin_rows = [
|
||||||
row
|
_agent_plugin_payload(state)
|
||||||
for state in plugin_states
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
if (row := _agent_plugin_payload(state))["name"] not in existing_names
|
if (state.mcp_servers or state.plugin.install_command)
|
||||||
|
and f"plugin-{state.plugin.name}" not in existing_names
|
||||||
]
|
]
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||||
@@ -1449,16 +1443,13 @@ async def mcp_presets_settings_action(
|
|||||||
if name.startswith("plugin-"):
|
if name.startswith("plugin-"):
|
||||||
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||||
plugin_name = name.removeprefix("plugin-")
|
plugin_name = name.removeprefix("plugin-")
|
||||||
plugin_state = next(
|
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
|
||||||
(
|
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
|
||||||
state
|
if (
|
||||||
for state in discover_agent_plugin_states(plugin_config.workspace_path)
|
name not in plugin_config.tools.mcp_servers
|
||||||
if state.plugin.name == plugin_name
|
and plugin_state is not None
|
||||||
and (state.mcp_servers or state.plugin.install_command)
|
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
|
||||||
),
|
):
|
||||||
None,
|
|
||||||
)
|
|
||||||
if name not in plugin_config.tools.mcp_servers and plugin_state is not None:
|
|
||||||
if action not in {"enable", "disable"}:
|
if action not in {"enable", "disable"}:
|
||||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
|
|||||||
|
|
||||||
|
|
||||||
def _loaded_plugin_skills(workspace: Path) -> list[str]:
|
def _loaded_plugin_skills(workspace: Path) -> list[str]:
|
||||||
return [skill.name for skill in enabled_agent_plugin_skills(workspace)]
|
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||||
|
|
||||||
|
|
||||||
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||||
@@ -95,7 +95,6 @@ def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
|||||||
"name": "release-notes",
|
"name": "release-notes",
|
||||||
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"plugin": "acme-tools",
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
||||||
|
|||||||
@@ -158,24 +158,15 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
|||||||
with pytest.raises(McpPresetError, match="enable and disable"):
|
with pytest.raises(McpPresetError, match="enable and disable"):
|
||||||
asyncio.run(plugin_action("remove"))
|
asyncio.run(plugin_action("remove"))
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
_use_config(tmp_path, monkeypatch)
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
config["tools"] = {
|
config["tools"] = {
|
||||||
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
||||||
}
|
}
|
||||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
_write_agent_plugin(load_config().workspace_path)
|
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
||||||
]
|
]
|
||||||
|
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["source"] == "custom"
|
assert rows[0]["source"] == "custom"
|
||||||
|
|
||||||
|
|||||||
@@ -7701,11 +7701,11 @@ function McpAppsCatalogRow({
|
|||||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||||
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
|
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
|
||||||
const agentPlugin = preset.source === "agent-plugin";
|
const agentPlugin = preset.source === "agent-plugin";
|
||||||
|
const toggleable = preset.enabled !== undefined;
|
||||||
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
||||||
const hasFields = preset.required_fields.length > 0;
|
const hasFields = preset.required_fields.length > 0;
|
||||||
const needsSetupInput = missingFields.length > 0;
|
const needsSetupInput = missingFields.length > 0;
|
||||||
const pluginEnabled = agentPlugin && preset.enabled === true;
|
const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
|
||||||
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
|
|
||||||
const canEnable =
|
const canEnable =
|
||||||
preset.install_supported &&
|
preset.install_supported &&
|
||||||
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
||||||
@@ -7717,8 +7717,8 @@ function McpAppsCatalogRow({
|
|||||||
const detail = agentPlugin && preset.requires
|
const detail = agentPlugin && preset.requires
|
||||||
? `${description} · ${preset.requires}`
|
? `${description} · ${preset.requires}`
|
||||||
: description || preset.requires;
|
: description || preset.requires;
|
||||||
const statusLabel = agentPlugin
|
const statusLabel = toggleable
|
||||||
? tx("settings.apps.pluginEnabled", "Plugin enabled")
|
? tx("settings.nanobotFeatures.enabled", "Enabled")
|
||||||
: mcpPresetStatusLabel(preset.status, tx);
|
: mcpPresetStatusLabel(preset.status, tx);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -7754,7 +7754,7 @@ function McpAppsCatalogRow({
|
|||||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
||||||
<AppsTypeBadge>
|
<AppsTypeBadge>
|
||||||
{agentPlugin
|
{agentPlugin
|
||||||
? tx("settings.apps.pluginLabel", "Plugin")
|
? tx("settings.apps.filterPlugins", "Plugins")
|
||||||
: tx("settings.apps.mcpLabel", "Integration")}
|
: tx("settings.apps.mcpLabel", "Integration")}
|
||||||
</AppsTypeBadge>
|
</AppsTypeBadge>
|
||||||
</div>
|
</div>
|
||||||
@@ -7775,31 +7775,31 @@ function McpAppsCatalogRow({
|
|||||||
</AppsActionButton>
|
</AppsActionButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{!agentPlugin ? (
|
{!toggleable ? (
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||||
<PlayCircle aria-hidden />
|
<PlayCircle aria-hidden />
|
||||||
{tx("settings.mcp.test", "Test")}
|
{tx("settings.mcp.test", "Test")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{!agentPlugin && toolNames.length ? (
|
{!toggleable && toolNames.length ? (
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||||
<SlidersHorizontal aria-hidden />
|
<SlidersHorizontal aria-hidden />
|
||||||
{tx("settings.mcp.toolScope", "Tools")}
|
{tx("settings.mcp.toolScope", "Tools")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
tone={agentPlugin ? undefined : "destructive"}
|
tone={toggleable ? undefined : "destructive"}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)}
|
onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
|
||||||
>
|
>
|
||||||
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
{toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||||
{agentPlugin
|
{toggleable
|
||||||
? tx("settings.apps.pluginDisable", "Disable")
|
? tx("settings.nanobotFeatures.disable", "Disable")
|
||||||
: tx("settings.mcp.remove", "Remove")}
|
: tx("settings.mcp.remove", "Remove")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{!agentPlugin ? (
|
{!toggleable ? (
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||||
busy={removeBusy}
|
busy={removeBusy}
|
||||||
@@ -7811,9 +7811,9 @@ function McpAppsCatalogRow({
|
|||||||
</AppsActionButton>
|
</AppsActionButton>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : agentPlugin && preset.installed ? (
|
) : preset.enabled === false ? (
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
ariaLabel={tx("settings.apps.pluginEnable", "Enable plugin")}
|
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
|
||||||
busy={enableBusy}
|
busy={enableBusy}
|
||||||
onClick={() => onAction("enable", preset.name, values)}
|
onClick={() => onAction("enable", preset.name, values)}
|
||||||
>
|
>
|
||||||
@@ -8023,8 +8023,7 @@ function appsTitle(item: AppsCatalogItem): string {
|
|||||||
|
|
||||||
function appsReady(item: AppsCatalogItem): boolean {
|
function appsReady(item: AppsCatalogItem): boolean {
|
||||||
if (item.kind === "cli") return item.app.installed;
|
if (item.kind === "cli") return item.app.installed;
|
||||||
if (item.preset.source === "agent-plugin") return item.preset.enabled === true;
|
return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
|
||||||
return item.preset.installed && item.preset.configured;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appsSearchText(item: AppsCatalogItem): string {
|
function appsSearchText(item: AppsCatalogItem): string {
|
||||||
|
|||||||
@@ -585,10 +585,6 @@
|
|||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"description": "Add tools to nanobot, then @ them in chat.",
|
||||||
"cliLabel": "App",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integration",
|
"mcpLabel": "Integration",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin enabled",
|
|
||||||
"pluginEnable": "Enable plugin",
|
|
||||||
"pluginDisable": "Disable",
|
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
"filterAll": "Ready",
|
"filterAll": "Ready",
|
||||||
|
|||||||
@@ -572,10 +572,6 @@
|
|||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "Integración",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin activado",
|
|
||||||
"pluginEnable": "Activar plugin",
|
|
||||||
"pluginDisable": "Desactivar",
|
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "Intégration",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin activé",
|
|
||||||
"pluginEnable": "Activer le plugin",
|
|
||||||
"pluginDisable": "Désactiver",
|
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||||
"cliLabel": "Aplikasi",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "Integrasi",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin aktif",
|
|
||||||
"pluginEnable": "Aktifkan plugin",
|
|
||||||
"pluginDisable": "Nonaktifkan",
|
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
"filterAll": "Siap",
|
"filterAll": "Siap",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "連携",
|
"mcpLabel": "連携",
|
||||||
"pluginLabel": "プラグイン",
|
|
||||||
"pluginEnabled": "プラグインは有効です",
|
|
||||||
"pluginEnable": "プラグインを有効にする",
|
|
||||||
"pluginDisable": "無効にする",
|
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
"filterAll": "使用可能",
|
"filterAll": "使用可能",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "연동",
|
"mcpLabel": "연동",
|
||||||
"pluginLabel": "플러그인",
|
|
||||||
"pluginEnabled": "플러그인 활성화됨",
|
|
||||||
"pluginEnable": "플러그인 활성화",
|
|
||||||
"pluginDisable": "비활성화",
|
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
"filterAll": "사용 가능",
|
"filterAll": "사용 가능",
|
||||||
|
|||||||
@@ -585,10 +585,6 @@
|
|||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "Integração",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin ativado",
|
|
||||||
"pluginEnable": "Ativar plugin",
|
|
||||||
"pluginDisable": "Desativar",
|
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||||
"cliLabel": "Ứng dụng",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "Tích hợp",
|
||||||
"pluginLabel": "Plugin",
|
|
||||||
"pluginEnabled": "Plugin đã bật",
|
|
||||||
"pluginEnable": "Bật plugin",
|
|
||||||
"pluginDisable": "Tắt",
|
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
"filterAll": "Sẵn sàng",
|
"filterAll": "Sẵn sàng",
|
||||||
|
|||||||
@@ -585,10 +585,6 @@
|
|||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "集成",
|
||||||
"pluginLabel": "插件",
|
|
||||||
"pluginEnabled": "插件已启用",
|
|
||||||
"pluginEnable": "启用插件",
|
|
||||||
"pluginDisable": "停用",
|
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
"filterAll": "可用",
|
"filterAll": "可用",
|
||||||
|
|||||||
@@ -571,10 +571,6 @@
|
|||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "整合",
|
"mcpLabel": "整合",
|
||||||
"pluginLabel": "外掛",
|
|
||||||
"pluginEnabled": "外掛已啟用",
|
|
||||||
"pluginEnable": "啟用外掛",
|
|
||||||
"pluginDisable": "停用",
|
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
"filterAll": "就緒",
|
"filterAll": "就緒",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
return payload.presets.filter(
|
||||||
|
(preset) => preset.enabled ?? (preset.installed && preset.configured),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { SettingsView } from "@/components/settings/SettingsView";
|
import { SettingsView } from "@/components/settings/SettingsView";
|
||||||
|
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
ChannelSetupContract,
|
ChannelSetupContract,
|
||||||
@@ -674,6 +675,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
connection_summary: "computer-use",
|
connection_summary: "computer-use",
|
||||||
source: "agent-plugin",
|
source: "agent-plugin",
|
||||||
};
|
};
|
||||||
|
expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]);
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
@@ -701,11 +703,11 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
renderSettingsView();
|
renderSettingsView();
|
||||||
|
|
||||||
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Plugin")).toBeInTheDocument();
|
expect(screen.getByText("Plugins")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" }));
|
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
@@ -715,7 +717,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
||||||
const enabledButton = screen.getByRole("button", { name: "Plugin enabled" });
|
const enabledButton = screen.getByRole("button", { name: "Enabled" });
|
||||||
await waitFor(() => expect(enabledButton).toBeEnabled());
|
await waitFor(() => expect(enabledButton).toBeEnabled());
|
||||||
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
|
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
|
||||||
@@ -728,7 +730,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument();
|
expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Enable plugin" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user