fix(dashboard): persist Discord toolsets to Discord platform

This commit is contained in:
Shannon Sands 2026-07-16 13:46:51 +10:00 committed by Teknium
parent c80b244b52
commit 3ffd8b3da0
6 changed files with 105 additions and 19 deletions

View file

@ -165,6 +165,20 @@ def _toolset_allowed_for_platform(ts_key: str, platform: str) -> bool:
return allowed is None or platform in allowed
def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str:
"""Return the platform a platform-less configuration UI should target.
Most configurable toolsets retain the historical desktop/CLI target. A
toolset restricted away from that platform must instead be configured on
one of its supported platforms; otherwise the shared save helper correctly
drops it and the UI reports a successful no-op.
"""
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key)
if not allowed or default in allowed:
return default
return sorted(allowed)[0]
def _get_effective_configurable_toolsets():
"""Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets.

View file

@ -13700,29 +13700,43 @@ async def get_toolsets(profile: Optional[str] = None):
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
_get_platform_tools,
_toolset_configuration_platform,
_toolset_has_keys,
gui_toolset_label,
)
from hermes_cli.platforms import platform_label
from toolsets import resolve_toolset
with _profile_scope(profile):
config = load_config()
enabled_toolsets = _get_platform_tools(
config,
"cli",
include_default_mcp_servers=False,
)
toolset_rows = _get_effective_configurable_toolsets()
target_platforms = {
_toolset_configuration_platform(name) for name, _, _ in toolset_rows
}
enabled_by_platform = {
platform: _get_platform_tools(
config,
platform,
include_default_mcp_servers=False,
)
for platform in target_platforms
}
result = []
for name, label, desc in _get_effective_configurable_toolsets():
for name, label, desc in toolset_rows:
try:
tools = sorted(set(resolve_toolset(name)))
except Exception:
tools = []
is_enabled = name in enabled_toolsets
target_platform = _toolset_configuration_platform(name)
is_enabled = name in enabled_by_platform[target_platform]
result.append({
"name": name,
"label": gui_toolset_label(label),
"description": desc,
"platform": target_platform,
"platform_label": gui_toolset_label(
platform_label(target_platform, target_platform)
),
"enabled": is_enabled,
"available": is_enabled,
"configured": _toolset_has_keys(name, config),
@ -13738,34 +13752,46 @@ class ToolsetToggle(BaseModel):
@app.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for the desktop (cli) platform.
"""Enable/disable a configurable toolset for its configuration platform.
Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools``
helper the CLI ``hermes tools`` picker uses, so the GUI and CLI stay in
lockstep. Scoped to ``body.profile`` when provided. Returns 400 for
unknown toolset keys.
Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted
toolsets instead target their supported platform (for example, Discord's
native toolsets persist to ``platform_toolsets.discord``). The shared
``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped
to ``body.profile`` when provided. Returns 400 for unknown toolset keys.
"""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
_get_platform_tools,
_save_platform_tools,
_toolset_configuration_platform,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
target_platform = _toolset_configuration_platform(name)
with _profile_scope(body.profile or profile):
config = load_config()
enabled = set(
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
_get_platform_tools(
config,
target_platform,
include_default_mcp_servers=False,
)
)
if body.enabled:
enabled.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, "cli", enabled)
return {"ok": True, "name": name, "enabled": body.enabled}
_save_platform_tools(config, target_platform, enabled)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
}
@app.get("/api/tools/toolsets/{name}/config")

View file

@ -4792,6 +4792,8 @@ class TestNewEndpoints:
"name": "web",
"label": "Web Search & Scraping",
"description": "web_search, web_extract",
"platform": "cli",
"platform_label": "CLI",
"enabled": True,
"available": True,
"configured": False,
@ -4801,6 +4803,8 @@ class TestNewEndpoints:
"name": "skills",
"label": "Skills",
"description": "list, view, manage",
"platform": "cli",
"platform_label": "CLI",
"enabled": True,
"available": True,
"configured": True,
@ -4810,6 +4814,8 @@ class TestNewEndpoints:
"name": "memory",
"label": "Memory",
"description": "persistent memory across sessions",
"platform": "cli",
"platform_label": "CLI",
"enabled": False,
"available": False,
"configured": True,
@ -4838,6 +4844,41 @@ class TestNewEndpoints:
listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()}
assert listing["x_search"]["enabled"] is False
def test_discord_toolsets_read_and_write_discord_platform(self):
"""Platform-restricted toolsets must not be saved as successful CLI no-ops."""
from hermes_cli.config import load_config
listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()}
assert listing["discord"]["platform"] == "discord"
assert listing["discord"]["platform_label"] == "Discord"
assert listing["discord"]["enabled"] is False
resp = self.client.put("/api/tools/toolsets/discord", json={"enabled": True})
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"name": "discord",
"platform": "discord",
"enabled": True,
}
config = load_config()
assert "discord" in config["platform_toolsets"]["discord"]
assert "discord" not in config["platform_toolsets"].get("cli", [])
listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()}
assert listing["discord"]["enabled"] is True
assert listing["discord_admin"]["enabled"] is False
resp = self.client.put(
"/api/tools/toolsets/discord_admin", json={"enabled": True}
)
assert resp.status_code == 200
config = load_config()
assert {"discord", "discord_admin"} <= set(
config["platform_toolsets"]["discord"]
)
def test_toggle_toolset_unknown_returns_400(self):
resp = self.client.put(
"/api/tools/toolsets/not_a_real_toolset", json={"enabled": True}

View file

@ -211,6 +211,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
};
const labelText = toolset.label?.trim() || toolset.name;
const platformText = toolset.platform_label?.trim() || toolset.platform;
return createPortal(
<div
@ -253,10 +254,12 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
checked={enabled}
onCheckedChange={(v) => void handleToggle(v)}
disabled={toggling}
aria-label="Enable toolset"
aria-label={`Enable toolset for ${platformText}`}
/>
<span className="text-xs text-muted-foreground">
{enabled ? "Enabled for the agent" : "Disabled"}
{enabled
? `Enabled for ${platformText}`
: `Disabled for ${platformText}`}
</span>
</div>
</header>

View file

@ -732,7 +732,7 @@ export const api = {
getToolsets: (profile?: string) =>
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
fetchJSON<{ ok: boolean; name: string; platform: string; enabled: boolean }>(
`/api/tools/toolsets/${encodeURIComponent(name)}`,
{
method: "PUT",
@ -2205,6 +2205,8 @@ export interface ToolsetInfo {
name: string;
label: string;
description: string;
platform: string;
platform_label: string;
enabled: boolean;
configured: boolean;
tools: string[];

View file

@ -201,7 +201,7 @@ export default function SkillsPage() {
/* ---- Refresh toolsets after a config change ---- */
const refreshToolsets = async () => {
try {
const tsets = await api.getToolsets();
const tsets = await api.getToolsets(selectedProfile || undefined);
setToolsets(tsets);
} catch {
/* non-fatal: the drawer already toasted on the failing write */