reliability and skills junk

This commit is contained in:
George Pickett
2026-02-27 13:38:53 -08:00
parent 7786f92d3b
commit 226ca5c0ee
23 changed files with 1820 additions and 488 deletions
+1 -1
View File
@@ -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: <CODE> ...`) 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.
+5 -5
View File
@@ -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.
+4 -4
View File
@@ -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.
+277
View File
@@ -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 (`<workspace>/.agents/skills`, `agents-skills-project`)
6. Workspace skills (`<workspace>/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.<skillKey>.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.<skillKey>` 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=<agentId>`.
- `/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 <name>` 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.
+38 -16
View File
@@ -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<string | null>(null);
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(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}
@@ -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<string, { kind: "success" | "error"; message: string }>;
skillApiKeyDrafts?: Record<string, string>;
defaultAgentScopeWarning?: string | null;
systemInitialSkillKey?: string | null;
onSystemInitialSkillHandled?: () => void;
skillsAllowlist?: string[] | undefined;
onUseAllSkills?: () => Promise<void> | void;
onDisableAllSkills?: () => Promise<void> | void;
onSetSkillEnabled?: (skillName: string, enabled: boolean) => Promise<void> | void;
onOpenSystemSetup?: (skillKey?: string) => void;
onSetSkillGlobalEnabled?: (skillKey: string, enabled: boolean) => Promise<void> | void;
onInstallSkill?: (skillKey: string, name: string, installId: string) => Promise<void> | 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<string | null>(null);
const [cronDraft, setCronDraft] = useState<CronCreateDraft>(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" ? (
<section className="sidebar-section" data-testid="agent-settings-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">Skills</h3>
<div className="font-mono text-[10px] text-muted-foreground">
{usingAllowlist ? `${enabledSkillCount}/${skillEntries.length}` : `All (${skillEntries.length})`}
</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
Control which discovered skills this agent can use.
</div>
<div className="mt-1 text-[10px] text-muted-foreground/80">
Allowlist toggles affect this agent only. Install and API key setup affect gateway-wide
skill readiness.
</div>
{defaultAgentScopeWarning ? (
<div className="ui-alert-danger mt-2 rounded-md px-3 py-2 text-[10px]">
{defaultAgentScopeWarning}
</div>
) : null}
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
value={skillsFilter}
onChange={(event) => 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"
/>
<div className="flex items-center gap-2">
<button
type="button"
className="ui-btn-secondary px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-65"
disabled={skillsBusy || skillsLoading || Boolean(skillsBusyKey)}
onClick={() => {
void onUseAllSkills();
}}
>
Use all
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-65"
disabled={skillsBusy || skillsLoading || Boolean(skillsBusyKey)}
onClick={() => {
void onDisableAllSkills();
}}
>
Disable all
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<label className="inline-flex items-center gap-2 text-[10px] text-muted-foreground/85">
<input
type="checkbox"
aria-label="Hide bundled + blocked"
checked={hideBundledBlockedSkills}
onChange={(event) => setHideBundledBlockedSkills(event.target.checked)}
/>
<span>Hide bundled + blocked</span>
</label>
<div className="font-mono text-[10px] text-muted-foreground">
{filteredSkillEntries.length}/{skillEntries.length} shown
</div>
</div>
{hideBundledBlockedSkills && hiddenBundledBlockedCount > 0 ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
Hidden bundled + blocked: {hiddenBundledBlockedCount}
</div>
) : null}
{skillsLoading ? (
<div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div>
) : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredSkillEntries.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredSkillEntries.length > 0 ? (
<div className="mt-3 flex flex-col gap-3">
{filteredSkillGroups.map((group) => {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return (
<details
key={group.id}
className="rounded-md border border-border/60 bg-surface-1/65"
open={!collapsedByDefault}
>
<summary className="flex cursor-pointer items-center justify-between px-3 py-2 text-[11px]">
<span className="font-medium text-foreground/88">{group.label}</span>
<span className="font-mono text-[10px] text-muted-foreground">
{group.skills.length}
</span>
</summary>
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
{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;
<AgentSkillsPanel
skillsReport={skillsReport}
skillsLoading={skillsLoading}
skillsError={skillsError}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillsAllowlist={skillsAllowlist}
onSetSkillEnabled={onSetSkillEnabled}
onOpenSystemSetup={onOpenSystemSetup}
/>
) : null}
return (
<div
key={`${skill.source}:${skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">
{skill.name}
</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${
skill.eligible ? "ui-badge-status-running" : "ui-badge-status-error"
}`}
>
{skill.eligible ? "eligible" : "blocked"}
</span>
{skill.disabled ? (
<span className="ui-badge-status-error rounded px-1.5 py-0.5 font-mono text-[9px]">
disabled
</span>
) : null}
{skill.blockedByAllowlist ? (
<span className="ui-badge-status-error rounded px-1.5 py-0.5 font-mono text-[9px]">
allowlist block
</span>
) : null}
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">
{skill.description}
</div>
{missingDetails.map((line) => (
<div key={`${skill.skillKey}:${line}`} className="mt-1 text-[10px] text-muted-foreground/70">
{line}
</div>
))}
{reasons.length > 0 ? (
<div className="mt-1 text-[10px] text-muted-foreground/70">
Reason: {reasons.join(", ")}
</div>
) : null}
{message ? (
<div
className={`mt-1 text-[10px] ${
message.kind === "error" ? "ui-text-danger" : "ui-text-success"
}`}
>
{message.message}
</div>
) : null}
</div>
<div className="flex w-full flex-col items-stretch gap-2 sm:w-[240px]">
<div className="flex items-center justify-between gap-2">
<button
type="button"
role="switch"
aria-label={`Skill ${skill.name}`}
aria-checked={enabled}
className={`ui-switch self-start ${enabled ? "ui-switch--on" : ""}`}
disabled={anySkillBusy}
onClick={() => {
void onSetSkillEnabled(skill.name, !enabled);
}}
>
<span className="ui-switch-thumb" />
</button>
{canDeleteSkill ? (
<button
type="button"
aria-label={`Remove skill ${skill.name}`}
className="ui-btn-icon ui-btn-icon-danger h-7 w-7 bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
disabled={anySkillBusy}
onClick={() => {
setPendingSkillRemoval({
skillKey: skill.skillKey,
name: skill.name,
source: skill.source,
baseDir: skill.baseDir,
});
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{installOption ? (
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
}}
>
{busyForSkill ? "Working..." : installOption.label}
</button>
) : null}
{skill.primaryEnv ? (
<>
<input
type="password"
value={apiKeyDraft}
onChange={(event) => {
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}`}
/>
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy || !hasApiKeyDraft}
onClick={() => {
if (!hasApiKeyDraft) {
return;
}
void onSaveSkillApiKey(skill.skillKey);
}}
>
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
</button>
</>
) : null}
</div>
</div>
);
})}
</div>
</details>
);
})}
</div>
) : null}
</section>
{mode === "system" ? (
<SystemSkillsPanel
skillsReport={skillsReport}
skillsLoading={skillsLoading}
skillsError={skillsError}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillMessages={skillMessages}
skillApiKeyDrafts={skillApiKeyDrafts}
defaultAgentScopeWarning={defaultAgentScopeWarning}
initialSkillKey={systemInitialSkillKey}
onInitialSkillKeyHandled={onSystemInitialSkillHandled}
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
onInstallSkill={onInstallSkill}
onRemoveSkill={onRemoveSkill}
onSkillApiKeyChange={onSkillApiKeyChange}
onSaveSkillApiKey={onSaveSkillApiKey}
/>
) : null}
{mode === "automations" ? (
@@ -1191,82 +928,6 @@ export const AgentSettingsPanel = ({
</>
) : null}
</div>
{pendingSkillRemoval ? (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
role="dialog"
aria-modal="true"
aria-label={`Remove skill ${pendingSkillRemoval.name}`}
onClick={() => {
setPendingSkillRemoval(null);
}}
>
<div
className="ui-panel w-full max-w-xl bg-card shadow-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
Remove skill files
</div>
<div className="mt-1 text-base font-semibold text-foreground">
Remove {pendingSkillRemoval.name} from the gateway?
</div>
</div>
<button
type="button"
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
onClick={() => {
setPendingSkillRemoval(null);
}}
>
Close
</button>
</div>
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
<div>
This permanently removes this skill directory on the gateway host. This action cannot
be undone.
</div>
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-3 font-mono text-[10px] text-foreground/85">
<div>Source: {pendingSkillRemoval.source}</div>
<div className="mt-1 break-all">Path: {pendingSkillRemoval.baseDir}</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-5 pt-2">
<button
type="button"
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium"
onClick={() => {
setPendingSkillRemoval(null);
}}
>
Cancel
</button>
<button
type="button"
className="ui-btn-secondary ui-btn-danger px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={skillsBusy || Boolean(skillsBusyKey)}
onClick={() => {
const target = pendingSkillRemoval;
setPendingSkillRemoval(null);
if (!target) {
return;
}
void onRemoveSkill({
skillKey: target.skillKey,
source: target.source,
baseDir: target.baseDir,
});
}}
>
Remove skill
</button>
</div>
</div>
</div>
) : null}
{cronCreateOpen ? (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
@@ -0,0 +1,255 @@
"use client";
import { useMemo, useState } from "react";
import type { SkillStatusReport } from "@/lib/skills/types";
import {
buildAgentSkillsAllowlistSet,
buildSkillMissingDetails,
deriveAgentSkillDisplayState,
deriveAgentSkillsAccessMode,
deriveSkillReadinessState,
type AgentSkillDisplayState,
} from "@/lib/skills/presentation";
type SkillRowFilter = "all" | AgentSkillDisplayState;
type AgentSkillsPanelProps = {
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillsAllowlist?: string[] | undefined;
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | 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<AgentSkillDisplayState, string> = {
ready: "Ready",
"setup-required": "Setup required",
"not-supported": "Not supported",
};
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
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<SkillRowFilter>("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<SkillRowFilter, number>
),
[searchedRows]
);
const enabledCount = useMemo(
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
[rows]
);
return (
<section className="sidebar-section" data-testid="agent-settings-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">Skills</h3>
<div className="font-mono text-[10px] text-muted-foreground">
{enabledCount}/{skillEntries.length}
</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
{accessMode === "selected" ? (
<div className="mt-2 text-[10px] text-muted-foreground/80">
This agent is using selected skills only.
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => 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"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{FILTERS.map((filter) => {
const selected = rowFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setRowFilter(filter.id);
}}
>
{filter.label} ({filterCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{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 (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
>
{statusLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.displayState !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveHint(entry.skill, entry.displayState)}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
<button
type="button"
role="switch"
aria-label={`Skill ${entry.skill.name}`}
aria-checked={entry.allowed}
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
disabled={switchDisabled}
onClick={() => {
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
}}
>
<span className="ui-switch-thumb" />
</button>
{canConfigureInSystem ? (
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
onClick={() => {
onOpenSystemSetup(entry.skill.skillKey);
}}
>
Open System Setup
</button>
) : null}
</div>
</div>
);
})}
</div>
) : null}
</section>
);
};
@@ -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> | void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | 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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
role="dialog"
aria-modal="true"
aria-label={`Setup ${skill.name}`}
onClick={onClose}
>
<div
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
System setup
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">{skill.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-2 text-[10px] text-muted-foreground/80">
Changes affect all agents on this gateway.
</div>
</div>
<button
type="button"
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
onClick={onClose}
>
Close
</button>
</div>
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
{defaultAgentScopeWarning ? (
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
{defaultAgentScopeWarning}
</div>
) : null}
<div>{skill.description}</div>
{skill.blockedByAllowlist ? (
<div className="text-[10px] text-muted-foreground/80">
Blocked by bundled skills policy (`skills.allowBundled`).
</div>
) : null}
{missingDetails.map((line) => (
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
{line}
</div>
))}
{skillMessage ? (
<div
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{skillMessage.message}
</div>
) : null}
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
{installOption ? (
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
}}
>
{busyForSkill ? "Working..." : installOption.label}
</button>
) : null}
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
}}
>
{busyForSkill
? "Working..."
: skill.disabled
? "Enable globally"
: "Disable globally"}
</button>
{skill.primaryEnv ? (
<>
<input
type="password"
value={apiKeyDraft}
onChange={(event) => {
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}`}
/>
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy || trimmedApiKey.length === 0}
onClick={() => {
if (trimmedApiKey.length === 0) {
return;
}
void onSaveSkillApiKey(skill.skillKey);
}}
>
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
</button>
</>
) : null}
{canDeleteSkill ? (
<button
type="button"
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
const approved = window.confirm(
`Remove ${skill.name} from the gateway? This affects all agents.`
);
if (!approved) {
return;
}
void onRemoveSkill({
skillKey: skill.skillKey,
source: skill.source,
baseDir: skill.baseDir,
});
onClose();
}}
>
Remove skill from gateway
</button>
) : null}
</div>
</div>
</div>
</div>
);
};
@@ -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<string, SkillSetupMessage>;
skillApiKeyDrafts?: Record<string, string>;
defaultAgentScopeWarning?: string | null;
initialSkillKey?: string | null;
onInitialSkillKeyHandled?: () => void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | 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<ReadinessFilter>("all");
const [setupSkillKey, setSetupSkillKey] = useState<string | null>(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<ReadinessFilter, number>
),
[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 (
<section className="sidebar-section" data-testid="agent-settings-system-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">System skill setup</h3>
<div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
Changes here affect all agents on this gateway.
</div>
{defaultAgentScopeWarning ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82">
{defaultAgentScopeWarning}
</div>
) : null}
{setupQueue.length > 0 ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
<div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div>
<div className="mt-2 flex flex-col gap-2">
{setupQueue.slice(0, 5).map((entry) => (
<div
key={`setup-queue:${entry.skill.skillKey}`}
className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85"
>
<span className="truncate">{entry.skill.name}</span>
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Set up
</button>
</div>
))}
</div>
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => 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"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{READINESS_FILTERS.map((filter) => {
const selected = readinessFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setReadinessFilter(filter.id);
}}
>
{filter.label} ({readinessCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{filteredRows.map((entry) => {
const readinessLabel = READINESS_LABELS[entry.readiness];
const readinessClassName = READINESS_CLASSES[entry.readiness];
const message = skillMessages[entry.skill.skillKey] ?? null;
return (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.readiness !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveReadinessHint(entry.skill, entry.readiness)}
</div>
) : null}
{message ? (
<div
className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{message.message}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-end gap-2 sm:w-[210px]">
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Configure
</button>
</div>
</div>
);
})}
</div>
) : null}
<AgentSkillsSetupModal
skill={selectedSetupSkill}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null}
apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""}
defaultAgentScopeWarning={defaultAgentScopeWarning}
onClose={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(null);
}}
onInstallSkill={onInstallSkill}
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
onRemoveSkill={onRemoveSkill}
onSkillApiKeyChange={onSkillApiKeyChange}
onSaveSkillApiKey={onSaveSkillApiKey}
/>
</section>
);
};
@@ -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"
@@ -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<AgentPermissionsDraft> =
Object.freeze(resolvePresetDefaultsForRole("autonomous"));
const resolveBootstrapErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message || "Failed to apply default permissions.";
@@ -2,6 +2,7 @@ export type SettingsRouteTab =
| "personality"
| "capabilities"
| "skills"
| "system"
| "automations"
| "advanced";
@@ -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<void>;
refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>;
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<void>;
}) => {
@@ -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,
};
}
+73 -1
View File
@@ -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<SkillSourceGroupId, "other">; 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<string, string> = {
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<string> =>
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;
@@ -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"],
@@ -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",
});
});
});
+124 -63
View File
@@ -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<string | null>("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, {
@@ -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);
+18
View File
@@ -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({
+21
View File
@@ -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(),
+103 -1
View File
@@ -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");
});
});
@@ -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 () => {
@@ -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",
});
});
});