From 226ca5c0eeee1e7be14f8b17a98b65d25133db70 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Fri, 27 Feb 2026 13:38:53 -0800 Subject: [PATCH] reliability and skills junk --- ARCHITECTURE.md | 2 +- docs/permissions-sandboxing.md | 10 +- docs/ui-guide.md | 8 +- skills-overview.md | 277 +++++++++++ src/app/page.tsx | 54 ++- .../agents/components/AgentInspectPanels.tsx | 435 ++---------------- .../agents/components/AgentSkillsPanel.tsx | 255 ++++++++++ .../components/AgentSkillsSetupModal.tsx | 235 ++++++++++ .../agents/components/SystemSkillsPanel.tsx | 319 +++++++++++++ .../agentSettingsMutationWorkflow.ts | 5 + .../createAgentBootstrapOperation.ts | 4 + .../operations/settingsRouteWorkflow.ts | 1 + .../useAgentSettingsMutationController.ts | 80 +++- src/lib/skills/presentation.ts | 74 ++- tests/unit/agentPermissionsOperation.test.ts | 9 + .../agentSettingsMutationWorkflow.test.ts | 41 ++ tests/unit/agentSettingsPanel.test.ts | 187 +++++--- .../createAgentBootstrapOperation.test.ts | 13 +- tests/unit/settingsRouteWorkflow.test.ts | 18 + tests/unit/skillsGatewayClient.test.ts | 21 + tests/unit/skillsPresentation.test.ts | 104 ++++- ...useAgentSettingsMutationController.test.ts | 136 +++++- tests/unit/useSettingsRouteController.test.ts | 20 + 23 files changed, 1820 insertions(+), 488 deletions(-) create mode 100644 skills-overview.md create mode 100644 src/features/agents/components/AgentSkillsPanel.tsx create mode 100644 src/features/agents/components/AgentSkillsSetupModal.tsx create mode 100644 src/features/agents/components/SystemSkillsPanel.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1af6b85..ddc9a65 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ Non-goals: This keeps feature cohesion high while preserving a clear client/server boundary. ## Main modules / bounded contexts -- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. +- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). The Skills section in `AgentSettingsPanel` is split into `Access` (per-agent allowlist mode + toggles) and `Library` (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. - **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings. - **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`. - **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway. diff --git a/docs/permissions-sandboxing.md b/docs/permissions-sandboxing.md index 3349307..e416d77 100644 --- a/docs/permissions-sandboxing.md +++ b/docs/permissions-sandboxing.md @@ -46,10 +46,10 @@ Agent creation is intentionally lightweight: - `src/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create. - `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`. -After creation, Studio applies a safe default capability envelope: -- Commands: `Ask` -- Web access: `Off` -- File tools: `Off` +After creation, Studio applies a permissive default capability envelope: +- Commands: `Auto` +- Web access: `On` +- File tools: `On` Implementation: - `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`. @@ -332,7 +332,7 @@ Code: UI model: - Direct controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`) -- Create modal remains permission-light (name/avatar only) and create flow immediately applies safe defaults (`Ask`, web off, file tools off). +- Create modal remains permission-light (name/avatar only) and create flow immediately applies permissive defaults (`Auto`, web on, file tools on). Why it matters: - You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied. diff --git a/docs/ui-guide.md b/docs/ui-guide.md index 3d7da96..55468a9 100644 --- a/docs/ui-guide.md +++ b/docs/ui-guide.md @@ -50,8 +50,8 @@ This doc describes the current Studio IA and behavior. ## Agent Creation Defaults - Create modal captures only name/avatar. -- After creation, Studio applies safe defaults: - - Commands: Ask - - Web access: Off - - File tools: Off +- After creation, Studio applies permissive defaults: + - Commands: Auto + - Web access: On + - File tools: On - Post-create UX keeps chat as primary and auto-opens Capabilities sidebar for onboarding. diff --git a/skills-overview.md b/skills-overview.md new file mode 100644 index 0000000..d1391a4 --- /dev/null +++ b/skills-overview.md @@ -0,0 +1,277 @@ +# Skills in OpenClaw + OpenClaw Studio + +This document explains skills from first principles, how they work in the OpenClaw runtime (`~/openclaw`), and how OpenClaw Studio currently exposes them in UX. + +It is intended as design context for rethinking the Skills UX. + +## 1) Why skills exist (first principles) + +Skills are the mechanism OpenClaw uses to give agents reusable operational know-how without hardcoding that know-how into core runtime logic. + +At a product level, a skill is: +- A unit of capability guidance (`SKILL.md`) that teaches an agent how to perform a job. +- A gated unit of readiness (only available when required binaries/env/config/OS are satisfied). +- A portable package format compatible with AgentSkills (`agentskills.io`) so skill content can be authored and shared outside a single product. + +Without skills, every workflow instruction would need to live in prompts, app code, or ad hoc user messages. Skills create a middle layer: structured capability packs that are discoverable, filterable, and enforceable. + +## 2) AgentSkills.io context + +OpenClaw intentionally uses AgentSkills-compatible `SKILL.md` structure and semantics. + +Why this matters: +- Interoperability: skills can move between ecosystems that understand AgentSkills. +- Community/network effects: external skill ecosystems (for OpenClaw specifically, ClawHub) can be leveraged instead of reinventing proprietary formats. +- UX consistency: users can reason about “a skill folder with `SKILL.md` + metadata gates” instead of app-specific abstractions. + +OpenClaw adds product-specific metadata under `metadata.openclaw` (install specs, gating fields, primary env key, etc.) while keeping the base skill shape compatible. + +## 3) Skill object model + +A skill is loaded from a directory containing `SKILL.md` with frontmatter. + +Minimum frontmatter: +- `name` +- `description` + +Important optional fields used by OpenClaw: +- `metadata.openclaw.always` +- `metadata.openclaw.skillKey` +- `metadata.openclaw.primaryEnv` +- `metadata.openclaw.os` +- `metadata.openclaw.requires.{bins, anyBins, env, config}` +- `metadata.openclaw.install[]` +- `user-invocable` +- `disable-model-invocation` +- `command-dispatch`, `command-tool`, `command-arg-mode` + +In runtime, this becomes a normalized `SkillEntry`: +- Raw skill (`name`, `description`, `source`, file paths) +- Parsed frontmatter +- Resolved OpenClaw metadata +- Invocation policy flags + +## 4) Where skills come from (discovery + precedence) + +OpenClaw merges multiple sources into one effective skill set. + +Current merge precedence in code (lowest -> highest): +1. `skills.load.extraDirs` and plugin-contributed skill dirs (`source: openclaw-extra`) +2. Bundled skills (`openclaw-bundled`) +3. Managed/global local skills (`~/.openclaw/skills`, `openclaw-managed`) +4. Personal agents skills (`~/.agents/skills`, `agents-skills-personal`) +5. Project agents skills (`/.agents/skills`, `agents-skills-project`) +6. Workspace skills (`/skills`, `openclaw-workspace`) + +Name conflicts are resolved by “last writer wins” according to this order. + +## 5) Eligibility and gating model + +Eligibility is not just “is this skill installed.” It is computed every load/snapshot using: +- Per-skill disable (`skills.entries..enabled === false`) +- Bundled allowlist (`skills.allowBundled`) for bundled skills only +- Runtime requirements: + - `requires.bins` (all required) + - `requires.anyBins` (at least one) + - `requires.env` + - `requires.config` + - `os` +- Remote node eligibility (macOS node bin probing can satisfy certain requirements) +- `always: true` short-circuiting requirement failures + +Status output carries: +- `eligible` / `blocked` +- structured `missing` reasons +- `configChecks` with `{ path, satisfied }` (not secret values) +- install options derived from metadata + +## 6) Agent-level filtering semantics + +OpenClaw has a separate per-agent skill filter via `agents.list[].skills`: +- Missing `skills` key: all discovered skills are allowed +- `skills: []`: no skills allowed +- `skills: ["a", "b"]`: allowlist mode + +This filter is normalized and passed into snapshot generation as `skillFilter`. + +In practice this is the key UX distinction: +- Discovery/readiness is global + workspace-derived. +- “Can this specific agent use it?” is per-agent allowlist. + +## 7) Snapshot + prompt lifecycle + +Skills are snapshotted into session state (`skillsSnapshot`) to avoid re-scanning every turn. + +Snapshot contains: +- prebuilt prompt block +- lightweight skill metadata (`name`, `primaryEnv`, required env names) +- normalized `skillFilter` +- resolved skills list +- version + +Lifecycle: +1. First turn/new session builds snapshot. +2. File watcher / remote-node events bump snapshot version. +3. Later turns refresh snapshot only if version is newer. +4. Prompt injection uses snapshot prompt when present. + +Watcher scope includes: +- workspace `skills/` +- workspace `.agents/skills` +- `~/.openclaw/skills` +- `~/.agents/skills` +- configured extra dirs +- plugin skill dirs + +Watcher monitors `SKILL.md` patterns (not entire trees) and debounces changes. + +## 8) Runtime execution behavior + +During an agent run: +1. Skill env overrides are applied (`skills.entries.*.env` + `apiKey` mapping to `primaryEnv`). +2. Overrides are sanitized/guarded (dangerous host env keys blocked). +3. Skills prompt is injected. +4. Environment is restored after run. + +Invocation behavior: +- `disable-model-invocation: true` keeps skill out of model prompt. +- `user-invocable: true` exposes slash commands. +- Optional direct tool dispatch can bypass model routing. + +Sandbox nuance: +- For non-`rw` sandbox workspaces, OpenClaw syncs skills into sandbox workspace (best-effort) so skill files remain accessible. + +## 9) Gateway API surface for skills + +Core RPC methods: +- `skills.status` -> returns `SkillStatusReport` for an agent workspace. +- `skills.install` -> installs dependencies for a skill install option. +- `skills.update` -> updates `skills.entries.` config (`enabled`, `apiKey`, `env`). +- `skills.bins` -> aggregates required bins across agent workspaces. + +Important scope behavior: +- `skills.install` is executed against the default agent workspace (not arbitrary selected agent workspace). +- `skills.update` writes gateway config (`openclaw.json`) and is gateway-wide state mutation. + +Security detail: +- `skills.status` exposes config check satisfaction, not raw secret config values. + +## 10) OpenClaw Studio UX (current behavior) + +### 10.1 Route and navigation model + +Studio settings currently live on root route with a query-driven settings mode: +- Canonical settings state is `/?settingsAgentId=`. +- `/agents/[agentId]/settings` currently redirects to that query route. + +Left nav tabs in settings mode: +- Behavior +- Capabilities +- Skills +- Automations +- Advanced + +### 10.2 Skills tab data and interactions + +When either `Skills` or `System setup` tab is active and connected, Studio: +1. Calls `skills.status`. +2. Reads current per-agent allowlist from gateway config (`agents.list[].skills`). +3. Renders two distinct settings surfaces: + +`Skills` tab (agent-scoped): +- Shows one list focused on “what this agent can use”. +- Per-skill allow toggle (`Skill ` switch) for agent access only. +- Simplified status chips (`Ready`, `Setup required`, `Not supported`). +- Search + status filters for scanning. +- Non-ready rows provide `Open System Setup` instead of inline setup actions. + +`System setup` tab (gateway-scoped): +- Explicitly states that setup actions affect all agents. +- Shows setup queue and full readiness details. +- Per-skill `Configure` modal with setup/lifecycle actions: + - install dependencies (`skills.install`) + - save API key (`skills.update` with `apiKey`) + - global enable/disable (`skills.update` with `enabled`) + - remove removable skill directories via Studio remove route +- Supports transition handoff from agent row to preselected skill setup context. + +### 10.3 Mutation wiring from Studio + +Per-agent access mutations: +- `updateGatewayAgentSkillsAllowlist` in Studio writes `config.set` with retry-on-stale-hash behavior. +- Agent toggles continue to rely on allowlist semantics (`undefined` means all, explicit array means selected-only). + +System setup mutations: +- Install -> `skills.install` +- API key save -> `skills.update` +- Remove files -> Studio route `/api/gateway/skills/remove` (local fs or SSH helper) + +Removal has strict guards: +- Only specific sources removable (`openclaw-managed`, `openclaw-workspace`). +- Must stay inside allowed root. +- Cannot remove skills root directory. +- Must look like a real skill dir (`SKILL.md` exists). + +### 10.4 Scope warning shown in Studio + +Studio computes the default agent id and passes install-scope context into the system setup surface. + +Current scope copy behavior: +- `Skills` tab copy states controls apply to the current agent. +- `System setup` tab copy states actions apply to all agents. +- Install target caveat (default-agent workspace behavior) is shown in system setup context and setup modal context, where install actions actually occur. + +This keeps scope and install-target warnings accurate while minimizing noise in the agent access flow. + +## 11) What recent `.agent/done` plans show + +Sorted by most recent creation time in `openclaw-studio/.agent/done`, the latest items are mostly bugfix exec plans (streaming, proxy auth, stale config, cron rollback, etc.). + +The most recent plan with explicit skills direction is: +- `ui-execplan-stuff.md` (2026-02-20 create time), which intentionally scoped skills as coming-soon during that IA pass. + +Additional files with incidental skill mentions: +- `simplify-agent-creation-starter-kits.md` +- `ux-zero-agent-layout-consolidation.md` + +Interpretation: +- The current Studio code now has a real Skills tab and mutation flow, but the older IA/doc language still contains “coming soon” assumptions in places. +- For redesign, trust current code behavior over older plan phrasing. + +## 12) UX redesign constraints that are not optional + +Any redesign should preserve these distinctions: + +1. Three separate scopes: +- Agent allowlist scope (`agents.list[].skills`) +- Gateway setup scope (`skills.entries.*`, installs) +- Source/discovery scope (workspace/managed/bundled/extra/plugin) + +2. Eligibility vs enablement: +- A skill can be enabled by allowlist but still blocked by missing requirements. +- A skill can be eligible but disabled by agent allowlist. + +3. Session-snapshot behavior: +- Skills changes may not appear mid-turn; they apply on next turn/snapshot refresh. + +4. Install target caveat: +- Install currently targets default agent workspace context in gateway path. + +5. Security posture: +- Secret values should never be exposed in status surfaces. +- Removal must stay bounded to allowed roots and verified skill dirs. + +## 13) Practical mental model for reviewing a Skills screenshot + +If you hand a screenshot to another LLM for UX feedback, ask it to evaluate on three axes: + +1. **Scope clarity** +- Can a user tell what is per-agent vs gateway-wide? + +2. **Readiness clarity** +- Can a user tell blocked vs eligible and why? + +3. **Action safety** +- Are destructive/setup actions clearly separated from allowlist toggles? + +If a design fails any of those axes, users will misconfigure skills even if controls are technically correct. diff --git a/src/app/page.tsx b/src/app/page.tsx index ca65bb4..907313f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -79,7 +79,6 @@ import { import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation"; import { resolveAgentPermissionsDraft, - type AgentPermissionsDraft, } from "@/features/agents/operations/agentPermissionsOperation"; import { executeStudioBootstrapLoadCommands, @@ -91,6 +90,7 @@ import { runStudioFocusedSelectionPersistenceOperation, } from "@/features/agents/operations/studioBootstrapOperation"; import { + CREATE_AGENT_DEFAULT_PERMISSIONS, applyCreateAgentBootstrapPermissions, executeCreateAgentBootstrapCommands, runCreateAgentBootstrapOperation, @@ -114,11 +114,6 @@ import { } from "@/features/agents/operations/settingsRouteWorkflow"; import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController"; const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500; -const CREATE_AGENT_DEFAULT_PERMISSIONS: AgentPermissionsDraft = { - commandMode: "ask", - webAccess: false, - fileTools: false, -}; type MobilePane = "fleet" | "chat"; type SettingsSidebarItem = SettingsRouteTab; @@ -249,6 +244,7 @@ const AgentStudioPage = () => { const [createAgentModalError, setCreateAgentModalError] = useState(null); const [mobilePane, setMobilePane] = useState("chat"); const [inspectSidebar, setInspectSidebar] = useState(null); + const [systemInitialSkillKey, setSystemInitialSkillKey] = useState(null); const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false); const [settingsSidebarItem, setSettingsSidebarItem] = useState("personality"); const [createAgentBlock, setCreateAgentBlock] = useState(null); @@ -308,6 +304,14 @@ const AgentStudioPage = () => { if (!inspectSidebarAgentId) return null; return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null; }, [agents, inspectSidebarAgentId]); + useEffect(() => { + setSystemInitialSkillKey(null); + }, [inspectSidebarAgentId]); + useEffect(() => { + if (effectiveSettingsTab !== "system") { + setSystemInitialSkillKey(null); + } + }, [effectiveSettingsTab]); const settingsAgentPermissionsDraft = useMemo(() => { if (!inspectSidebarAgent) return null; const baseConfig = @@ -360,9 +364,9 @@ const AgentStudioPage = () => { const settingsSkillScopeWarning = useMemo(() => { if (!inspectSidebarAgent) return null; if (inspectSidebarAgent.agentId === settingsDefaultAgentId) { - return "Skill setup actions are gateway-wide. Install actions run in this default agent workspace."; + return "Setup actions are shared across agents. Installs run in this shared workspace."; } - return `Skill setup actions are gateway-wide. Install actions currently run in the default agent workspace (${settingsDefaultAgentId}), not this agent (${inspectSidebarAgent.agentId}).`; + return `Setup actions are shared across agents. Installs currently run in ${settingsDefaultAgentId} (shared workspace), not ${inspectSidebarAgent.agentId}.`; }, [inspectSidebarAgent, settingsDefaultAgentId]); const focusedPendingExecApprovals = useMemo(() => { if (!focusedAgentId) return unscopedPendingExecApprovals; @@ -821,6 +825,15 @@ const AgentStudioPage = () => { replace: router.replace, confirmDiscard: () => window.confirm("Discard changes?"), }); + const handleOpenSystemSkillSetup = useCallback( + (skillKey?: string) => { + const normalized = skillKey?.trim() ?? ""; + setSystemInitialSkillKey(normalized.length > 0 ? normalized : null); + setSettingsSidebarItem("system"); + handleSettingsRouteTabChange("system"); + }, + [handleSettingsRouteTabChange] + ); const handleOpenCreateAgentModal = useCallback(() => { if (createAgentBusy) return; @@ -905,7 +918,7 @@ const AgentStudioPage = () => { client, agentId, sessionKey, - draft: CREATE_AGENT_DEFAULT_PERMISSIONS, + draft: { ...CREATE_AGENT_DEFAULT_PERMISSIONS }, loadAgents, }); }, @@ -1398,6 +1411,7 @@ const AgentStudioPage = () => { { id: "personality", label: "Behavior" }, { id: "capabilities", label: "Capabilities" }, { id: "skills", label: "Skills" }, + { id: "system", label: "System setup" }, { id: "automations", label: "Automations" }, { id: "advanced", label: "Advanced" }, ] as const @@ -1464,7 +1478,9 @@ const AgentStudioPage = () => { ? "automations" : effectiveSettingsTab === "skills" ? "skills" - : effectiveSettingsTab === "advanced" + : effectiveSettingsTab === "system" + ? "system" + : effectiveSettingsTab === "advanced" ? "advanced" : "capabilities" } @@ -1496,13 +1512,11 @@ const AgentStudioPage = () => { skillMessages={settingsMutationController.settingsSkillMessages} skillApiKeyDrafts={settingsMutationController.settingsSkillApiKeyDrafts} defaultAgentScopeWarning={settingsSkillScopeWarning} + systemInitialSkillKey={systemInitialSkillKey} + onSystemInitialSkillHandled={() => { + setSystemInitialSkillKey(null); + }} skillsAllowlist={settingsAgentSkillsAllowlist} - onUseAllSkills={() => - settingsMutationController.handleUseAllSkills(inspectSidebarAgent.agentId) - } - onDisableAllSkills={() => - settingsMutationController.handleDisableAllSkills(inspectSidebarAgent.agentId) - } onSetSkillEnabled={(skillName, enabled) => settingsMutationController.handleSetSkillEnabled( inspectSidebarAgent.agentId, @@ -1510,6 +1524,7 @@ const AgentStudioPage = () => { enabled ) } + onOpenSystemSetup={handleOpenSystemSkillSetup} onInstallSkill={(skillKey, name, installId) => settingsMutationController.handleInstallSkill( inspectSidebarAgent.agentId, @@ -1533,6 +1548,13 @@ const AgentStudioPage = () => { skillKey ) } + onSetSkillGlobalEnabled={(skillKey, enabled) => + settingsMutationController.handleSetSkillGlobalEnabled( + inspectSidebarAgent.agentId, + skillKey, + enabled + ) + } cronJobs={settingsMutationController.settingsCronJobs} cronLoading={settingsMutationController.settingsCronLoading} cronError={settingsMutationController.settingsCronError} diff --git a/src/features/agents/components/AgentInspectPanels.tsx b/src/features/agents/components/AgentInspectPanels.tsx index e2bd5cc..0e8a0a9 100644 --- a/src/features/agents/components/AgentInspectPanels.tsx +++ b/src/features/agents/components/AgentInspectPanels.tsx @@ -19,20 +19,14 @@ import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPay import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; import type { SkillStatusReport } from "@/lib/skills/types"; -import { - buildSkillMissingDetails, - buildSkillReasons, - canRemoveSkill, - groupSkillsBySource, - isBundledBlockedSkill, - resolvePreferredInstallOption, -} from "@/lib/skills/presentation"; import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles"; import { resolveExecutionRoleFromAgent, resolvePresetDefaultsForRole, type AgentPermissionsDraft, } from "@/features/agents/operations/agentPermissionsOperation"; +import { AgentSkillsPanel } from "@/features/agents/components/AgentSkillsPanel"; +import { SystemSkillsPanel } from "@/features/agents/components/SystemSkillsPanel"; import { AGENT_FILE_NAMES, type AgentFileName, @@ -97,7 +91,7 @@ const AgentInspectHeader = ({ type AgentSettingsPanelProps = { agent: AgentState; - mode?: "capabilities" | "skills" | "automations" | "advanced"; + mode?: "capabilities" | "skills" | "system" | "automations" | "advanced"; showHeader?: boolean; onClose: () => void; permissionsDraft?: AgentPermissionsDraft; @@ -124,10 +118,12 @@ type AgentSettingsPanelProps = { skillMessages?: Record; skillApiKeyDrafts?: Record; defaultAgentScopeWarning?: string | null; + systemInitialSkillKey?: string | null; + onSystemInitialSkillHandled?: () => void; skillsAllowlist?: string[] | undefined; - onUseAllSkills?: () => Promise | void; - onDisableAllSkills?: () => Promise | void; onSetSkillEnabled?: (skillName: string, enabled: boolean) => Promise | void; + onOpenSystemSetup?: (skillKey?: string) => void; + onSetSkillGlobalEnabled?: (skillKey: string, enabled: boolean) => Promise | void; onInstallSkill?: (skillKey: string, name: string, installId: string) => Promise | void; onRemoveSkill?: ( skill: { skillKey: string; source: string; baseDir: string } @@ -325,10 +321,12 @@ export const AgentSettingsPanel = ({ skillMessages = {}, skillApiKeyDrafts = {}, defaultAgentScopeWarning = null, + systemInitialSkillKey = null, + onSystemInitialSkillHandled = () => {}, skillsAllowlist, - onUseAllSkills = () => {}, - onDisableAllSkills = () => {}, onSetSkillEnabled = () => {}, + onOpenSystemSetup = () => {}, + onSetSkillGlobalEnabled = () => {}, onInstallSkill = () => {}, onRemoveSkill = () => {}, onSkillApiKeyChange = () => {}, @@ -352,14 +350,6 @@ export const AgentSettingsPanel = ({ const [cronCreateStep, setCronCreateStep] = useState(0); const [cronCreateError, setCronCreateError] = useState(null); const [cronDraft, setCronDraft] = useState(createInitialCronDraft); - const [skillsFilter, setSkillsFilter] = useState(""); - const [hideBundledBlockedSkills, setHideBundledBlockedSkills] = useState(true); - const [pendingSkillRemoval, setPendingSkillRemoval] = useState<{ - skillKey: string; - name: string; - source: string; - baseDir: string; - } | null>(null); const resolvedExecutionRole = useMemo(() => resolveExecutionRoleFromAgent(agent), [agent]); const resolvedPermissionsDraft = useMemo( @@ -428,12 +418,6 @@ export const AgentSettingsPanel = ({ }; }, [permissionsDirty, permissionsDraftValue, permissionsSaving, runPermissionsSave]); - useEffect(() => { - setSkillsFilter(""); - setHideBundledBlockedSkills(true); - setPendingSkillRemoval(null); - }, [agent.agentId]); - const openCronCreate = () => { setCronCreateOpen(true); setCronCreateStep(0); @@ -520,45 +504,14 @@ export const AgentSettingsPanel = ({ } }; - const skillEntries = skillsReport?.skills ?? []; - const normalizedAllowlist = useMemo( - () => - (skillsAllowlist ?? []) - .map((value) => value.trim()) - .filter((value) => value.length > 0), - [skillsAllowlist] - ); - const allowlistSet = useMemo(() => new Set(normalizedAllowlist), [normalizedAllowlist]); - const usingAllowlist = skillsAllowlist !== undefined; - const enabledSkillCount = useMemo(() => { - if (!usingAllowlist) return skillEntries.length; - return skillEntries.reduce((count, skill) => count + (allowlistSet.has(skill.name) ? 1 : 0), 0); - }, [allowlistSet, skillEntries, usingAllowlist]); - const searchedSkillEntries = useMemo(() => { - const query = skillsFilter.trim().toLowerCase(); - if (!query) return skillEntries; - return skillEntries.filter((skill) => - [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(query) - ); - }, [skillEntries, skillsFilter]); - const filteredSkillEntries = useMemo(() => { - if (!hideBundledBlockedSkills) { - return searchedSkillEntries; - } - return searchedSkillEntries.filter((skill) => !isBundledBlockedSkill(skill)); - }, [hideBundledBlockedSkills, searchedSkillEntries]); - const filteredSkillGroups = useMemo( - () => groupSkillsBySource(filteredSkillEntries), - [filteredSkillEntries] - ); - const hiddenBundledBlockedCount = useMemo(() => { - if (!hideBundledBlockedSkills) return 0; - return searchedSkillEntries.reduce( - (count, skill) => count + (isBundledBlockedSkill(skill) ? 1 : 0), - 0 - ); - }, [hideBundledBlockedSkills, searchedSkillEntries]); - const panelLabel = mode === "advanced" ? "Advanced" : mode === "skills" ? "Skills" : ""; + const panelLabel = + mode === "advanced" + ? "Advanced" + : mode === "skills" + ? "Skills" + : mode === "system" + ? "System setup" + : ""; const canOpenControlUi = typeof controlUiUrl === "string" && controlUiUrl.trim().length > 0; const timedAutomationStepMeta = TIMED_AUTOMATION_STEP_META[cronCreateStep] ?? @@ -725,252 +678,36 @@ export const AgentSettingsPanel = ({ ) : null} {mode === "skills" ? ( -
-
-

Skills

-
- {usingAllowlist ? `${enabledSkillCount}/${skillEntries.length}` : `All (${skillEntries.length})`} -
-
-
- Control which discovered skills this agent can use. -
-
- Allowlist toggles affect this agent only. Install and API key setup affect gateway-wide - skill readiness. -
- {defaultAgentScopeWarning ? ( -
- {defaultAgentScopeWarning} -
- ) : null} -
- setSkillsFilter(event.target.value)} - placeholder="Search skills" - className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border" - aria-label="Search skills" - /> -
- - -
-
-
- -
- {filteredSkillEntries.length}/{skillEntries.length} shown -
-
- {hideBundledBlockedSkills && hiddenBundledBlockedCount > 0 ? ( -
- Hidden bundled + blocked: {hiddenBundledBlockedCount} -
- ) : null} - {skillsLoading ? ( -
Loading skills...
- ) : null} - {!skillsLoading && skillsError ? ( -
{skillsError}
- ) : null} - {!skillsLoading && !skillsError && filteredSkillEntries.length === 0 ? ( -
No matching skills.
- ) : null} - {!skillsLoading && !skillsError && filteredSkillEntries.length > 0 ? ( -
- {filteredSkillGroups.map((group) => { - const collapsedByDefault = group.id === "workspace" || group.id === "built-in"; - return ( -
- - {group.label} - - {group.skills.length} - - -
- {group.skills.map((skill) => { - const enabled = usingAllowlist ? allowlistSet.has(skill.name) : true; - const missingDetails = buildSkillMissingDetails(skill); - const reasons = buildSkillReasons(skill); - const message = skillMessages[skill.skillKey] ?? null; - const busyForSkill = skillsBusyKey === skill.skillKey; - const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); - const installOption = resolvePreferredInstallOption(skill); - const canDeleteSkill = canRemoveSkill(skill); - const apiKeyDraft = skillApiKeyDrafts[skill.skillKey] ?? ""; - const hasApiKeyDraft = apiKeyDraft.trim().length > 0; + + ) : null} - return ( -
-
-
- - {skill.name} - - - {skill.source} - - - {skill.eligible ? "eligible" : "blocked"} - - {skill.disabled ? ( - - disabled - - ) : null} - {skill.blockedByAllowlist ? ( - - allowlist block - - ) : null} -
-
- {skill.description} -
- {missingDetails.map((line) => ( -
- {line} -
- ))} - {reasons.length > 0 ? ( -
- Reason: {reasons.join(", ")} -
- ) : null} - {message ? ( -
- {message.message} -
- ) : null} -
-
-
- - {canDeleteSkill ? ( - - ) : null} -
- {installOption ? ( - - ) : null} - {skill.primaryEnv ? ( - <> - { - void onSkillApiKeyChange(skill.skillKey, event.target.value); - }} - disabled={anySkillBusy} - className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border" - placeholder={`Set ${skill.primaryEnv}`} - aria-label={`API key for ${skill.name}`} - /> - - - ) : null} -
-
- ); - })} -
-
- ); - })} -
- ) : null} -
+ {mode === "system" ? ( + ) : null} {mode === "automations" ? ( @@ -1191,82 +928,6 @@ export const AgentSettingsPanel = ({ ) : null} - {pendingSkillRemoval ? ( -
{ - setPendingSkillRemoval(null); - }} - > -
event.stopPropagation()} - > -
-
-
- Remove skill files -
-
- Remove {pendingSkillRemoval.name} from the gateway? -
-
- -
-
-
- This permanently removes this skill directory on the gateway host. This action cannot - be undone. -
-
-
Source: {pendingSkillRemoval.source}
-
Path: {pendingSkillRemoval.baseDir}
-
-
-
- - -
-
-
- ) : null} {cronCreateOpen ? (
Promise | void; + onOpenSystemSetup: (skillKey?: string) => void; +}; + +const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [ + { id: "all", label: "All" }, + { id: "ready", label: "Ready" }, + { id: "setup-required", label: "Setup required" }, + { id: "not-supported", label: "Not supported" }, +]; + +const DISPLAY_LABELS: Record = { + ready: "Ready", + "setup-required": "Setup required", + "not-supported": "Not supported", +}; + +const DISPLAY_CLASSES: Record = { + ready: "ui-badge-status-running", + "setup-required": "ui-badge-status-error", + "not-supported": "ui-badge-status-error", +}; + +const resolveHint = ( + skill: SkillStatusReport["skills"][number], + displayState: AgentSkillDisplayState +): string | null => { + if (displayState === "ready") { + return null; + } + if (displayState === "not-supported") { + if (skill.blockedByAllowlist) { + return "Blocked by bundled skills policy."; + } + return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported."; + } + const readiness = deriveSkillReadinessState(skill); + if (readiness === "disabled-globally") { + return "Disabled globally. Enable it in System setup."; + } + return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup."; +}; + +export const AgentSkillsPanel = ({ + skillsReport = null, + skillsLoading = false, + skillsError = null, + skillsBusy = false, + skillsBusyKey = null, + skillsAllowlist, + onSetSkillEnabled, + onOpenSystemSetup, +}: AgentSkillsPanelProps) => { + const [skillsFilter, setSkillsFilter] = useState(""); + const [rowFilter, setRowFilter] = useState("all"); + + const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]); + const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist); + const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]); + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + + const rows = useMemo(() => { + return skillEntries.map((skill) => { + const normalizedName = skill.name.trim(); + const allowed = + accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName); + const readiness = deriveSkillReadinessState(skill); + return { + skill, + allowed, + displayState: deriveAgentSkillDisplayState(readiness), + }; + }); + }, [accessMode, allowlistSet, skillEntries]); + + const searchedRows = useMemo(() => { + const query = skillsFilter.trim().toLowerCase(); + if (!query) { + return rows; + } + return rows.filter((entry) => + [entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey] + .join(" ") + .toLowerCase() + .includes(query) + ); + }, [rows, skillsFilter]); + + const filteredRows = useMemo(() => { + if (rowFilter === "all") { + return searchedRows; + } + return searchedRows.filter((entry) => entry.displayState === rowFilter); + }, [rowFilter, searchedRows]); + + const filterCounts = useMemo( + () => + searchedRows.reduce( + (counts, entry) => { + counts.all += 1; + counts[entry.displayState] += 1; + return counts; + }, + { + all: 0, + ready: 0, + "setup-required": 0, + "not-supported": 0, + } satisfies Record + ), + [searchedRows] + ); + + const enabledCount = useMemo( + () => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0), + [rows] + ); + + return ( +
+
+

Skills

+
+ {enabledCount}/{skillEntries.length} +
+
+
Skill access controls apply to this agent.
+ {accessMode === "selected" ? ( +
+ This agent is using selected skills only. +
+ ) : null} +
+ setSkillsFilter(event.target.value)} + placeholder="Search skills" + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border" + aria-label="Search skills" + /> +
+
+ {FILTERS.map((filter) => { + const selected = rowFilter === filter.id; + return ( + + ); + })} +
+ {skillsLoading ?
Loading skills...
: null} + {!skillsLoading && skillsError ? ( +
{skillsError}
+ ) : null} + {!skillsLoading && !skillsError && filteredRows.length === 0 ? ( +
No matching skills.
+ ) : null} + {!skillsLoading && !skillsError && filteredRows.length > 0 ? ( +
+ {filteredRows.map((entry) => { + const statusLabel = DISPLAY_LABELS[entry.displayState]; + const statusClassName = DISPLAY_CLASSES[entry.displayState]; + const canConfigureInSystem = entry.displayState === "setup-required"; + const switchDisabled = anySkillBusy || entry.displayState === "not-supported"; + return ( +
+
+
+ {entry.skill.name} + + {entry.skill.source} + + + {statusLabel} + +
+
{entry.skill.description}
+ {entry.displayState !== "ready" ? ( +
+ {resolveHint(entry.skill, entry.displayState)} +
+ ) : null} +
+
+ + {canConfigureInSystem ? ( + + ) : null} +
+
+ ); + })} +
+ ) : null} +
+ ); +}; diff --git a/src/features/agents/components/AgentSkillsSetupModal.tsx b/src/features/agents/components/AgentSkillsSetupModal.tsx new file mode 100644 index 0000000..a250aa5 --- /dev/null +++ b/src/features/agents/components/AgentSkillsSetupModal.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useEffect } from "react"; + +import type { SkillStatusEntry } from "@/lib/skills/types"; +import { + buildSkillMissingDetails, + canRemoveSkill, + deriveSkillReadinessState, + resolvePreferredInstallOption, +} from "@/lib/skills/presentation"; + +type SkillSetupMessage = { kind: "success" | "error"; message: string }; + +type AgentSkillsSetupModalProps = { + skill: SkillStatusEntry | null; + skillsBusy: boolean; + skillsBusyKey: string | null; + skillMessage: SkillSetupMessage | null; + apiKeyDraft: string; + defaultAgentScopeWarning?: string | null; + onClose: () => void; + onInstallSkill: (skillKey: string, name: string, installId: string) => Promise | void; + onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise | void; + onRemoveSkill: ( + skill: { skillKey: string; source: string; baseDir: string } + ) => Promise | void; + onSkillApiKeyChange: (skillKey: string, value: string) => Promise | void; + onSaveSkillApiKey: (skillKey: string) => Promise | void; +}; + +const READINESS_LABELS = { + ready: "Ready", + "needs-setup": "Needs setup", + unavailable: "Unavailable", + "disabled-globally": "Disabled globally", +} as const; + +const READINESS_CLASSES = { + ready: "ui-badge-status-running", + "needs-setup": "ui-badge-status-error", + unavailable: "ui-badge-status-error", + "disabled-globally": "ui-badge-status-error", +} as const; + +export const AgentSkillsSetupModal = ({ + skill, + skillsBusy, + skillsBusyKey, + skillMessage, + apiKeyDraft, + defaultAgentScopeWarning = null, + onClose, + onInstallSkill, + onSetSkillGlobalEnabled, + onRemoveSkill, + onSkillApiKeyChange, + onSaveSkillApiKey, +}: AgentSkillsSetupModalProps) => { + useEffect(() => { + if (!skill) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose, skill]); + + if (!skill) { + return null; + } + + const readiness = deriveSkillReadinessState(skill); + const readinessLabel = READINESS_LABELS[readiness]; + const readinessClassName = READINESS_CLASSES[readiness]; + const missingDetails = buildSkillMissingDetails(skill); + const installOption = resolvePreferredInstallOption(skill); + const canDeleteSkill = canRemoveSkill(skill); + const busyForSkill = skillsBusyKey === skill.skillKey; + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + const trimmedApiKey = apiKeyDraft.trim(); + + return ( +
+
event.stopPropagation()} + > +
+
+
+ System setup +
+
+ {skill.name} + + {readinessLabel} + +
+
+ Changes affect all agents on this gateway. +
+
+ +
+
+ {defaultAgentScopeWarning ? ( +
+ {defaultAgentScopeWarning} +
+ ) : null} +
{skill.description}
+ {skill.blockedByAllowlist ? ( +
+ Blocked by bundled skills policy (`skills.allowBundled`). +
+ ) : null} + {missingDetails.map((line) => ( +
+ {line} +
+ ))} + {skillMessage ? ( +
+ {skillMessage.message} +
+ ) : null} +
+ {installOption ? ( + + ) : null} + + {skill.primaryEnv ? ( + <> + { + void onSkillApiKeyChange(skill.skillKey, event.target.value); + }} + disabled={anySkillBusy} + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border" + placeholder={`Set ${skill.primaryEnv}`} + aria-label={`API key for ${skill.name}`} + /> + + + ) : null} + {canDeleteSkill ? ( + + ) : null} +
+
+
+
+ ); +}; diff --git a/src/features/agents/components/SystemSkillsPanel.tsx b/src/features/agents/components/SystemSkillsPanel.tsx new file mode 100644 index 0000000..cf54be2 --- /dev/null +++ b/src/features/agents/components/SystemSkillsPanel.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal"; +import { + buildSkillMissingDetails, + deriveSkillReadinessState, + type SkillReadinessState, +} from "@/lib/skills/presentation"; +import type { SkillStatusReport } from "@/lib/skills/types"; + +type SkillSetupMessage = { kind: "success" | "error"; message: string }; + +type ReadinessFilter = "all" | SkillReadinessState; + +type SystemSkillsPanelProps = { + skillsReport?: SkillStatusReport | null; + skillsLoading?: boolean; + skillsError?: string | null; + skillsBusy?: boolean; + skillsBusyKey?: string | null; + skillMessages?: Record; + skillApiKeyDrafts?: Record; + defaultAgentScopeWarning?: string | null; + initialSkillKey?: string | null; + onInitialSkillKeyHandled?: () => void; + onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise | void; + onInstallSkill: (skillKey: string, name: string, installId: string) => Promise | void; + onRemoveSkill: ( + skill: { skillKey: string; source: string; baseDir: string } + ) => Promise | void; + onSkillApiKeyChange: (skillKey: string, value: string) => Promise | void; + onSaveSkillApiKey: (skillKey: string) => Promise | void; +}; + +const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [ + { id: "all", label: "All" }, + { id: "ready", label: "Ready" }, + { id: "needs-setup", label: "Needs setup" }, + { id: "unavailable", label: "Unavailable" }, + { id: "disabled-globally", label: "Disabled globally" }, +]; + +const READINESS_LABELS = { + ready: "Ready", + "needs-setup": "Needs setup", + unavailable: "Unavailable", + "disabled-globally": "Disabled globally", +} as const; + +const READINESS_CLASSES = { + ready: "ui-badge-status-running", + "needs-setup": "ui-badge-status-error", + unavailable: "ui-badge-status-error", + "disabled-globally": "ui-badge-status-error", +} as const; + +const resolveReadinessHint = ( + skill: SkillStatusReport["skills"][number], + readiness: SkillReadinessState +): string | null => { + if (readiness === "ready") { + return null; + } + if (readiness === "disabled-globally") { + return "Disabled globally for all agents."; + } + if (readiness === "unavailable") { + if (skill.blockedByAllowlist) { + return "Blocked by bundled skills policy."; + } + return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system."; + } + return buildSkillMissingDetails(skill)[0] ?? "Requires setup."; +}; + +export const SystemSkillsPanel = ({ + skillsReport = null, + skillsLoading = false, + skillsError = null, + skillsBusy = false, + skillsBusyKey = null, + skillMessages = {}, + skillApiKeyDrafts = {}, + defaultAgentScopeWarning = null, + initialSkillKey = null, + onInitialSkillKeyHandled, + onSetSkillGlobalEnabled, + onInstallSkill, + onRemoveSkill, + onSkillApiKeyChange, + onSaveSkillApiKey, +}: SystemSkillsPanelProps) => { + const [skillsFilter, setSkillsFilter] = useState(""); + const [readinessFilter, setReadinessFilter] = useState("all"); + const [setupSkillKey, setSetupSkillKey] = useState(null); + + const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]); + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + const requestedInitialSkillKey = useMemo(() => { + const candidate = initialSkillKey?.trim() ?? ""; + if (!candidate) { + return null; + } + return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null; + }, [initialSkillKey, skillEntries]); + + const rows = useMemo( + () => + skillEntries.map((skill) => ({ + skill, + readiness: deriveSkillReadinessState(skill), + })), + [skillEntries] + ); + + const searchedRows = useMemo(() => { + const query = skillsFilter.trim().toLowerCase(); + if (!query) { + return rows; + } + return rows.filter((entry) => + [entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey] + .join(" ") + .toLowerCase() + .includes(query) + ); + }, [rows, skillsFilter]); + + const filteredRows = useMemo(() => { + if (readinessFilter === "all") { + return searchedRows; + } + return searchedRows.filter((entry) => entry.readiness === readinessFilter); + }, [readinessFilter, searchedRows]); + + const readinessCounts = useMemo( + () => + searchedRows.reduce( + (counts, entry) => { + counts.all += 1; + counts[entry.readiness] += 1; + return counts; + }, + { + all: 0, + ready: 0, + "needs-setup": 0, + unavailable: 0, + "disabled-globally": 0, + } satisfies Record + ), + [searchedRows] + ); + + const setupQueue = useMemo( + () => + rows.filter( + (entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally" + ), + [rows] + ); + + const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey; + const selectedSetupSkill = selectedSkillKey + ? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null + : null; + + return ( +
+
+

System skill setup

+
{skillEntries.length}
+
+
+ Changes here affect all agents on this gateway. +
+ {defaultAgentScopeWarning ? ( +
+ {defaultAgentScopeWarning} +
+ ) : null} + {setupQueue.length > 0 ? ( +
+
Needs setup ({setupQueue.length})
+
+ {setupQueue.slice(0, 5).map((entry) => ( +
+ {entry.skill.name} + +
+ ))} +
+
+ ) : null} +
+ setSkillsFilter(event.target.value)} + placeholder="Search skills" + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border" + aria-label="Search skills" + /> +
+
+ {READINESS_FILTERS.map((filter) => { + const selected = readinessFilter === filter.id; + return ( + + ); + })} +
+ {skillsLoading ?
Loading skills...
: null} + {!skillsLoading && skillsError ? ( +
{skillsError}
+ ) : null} + {!skillsLoading && !skillsError && filteredRows.length === 0 ? ( +
No matching skills.
+ ) : null} + {!skillsLoading && !skillsError && filteredRows.length > 0 ? ( +
+ {filteredRows.map((entry) => { + const readinessLabel = READINESS_LABELS[entry.readiness]; + const readinessClassName = READINESS_CLASSES[entry.readiness]; + const message = skillMessages[entry.skill.skillKey] ?? null; + return ( +
+
+
+ {entry.skill.name} + + {entry.skill.source} + + + {readinessLabel} + +
+
{entry.skill.description}
+ {entry.readiness !== "ready" ? ( +
+ {resolveReadinessHint(entry.skill, entry.readiness)} +
+ ) : null} + {message ? ( +
+ {message.message} +
+ ) : null} +
+
+ +
+
+ ); + })} +
+ ) : null} + { + onInitialSkillKeyHandled?.(); + setSetupSkillKey(null); + }} + onInstallSkill={onInstallSkill} + onSetSkillGlobalEnabled={onSetSkillGlobalEnabled} + onRemoveSkill={onRemoveSkill} + onSkillApiKeyChange={onSkillApiKeyChange} + onSaveSkillApiKey={onSaveSkillApiKey} + /> +
+ ); +}; diff --git a/src/features/agents/operations/agentSettingsMutationWorkflow.ts b/src/features/agents/operations/agentSettingsMutationWorkflow.ts index 6f0eb35..10af4a9 100644 --- a/src/features/agents/operations/agentSettingsMutationWorkflow.ts +++ b/src/features/agents/operations/agentSettingsMutationWorkflow.ts @@ -11,7 +11,9 @@ type GuardedActionKind = | "update-agent-permissions" | "use-all-skills" | "disable-all-skills" + | "set-skills-allowlist" | "set-skill-enabled" + | "set-skill-global-enabled" | "install-skill" | "remove-skill" | "save-skill-api-key"; @@ -64,7 +66,9 @@ const isGuardedAction = ( kind === "update-agent-permissions" || kind === "use-all-skills" || kind === "disable-all-skills" || + kind === "set-skills-allowlist" || kind === "set-skill-enabled" || + kind === "set-skill-global-enabled" || kind === "install-skill" || kind === "remove-skill" || kind === "save-skill-api-key"; @@ -149,6 +153,7 @@ export const planAgentSettingsMutation = ( } if ( + request.kind === "set-skill-global-enabled" || request.kind === "install-skill" || request.kind === "remove-skill" || request.kind === "save-skill-api-key" diff --git a/src/features/agents/operations/createAgentBootstrapOperation.ts b/src/features/agents/operations/createAgentBootstrapOperation.ts index 80d2ad0..e3f443d 100644 --- a/src/features/agents/operations/createAgentBootstrapOperation.ts +++ b/src/features/agents/operations/createAgentBootstrapOperation.ts @@ -1,5 +1,6 @@ import { type AgentPermissionsDraft, + resolvePresetDefaultsForRole, updateAgentPermissionsViaStudio, } from "@/features/agents/operations/agentPermissionsOperation"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; @@ -18,6 +19,9 @@ type CreatedAgent = { sessionKey: string; }; +export const CREATE_AGENT_DEFAULT_PERMISSIONS: Readonly = + Object.freeze(resolvePresetDefaultsForRole("autonomous")); + const resolveBootstrapErrorMessage = (error: unknown): string => { if (error instanceof Error) { return error.message || "Failed to apply default permissions."; diff --git a/src/features/agents/operations/settingsRouteWorkflow.ts b/src/features/agents/operations/settingsRouteWorkflow.ts index 038c385..45fa25b 100644 --- a/src/features/agents/operations/settingsRouteWorkflow.ts +++ b/src/features/agents/operations/settingsRouteWorkflow.ts @@ -2,6 +2,7 @@ export type SettingsRouteTab = | "personality" | "capabilities" | "skills" + | "system" | "automations" | "advanced"; diff --git a/src/features/agents/operations/useAgentSettingsMutationController.ts b/src/features/agents/operations/useAgentSettingsMutationController.ts index bec99fd..1c5cd38 100644 --- a/src/features/agents/operations/useAgentSettingsMutationController.ts +++ b/src/features/agents/operations/useAgentSettingsMutationController.ts @@ -14,6 +14,7 @@ import { type MutationBlockState, type MutationWorkflowKind, } from "@/features/agents/operations/mutationLifecycleWorkflow"; +import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow"; import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayRestartBlock"; import type { AgentState } from "@/features/agents/state/store"; @@ -35,7 +36,7 @@ import { updateGatewayAgentSkillsAllowlist, } from "@/lib/gateway/agentConfig"; import { fetchJson } from "@/lib/http"; -import { canRemoveSkillSource } from "@/lib/skills/presentation"; +import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation"; import { removeSkillFromGateway } from "@/lib/skills/remove"; import { installSkill, @@ -66,7 +67,7 @@ export type UseAgentSettingsMutationControllerParams = { gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; - inspectSidebarTab: string | null; + inspectSidebarTab: SettingsRouteTab | null; loadAgents: () => Promise; refreshGatewayConfigSnapshot: () => Promise; clearInspectSidebar: () => void; @@ -181,11 +182,13 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat ); useEffect(() => { + const skillsTabActive = + params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system"; if ( !params.settingsRouteActive || !params.inspectSidebarAgentId || params.status !== "connected" || - params.inspectSidebarTab !== "skills" + !skillsTabActive ) { skillsLoadRequestIdRef.current += 1; setSettingsSkillsReport(null); @@ -635,9 +638,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const reloadSkillsIfVisible = useCallback( async (agentId: string) => { + const skillsTabActive = + params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system"; if ( params.settingsRouteActive && - params.inspectSidebarTab === "skills" && + skillsTabActive && params.inspectSidebarAgentId === agentId && params.status === "connected" ) { @@ -656,7 +661,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const runSkillsMutation = useCallback( async (input: { agentId: string; - decisionKind: "use-all-skills" | "disable-all-skills" | "set-skill-enabled"; + decisionKind: + | "use-all-skills" + | "disable-all-skills" + | "set-skills-allowlist" + | "set-skill-enabled"; skillName?: string; run: (normalizedAgentId: string) => Promise; }) => { @@ -747,7 +756,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const resolvedSkillName = skillName.trim(); const visibleSkillNames = Array.from( new Set( - (settingsSkillsReport?.skills ?? []) + filterOsCompatibleSkills(settingsSkillsReport?.skills ?? []) .map((entry) => entry.name.trim()) .filter((name) => name.length > 0) ) @@ -780,6 +789,34 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat [params.client, runSkillsMutation, settingsSkillsReport] ); + const handleSetSkillsAllowlist = useCallback( + async (agentId: string, skillNames: string[]) => { + await runSkillsMutation({ + agentId, + decisionKind: "set-skills-allowlist", + run: async (normalizedAgentId) => { + const normalizedSkillNames = Array.from( + new Set( + skillNames + .map((value) => value.trim()) + .filter((value) => value.length > 0) + ) + ); + if (normalizedSkillNames.length === 0) { + throw new Error("Cannot set selected skills mode: choose at least one skill."); + } + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: normalizedSkillNames, + }); + }, + }); + }, + [params.client, runSkillsMutation] + ); + const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => { const normalizedSkillKey = skillKey.trim(); if (!normalizedSkillKey) { @@ -794,7 +831,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat const runSkillSetupMutation = useCallback( async (input: { agentId: string; - decisionKind: "install-skill" | "remove-skill" | "save-skill-api-key"; + decisionKind: + | "install-skill" + | "remove-skill" + | "save-skill-api-key" + | "set-skill-global-enabled"; skillKey: string; label: string; run: () => Promise<{ successMessage: string }>; @@ -963,6 +1004,29 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat [params.client, runSkillSetupMutation, setSkillMessage, settingsSkillApiKeyDrafts] ); + const handleSetSkillGlobalEnabled = useCallback( + async (agentId: string, skillKey: string, enabled: boolean) => { + const normalizedSkillKey = skillKey.trim(); + await runSkillSetupMutation({ + agentId, + decisionKind: "set-skill-global-enabled", + skillKey: normalizedSkillKey, + label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`, + refreshConfigSnapshot: true, + run: async () => { + await updateSkill(params.client, { + skillKey: normalizedSkillKey, + enabled, + }); + return { + successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally", + }; + }, + }); + }, + [params.client, runSkillSetupMutation] + ); + return { settingsSkillsReport, settingsSkillsLoading, @@ -989,10 +1053,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat handleUpdateAgentPermissions, handleUseAllSkills, handleDisableAllSkills, + handleSetSkillsAllowlist, handleSetSkillEnabled, handleInstallSkill, handleRemoveSkill, handleSkillApiKeyDraftChange, handleSaveSkillApiKey, + handleSetSkillGlobalEnabled, }; } diff --git a/src/lib/skills/presentation.ts b/src/lib/skills/presentation.ts index 98bb1d2..3b9ddb1 100644 --- a/src/lib/skills/presentation.ts +++ b/src/lib/skills/presentation.ts @@ -12,6 +12,16 @@ export type SkillSourceGroup = { skills: SkillStatusEntry[]; }; +export type SkillReadinessState = + | "ready" + | "needs-setup" + | "unavailable" + | "disabled-globally"; + +export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported"; + +export type AgentSkillsAccessMode = "all" | "none" | "selected"; + const GROUP_DEFINITIONS: Array<{ id: Exclude; label: string }> = [ { id: "workspace", label: "Workspace Skills" }, { id: "built-in", label: "Built-in Skills" }, @@ -30,6 +40,18 @@ const trimNonEmpty = (value: string): string | null => { return trimmed.length > 0 ? trimmed : null; }; +const OS_LABELS: Record = { + darwin: "macOS", + linux: "Linux", + win32: "Windows", + windows: "Windows", +}; + +const toOsLabel = (value: string): string => { + const normalized = value.trim().toLowerCase(); + return OS_LABELS[normalized] ?? value.trim(); +}; + const normalizeStringList = (values: string[] | undefined): string[] => { if (!Array.isArray(values)) { return []; @@ -44,6 +66,23 @@ const normalizeStringList = (values: string[] | undefined): string[] => { return normalized; }; +export const normalizeAgentSkillsAllowlist = (values: string[] | undefined): string[] => { + const normalized = normalizeStringList(values); + return Array.from(new Set(normalized)); +}; + +export const deriveAgentSkillsAccessMode = ( + values: string[] | undefined +): AgentSkillsAccessMode => { + if (!Array.isArray(values)) { + return "all"; + } + return normalizeAgentSkillsAllowlist(values).length === 0 ? "none" : "selected"; +}; + +export const buildAgentSkillsAllowlistSet = (values: string[] | undefined): Set => + new Set(normalizeAgentSkillsAllowlist(values)); + const resolveGroupId = (skill: SkillStatusEntry): SkillSourceGroupId => { const source = trimNonEmpty(skill.source) ?? ""; const bundled = skill.bundled || source === "openclaw-bundled"; @@ -116,7 +155,7 @@ export const buildSkillMissingDetails = (skill: SkillStatusEntry): string[] => { const os = normalizeStringList(skill.missing.os); if (os.length > 0) { - details.push(`Unsupported OS: ${os.join(", ")}`); + details.push(`Requires OS: ${os.map((value) => toOsLabel(value)).join(", ")}`); } return details; @@ -148,6 +187,39 @@ export const buildSkillReasons = (skill: SkillStatusEntry): string[] => { return reasons; }; +export const isSkillOsIncompatible = (skill: SkillStatusEntry): boolean => { + return normalizeStringList(skill.missing.os).length > 0; +}; + +export const filterOsCompatibleSkills = (skills: SkillStatusEntry[]): SkillStatusEntry[] => { + return skills.filter((skill) => !isSkillOsIncompatible(skill)); +}; + +export const deriveSkillReadinessState = (skill: SkillStatusEntry): SkillReadinessState => { + if (skill.disabled) { + return "disabled-globally"; + } + if (isSkillOsIncompatible(skill) || skill.blockedByAllowlist) { + return "unavailable"; + } + if (skill.eligible) { + return "ready"; + } + return "needs-setup"; +}; + +export const deriveAgentSkillDisplayState = ( + readiness: SkillReadinessState +): AgentSkillDisplayState => { + if (readiness === "ready") { + return "ready"; + } + if (readiness === "unavailable") { + return "not-supported"; + } + return "setup-required"; +}; + export const isBundledBlockedSkill = (skill: SkillStatusEntry): boolean => { const source = trimNonEmpty(skill.source) ?? ""; return (skill.bundled || source === "openclaw-bundled") && !skill.eligible; diff --git a/tests/unit/agentPermissionsOperation.test.ts b/tests/unit/agentPermissionsOperation.test.ts index 90a9283..c128e63 100644 --- a/tests/unit/agentPermissionsOperation.test.ts +++ b/tests/unit/agentPermissionsOperation.test.ts @@ -4,6 +4,7 @@ import { isPermissionsCustom, resolveAgentPermissionsDraft, resolveCommandModeFromRole, + resolvePresetDefaultsForRole, resolveRoleForCommandMode, resolveToolGroupOverrides, resolveToolGroupStateFromConfigEntry, @@ -20,6 +21,14 @@ describe("agentPermissionsOperation", () => { expect(resolveCommandModeFromRole("autonomous")).toBe("auto"); }); + it("resolves autonomous preset defaults to permissive capabilities", () => { + expect(resolvePresetDefaultsForRole("autonomous")).toEqual({ + commandMode: "auto", + webAccess: true, + fileTools: true, + }); + }); + it("derives tool-group state from allow and deny with deny precedence", () => { const state = resolveToolGroupStateFromConfigEntry({ allow: ["group:web", "group:runtime"], diff --git a/tests/unit/agentSettingsMutationWorkflow.test.ts b/tests/unit/agentSettingsMutationWorkflow.test.ts index 0b0832e..1a8f681 100644 --- a/tests/unit/agentSettingsMutationWorkflow.test.ts +++ b/tests/unit/agentSettingsMutationWorkflow.test.ts @@ -32,6 +32,14 @@ describe("agentSettingsMutationWorkflow", () => { { kind: "install-skill", agentId: "agent-1", skillKey: "browser" }, createContext({ status: "disconnected" }) ); + const allowlistResult = planAgentSettingsMutation( + { kind: "set-skills-allowlist", agentId: "agent-1" }, + createContext({ status: "disconnected" }) + ); + const globalToggleResult = planAgentSettingsMutation( + { kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: "browser" }, + createContext({ status: "disconnected" }) + ); const removeResult = planAgentSettingsMutation( { kind: "remove-skill", agentId: "agent-1", skillKey: "browser" }, createContext({ status: "disconnected" }) @@ -55,6 +63,18 @@ describe("agentSettingsMutationWorkflow", () => { message: null, guardReason: "not-connected", }); + expect(allowlistResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(globalToggleResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); expect(removeResult).toEqual({ kind: "deny", reason: "start-guard-deny", @@ -146,6 +166,10 @@ describe("agentSettingsMutationWorkflow", () => { { kind: "save-skill-api-key", agentId: "agent-1", skillKey: " " }, createContext() ); + const globalToggleResult = planAgentSettingsMutation( + { kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: " " }, + createContext() + ); const removeResult = planAgentSettingsMutation( { kind: "remove-skill", agentId: "agent-1", skillKey: " " }, createContext() @@ -161,10 +185,27 @@ describe("agentSettingsMutationWorkflow", () => { reason: "missing-skill-key", message: null, }); + expect(globalToggleResult).toEqual({ + kind: "deny", + reason: "missing-skill-key", + message: null, + }); expect(removeResult).toEqual({ kind: "deny", reason: "missing-skill-key", message: null, }); }); + + it("allows_setting_skills_allowlist_with_normalized_agent_id", () => { + const result = planAgentSettingsMutation( + { kind: "set-skills-allowlist", agentId: " agent-1 " }, + createContext() + ); + + expect(result).toEqual({ + kind: "allow", + normalizedAgentId: "agent-1", + }); + }); }); diff --git a/tests/unit/agentSettingsPanel.test.ts b/tests/unit/agentSettingsPanel.test.ts index fc85860..e718e4f 100644 --- a/tests/unit/agentSettingsPanel.test.ts +++ b/tests/unit/agentSettingsPanel.test.ts @@ -1,6 +1,6 @@ -import { createElement } from "react"; +import { createElement, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { AgentState } from "@/features/agents/state/store"; import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels"; import type { CronJobSummary } from "@/lib/cron/types"; @@ -103,6 +103,22 @@ const createSkillsReport = (): SkillStatusReport => ({ ], }); +const createSkillsReportWithOsIncompatibleBrowser = (): SkillStatusReport => { + const report = createSkillsReport(); + return { + ...report, + skills: report.skills.map((entry) => + entry.skillKey === "browser" + ? { + ...entry, + requirements: { ...entry.requirements, os: ["darwin"] }, + missing: { ...entry.missing, os: ["darwin"] }, + } + : entry + ), + }; +}; + describe("AgentSettingsPanel", () => { afterEach(() => { cleanup(); @@ -371,9 +387,8 @@ describe("AgentSettingsPanel", () => { expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument(); }); - it("renders_skills_mode_and_runs_bulk_actions", () => { - const onUseAllSkills = vi.fn(); - const onDisableAllSkills = vi.fn(); + it("renders_skills_mode_and_opens_system_setup_for_non_ready_skills", () => { + const onOpenSystemSetup = vi.fn(); render( createElement(AgentSettingsPanel, { agent: createAgent(), @@ -390,19 +405,17 @@ describe("AgentSettingsPanel", () => { onRunCronJob: vi.fn(), onDeleteCronJob: vi.fn(), skillsReport: createSkillsReport(), - onUseAllSkills, - onDisableAllSkills, + skillsAllowlist: ["github"], + onOpenSystemSetup, }) ); expect(screen.getByTestId("agent-settings-skills")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Use all" })); - fireEvent.click(screen.getByRole("button", { name: "Disable all" })); - expect(onUseAllSkills).toHaveBeenCalledTimes(1); - expect(onDisableAllSkills).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Open System Setup" })); + expect(onOpenSystemSetup).toHaveBeenCalledWith("browser"); }); - it("hides_bundled_blocked_skills_by_default_and_can_show_them", () => { + it("shows_selected_mode_hint_when_allowlist_mode_is_active", () => { render( createElement(AgentSettingsPanel, { agent: createAgent(), @@ -419,42 +432,11 @@ describe("AgentSettingsPanel", () => { onRunCronJob: vi.fn(), onDeleteCronJob: vi.fn(), skillsReport: createSkillsReport(), + skillsAllowlist: ["github"], }) ); - expect(screen.queryByText("browser")).not.toBeInTheDocument(); - - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); - fireEvent.click(screen.getByText("Built-in Skills")); - - expect(screen.getByText("browser")).toBeInTheDocument(); - }); - - it("renders_explicit_missing_and_reason_lines_for_blocked_skills", () => { - render( - createElement(AgentSettingsPanel, { - agent: createAgent(), - mode: "skills", - onClose: vi.fn(), - onDelete: vi.fn(), - onToolCallingToggle: vi.fn(), - onThinkingTracesToggle: vi.fn(), - cronJobs: [], - cronLoading: false, - cronError: null, - cronRunBusyJobId: null, - cronDeleteBusyJobId: null, - onRunCronJob: vi.fn(), - onDeleteCronJob: vi.fn(), - skillsReport: createSkillsReport(), - }) - ); - - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); - fireEvent.click(screen.getByText("Built-in Skills")); - - expect(screen.getByText("Missing tools: playwright")).toBeInTheDocument(); - expect(screen.getByText("Reason: disabled, blocked by allowlist, missing tools")).toBeInTheDocument(); + expect(screen.getByText("This agent is using selected skills only.")).toBeInTheDocument(); }); it("filters_skills_list_from_search_input", () => { @@ -477,11 +459,9 @@ describe("AgentSettingsPanel", () => { }) ); - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); fireEvent.change(screen.getByLabelText("Search skills"), { target: { value: "browse" }, }); - fireEvent.click(screen.getByText("Built-in Skills")); expect(screen.getByText("browser")).toBeInTheDocument(); expect(screen.queryByText("github")).not.toBeInTheDocument(); @@ -510,9 +490,6 @@ describe("AgentSettingsPanel", () => { }) ); - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); - fireEvent.click(screen.getByText("Workspace Skills")); - fireEvent.click(screen.getByText("Built-in Skills")); const githubToggle = screen.getByRole("switch", { name: "Skill github" }); const browserToggle = screen.getByRole("switch", { name: "Skill browser" }); expect(githubToggle).toHaveAttribute("aria-checked", "true"); @@ -524,14 +501,15 @@ describe("AgentSettingsPanel", () => { expect(onSetSkillEnabled).toHaveBeenNthCalledWith(2, "browser", true); }); - it("runs_install_and_api_key_actions", () => { + it("runs_system_setup_actions_from_modal", () => { const onInstallSkill = vi.fn(); + const onSetSkillGlobalEnabled = vi.fn(); const onSkillApiKeyChange = vi.fn(); const onSaveSkillApiKey = vi.fn(); render( createElement(AgentSettingsPanel, { agent: createAgent(), - mode: "skills", + mode: "system", onClose: vi.fn(), onDelete: vi.fn(), onToolCallingToggle: vi.fn(), @@ -546,30 +524,77 @@ describe("AgentSettingsPanel", () => { skillsReport: createSkillsReport(), skillApiKeyDrafts: { browser: "seed-key" }, onInstallSkill, + onSetSkillGlobalEnabled, onSkillApiKeyChange, onSaveSkillApiKey, }) ); - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); - fireEvent.click(screen.getByText("Built-in Skills")); + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "browse" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); + expect(screen.getByRole("dialog", { name: "Setup browser" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Install playwright" })); + fireEvent.click(screen.getByRole("button", { name: "Enable globally" })); fireEvent.change(screen.getByLabelText("API key for browser"), { target: { value: "test-key" }, }); fireEvent.click(screen.getByRole("button", { name: "Save BROWSER_API_KEY" })); expect(onInstallSkill).toHaveBeenCalledWith("browser", "browser", "install-playwright"); + expect(onSetSkillGlobalEnabled).toHaveBeenCalledWith("browser", true); expect(onSkillApiKeyChange).toHaveBeenCalledWith("browser", "test-key"); expect(onSaveSkillApiKey).toHaveBeenCalledWith("browser"); }); + it("keeps_system_setup_modal_open_until_user_closes_and_then_clears_handoff", async () => { + const onSystemInitialSkillHandled = vi.fn(); + const Harness = () => { + const [initialSkillKey, setInitialSkillKey] = useState("browser"); + return createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "system", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + systemInitialSkillKey: initialSkillKey, + onSystemInitialSkillHandled: () => { + onSystemInitialSkillHandled(); + setInitialSkillKey(null); + }, + }); + }; + + render(createElement(Harness)); + + const dialog = screen.getByRole("dialog", { name: "Setup browser" }); + expect(onSystemInitialSkillHandled).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(onSystemInitialSkillHandled).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole("dialog", { name: "Setup browser" })).not.toBeInTheDocument(); + }); + it("prompts_before_removing_skill_files_and_confirms_action", () => { const onRemoveSkill = vi.fn(); + vi.spyOn(window, "confirm").mockReturnValue(true); render( createElement(AgentSettingsPanel, { agent: createAgent(), - mode: "skills", + mode: "system", onClose: vi.fn(), onDelete: vi.fn(), onToolCallingToggle: vi.fn(), @@ -586,11 +611,11 @@ describe("AgentSettingsPanel", () => { }) ); - fireEvent.click(screen.getByText("Workspace Skills")); - fireEvent.click(screen.getByRole("button", { name: "Remove skill github" })); - - expect(screen.getByRole("dialog", { name: "Remove skill github" })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Remove skill" })); + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "git" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); + fireEvent.click(screen.getByRole("button", { name: "Remove skill from gateway" })); expect(onRemoveSkill).toHaveBeenCalledWith({ skillKey: "github", @@ -603,7 +628,7 @@ describe("AgentSettingsPanel", () => { render( createElement(AgentSettingsPanel, { agent: createAgent(), - mode: "skills", + mode: "system", onClose: vi.fn(), onDelete: vi.fn(), onToolCallingToggle: vi.fn(), @@ -619,8 +644,10 @@ describe("AgentSettingsPanel", () => { }) ); - fireEvent.click(screen.getByLabelText("Hide bundled + blocked")); - fireEvent.click(screen.getByText("Built-in Skills")); + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "browse" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); expect(screen.getByRole("button", { name: "Save BROWSER_API_KEY" })).toBeDisabled(); }); @@ -649,6 +676,40 @@ describe("AgentSettingsPanel", () => { expect(screen.getByText("1/2")).toBeInTheDocument(); }); + it("shows_os_incompatible_skills_for_visibility_in_agent_view", () => { + const report = createSkillsReportWithOsIncompatibleBrowser(); + report.skills = report.skills.map((entry) => + entry.skillKey === "browser" + ? { ...entry, disabled: false, blockedByAllowlist: false } + : entry + ); + + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: report, + skillsAllowlist: ["github", "browser"], + }) + ); + + expect(screen.getByRole("switch", { name: "Skill browser" })).toBeDisabled(); + expect(screen.getByText("2/2")).toBeInTheDocument(); + expect(screen.getByText("Not supported")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open System Setup" })).not.toBeInTheDocument(); + }); + it("shows_skills_loading_and_error_states", () => { const { rerender } = render( createElement(AgentSettingsPanel, { diff --git a/tests/unit/createAgentBootstrapOperation.test.ts b/tests/unit/createAgentBootstrapOperation.test.ts index 7209668..1a5dd38 100644 --- a/tests/unit/createAgentBootstrapOperation.test.ts +++ b/tests/unit/createAgentBootstrapOperation.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it, vi } from "vitest"; -import { runCreateAgentBootstrapOperation } from "@/features/agents/operations/createAgentBootstrapOperation"; +import { + CREATE_AGENT_DEFAULT_PERMISSIONS, + runCreateAgentBootstrapOperation, +} from "@/features/agents/operations/createAgentBootstrapOperation"; describe("createAgentBootstrapOperation", () => { + it("exports_autonomous_create_defaults", () => { + expect(CREATE_AGENT_DEFAULT_PERMISSIONS).toEqual({ + commandMode: "auto", + webAccess: true, + fileTools: true, + }); + }); + it("retries load and lookup once before unresolved-created-agent disposition", async () => { const loadAgents = vi.fn(async () => undefined); const findAgentById = vi.fn(() => null); diff --git a/tests/unit/settingsRouteWorkflow.test.ts b/tests/unit/settingsRouteWorkflow.test.ts index 947de08..e6eb0f6 100644 --- a/tests/unit/settingsRouteWorkflow.test.ts +++ b/tests/unit/settingsRouteWorkflow.test.ts @@ -120,6 +120,24 @@ describe("settingsRouteWorkflow", () => { ]); }); + it("changes from skills to system without discard confirmation", () => { + expect( + planSettingsTabChangeCommands({ + nextTab: "system", + currentInspectSidebar: { agentId: "agent-1", tab: "skills" }, + settingsRouteAgentId: "agent-1", + settingsRouteActive: true, + personalityHasUnsavedChanges: true, + discardConfirmed: false, + }) + ).toEqual([ + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-1", tab: "system" }, + }, + ]); + }); + it("plans route-agent synchronization commands", () => { expect( planSettingsRouteSyncCommands({ diff --git a/tests/unit/skillsGatewayClient.test.ts b/tests/unit/skillsGatewayClient.test.ts index 9e9148a..86ccb57 100644 --- a/tests/unit/skillsGatewayClient.test.ts +++ b/tests/unit/skillsGatewayClient.test.ts @@ -93,6 +93,27 @@ describe("skills gateway client", () => { expect(result).toBe(response); }); + it("updates global enabled state through skills.update", async () => { + const response = { + ok: true, + skillKey: "browser", + config: {}, + }; + const client = { + call: vi.fn(async () => response), + } as unknown as GatewayClient; + + await updateSkill(client, { + skillKey: " browser ", + enabled: false, + }); + + expect(client.call).toHaveBeenCalledWith("skills.update", { + skillKey: "browser", + enabled: false, + }); + }); + it("fails fast when skill key is empty for updates", async () => { const client = { call: vi.fn(), diff --git a/tests/unit/skillsPresentation.test.ts b/tests/unit/skillsPresentation.test.ts index 220692c..a7e6b7e 100644 --- a/tests/unit/skillsPresentation.test.ts +++ b/tests/unit/skillsPresentation.test.ts @@ -2,12 +2,19 @@ import { describe, expect, it } from "vitest"; import type { SkillStatusEntry } from "@/lib/skills/types"; import { + deriveAgentSkillDisplayState, + buildAgentSkillsAllowlistSet, buildSkillMissingDetails, buildSkillReasons, canRemoveSkill, + deriveAgentSkillsAccessMode, + deriveSkillReadinessState, + filterOsCompatibleSkills, groupSkillsBySource, hasInstallableMissingBinary, isBundledBlockedSkill, + isSkillOsIncompatible, + normalizeAgentSkillsAllowlist, resolvePreferredInstallOption, } from "@/lib/skills/presentation"; @@ -70,7 +77,7 @@ describe("skills presentation helpers", () => { "Missing one-of tools (install any): chromium | chrome", "Missing env vars (set in gateway env): GITHUB_TOKEN", "Missing config values (set in openclaw.json): browser.enabled", - "Unsupported OS: linux", + "Requires OS: Linux", ]); }); @@ -93,6 +100,48 @@ describe("skills presentation helpers", () => { expect(reasons).toEqual(["disabled", "blocked by allowlist", "missing tools"]); }); + it("detects_os_incompatibility_from_missing_os_requirements", () => { + expect( + isSkillOsIncompatible( + createSkill({ + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: ["darwin"], + }, + }) + ) + ).toBe(true); + expect( + isSkillOsIncompatible( + createSkill({ + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [" "], + }, + }) + ) + ).toBe(false); + }); + + it("filters_out_os_incompatible_skills_while_preserving_order", () => { + const filtered = filterOsCompatibleSkills([ + createSkill({ name: "github", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }), + createSkill({ + name: "apple-notes", + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + }), + createSkill({ name: "slack", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }), + ]); + + expect(filtered.map((skill) => skill.name)).toEqual(["github", "slack"]); + }); + it("detects bundled blocked skills", () => { expect( isBundledBlockedSkill( @@ -169,4 +218,57 @@ describe("skills presentation helpers", () => { ); expect(canRemoveSkill(createSkill({ source: "openclaw-extra" }))).toBe(false); }); + + it("derives agent access mode from allowlist shape", () => { + expect(deriveAgentSkillsAccessMode(undefined)).toBe("all"); + expect(deriveAgentSkillsAccessMode([])).toBe("none"); + expect(deriveAgentSkillsAccessMode([" ", "github"])).toBe("selected"); + }); + + it("normalizes allowlist values and creates a lookup set", () => { + expect(normalizeAgentSkillsAllowlist([" github ", "github", "slack", " "])).toEqual([ + "github", + "slack", + ]); + expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("github")).toBe(true); + expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("browser")).toBe(false); + }); + + it("classifies readiness with disabled and unavailable precedence", () => { + expect(deriveSkillReadinessState(createSkill({ disabled: true, eligible: false }))).toBe( + "disabled-globally" + ); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + }) + ) + ).toBe("unavailable"); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + blockedByAllowlist: true, + }) + ) + ).toBe("unavailable"); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] }, + }) + ) + ).toBe("needs-setup"); + expect(deriveSkillReadinessState(createSkill({ eligible: true }))).toBe("ready"); + }); + + it("maps readiness into agent display states", () => { + expect(deriveAgentSkillDisplayState("ready")).toBe("ready"); + expect(deriveAgentSkillDisplayState("needs-setup")).toBe("setup-required"); + expect(deriveAgentSkillDisplayState("disabled-globally")).toBe("setup-required"); + expect(deriveAgentSkillDisplayState("unavailable")).toBe("not-supported"); + }); }); diff --git a/tests/unit/useAgentSettingsMutationController.test.ts b/tests/unit/useAgentSettingsMutationController.test.ts index 1592f9f..16fc99c 100644 --- a/tests/unit/useAgentSettingsMutationController.test.ts +++ b/tests/unit/useAgentSettingsMutationController.test.ts @@ -408,7 +408,9 @@ describe("useAgentSettingsMutationController", () => { expect(ctx.getValue().hasRestartBlockInProgress).toBe(true); }); - expect(restartBlockHookParams?.block).not.toBeNull(); + await waitFor(() => { + expect(restartBlockHookParams?.block).not.toBeNull(); + }); await act(async () => { restartBlockHookParams?.onTimeout(); @@ -470,6 +472,25 @@ describe("useAgentSettingsMutationController", () => { }); }); + it("loads_skills_when_settings_system_tab_is_active", async () => { + const report = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + mockedLoadAgentSkillStatus.mockResolvedValue(report); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "system", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1"); + expect(ctx.getValue().settingsSkillsReport).toEqual(report); + }); + }); + it("use_all_and_disable_all_skills_write_via_config_queue", async () => { const ctx = renderController(); @@ -494,6 +515,40 @@ describe("useAgentSettingsMutationController", () => { expect(mockedLoadAgentSkillStatus).not.toHaveBeenCalled(); }); + it("sets_selected_skills_allowlist_via_config_queue", async () => { + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" github ", "slack", "github"]); + }); + + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-agent-skills" }) + ); + expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + mode: "allowlist", + skillNames: ["github", "slack"], + }) + ); + expect(ctx.loadAgents).toHaveBeenCalledTimes(1); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + }); + + it("rejects_empty_selected_skills_allowlist_before_gateway_call", async () => { + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" ", ""]); + }); + + expect(mockedUpdateGatewayAgentSkillsAllowlist).not.toHaveBeenCalled(); + expect(ctx.getValue().settingsSkillsError).toBe( + "Cannot set selected skills mode: choose at least one skill." + ); + }); + it("installs_skill_dependencies_with_per_skill_busy_and_message_state", async () => { mockedLoadAgentSkillStatus.mockResolvedValue({ workspaceDir: "/tmp/workspace", @@ -530,6 +585,29 @@ describe("useAgentSettingsMutationController", () => { expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2); }); + it("refreshes_skills_after_system_setup_mutation_when_system_tab_is_active", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "system", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser"); + }); + + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2); + }); + it("removes_skill_files_with_per_skill_busy_and_message_state", async () => { mockedLoadAgentSkillStatus.mockResolvedValue({ workspaceDir: "/tmp/workspace", @@ -563,6 +641,9 @@ describe("useAgentSettingsMutationController", () => { await waitFor(() => { expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); }); + await waitFor(() => { + expect(ctx.getValue().settingsSkillsReport?.workspaceDir).toBe("/tmp/workspace"); + }); await act(async () => { await ctx.getValue().handleRemoveSkill("agent-1", { @@ -627,6 +708,40 @@ describe("useAgentSettingsMutationController", () => { }); }); + it("toggles_global_skill_enabled_via_skill_update", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await ctx.getValue().handleSetSkillGlobalEnabled("agent-1", "browser", false); + }); + + expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), { + skillKey: "browser", + enabled: false, + }); + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-skill-setup" }) + ); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "success", + message: "Skill disabled globally", + }); + }); + it("preserves_api_key_draft_and_sets_error_message_when_save_fails", async () => { mockedLoadAgentSkillStatus.mockResolvedValue({ workspaceDir: "/tmp/workspace", @@ -746,6 +861,23 @@ describe("useAgentSettingsMutationController", () => { configChecks: [], install: [], }, + { + name: "apple-notes", + description: "", + source: "openclaw-managed", + bundled: false, + filePath: "/tmp/skills/apple-notes/SKILL.md", + baseDir: "/tmp/skills/apple-notes", + skillKey: "apple-notes", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + configChecks: [], + install: [], + }, ], }); const ctx = renderController({ @@ -755,7 +887,7 @@ describe("useAgentSettingsMutationController", () => { }); await waitFor(() => { - expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(3); + expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(4); }); await act(async () => { diff --git a/tests/unit/useSettingsRouteController.test.ts b/tests/unit/useSettingsRouteController.test.ts index 9951477..6dc8eac 100644 --- a/tests/unit/useSettingsRouteController.test.ts +++ b/tests/unit/useSettingsRouteController.test.ts @@ -353,4 +353,24 @@ describe("useSettingsRouteController", () => { tab: "skills", }); }); + + it("switches to system tab without discard prompt when leaving skills", () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + inspectSidebar: { agentId: "agent-1", tab: "skills" }, + activeTab: "skills" satisfies SettingsRouteTab, + personalityHasUnsavedChanges: true, + }); + + act(() => { + ctx.getValue().handleSettingsRouteTabChange("system"); + }); + + expect(ctx.confirmDiscard).not.toHaveBeenCalled(); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "system", + }); + }); });