First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
@@ -0,0 +1,908 @@
import { createElement, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
import type { CronRunResult } from "@/lib/cron/types";
import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { runCronJobNow, removeCronJob } from "@/lib/cron/types";
import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode";
import {
readGatewayAgentSkillsAllowlist,
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import { installSkill, loadAgentSkillStatus, updateSkill } from "@/lib/skills/types";
let restartBlockHookParams:
| {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}
| null = null;
vi.mock("@/features/agents/operations/useGatewayRestartBlock", () => ({
useGatewayRestartBlock: (params: {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}) => {
restartBlockHookParams = {
block: params.block,
onTimeout: params.onTimeout,
onRestartComplete: params.onRestartComplete,
};
},
}));
vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({
deleteAgentViaStudio: vi.fn(),
}));
vi.mock("@/features/agents/operations/cronCreateOperation", () => ({
performCronCreateFlow: vi.fn(),
}));
vi.mock("@/features/agents/operations/agentPermissionsOperation", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/agentPermissionsOperation")
>("@/features/agents/operations/agentPermissionsOperation");
return {
...actual,
updateAgentPermissionsViaStudio: vi.fn(),
};
});
vi.mock("@/features/agents/operations/mutationLifecycleWorkflow", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/mutationLifecycleWorkflow")
>("@/features/agents/operations/mutationLifecycleWorkflow");
return {
...actual,
runAgentConfigMutationLifecycle: vi.fn(),
};
});
vi.mock("@/lib/cron/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types");
return {
...actual,
runCronJobNow: vi.fn(),
removeCronJob: vi.fn(),
listCronJobs: vi.fn(async () => ({ jobs: [] })),
};
});
vi.mock("@/lib/gateway/gatewayReloadMode", () => ({
shouldAwaitDisconnectRestartForRemoteMutation: vi.fn(async () => false),
}));
vi.mock("@/lib/gateway/agentConfig", async () => {
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
"@/lib/gateway/agentConfig"
);
return {
...actual,
readGatewayAgentSkillsAllowlist: vi.fn(async () => undefined),
updateGatewayAgentSkillsAllowlist: vi.fn(async () => undefined),
};
});
vi.mock("@/lib/skills/types", () => ({
loadAgentSkillStatus: vi.fn(async () => ({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
})),
installSkill: vi.fn(async () => ({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
})),
updateSkill: vi.fn(async () => ({
ok: true,
skillKey: "browser",
config: {},
})),
}));
vi.mock("@/lib/skills/remove", () => ({
removeSkillFromGateway: vi.fn(async () => ({
removed: true,
removedPath: "/tmp/workspace/skills/browser",
source: "openclaw-workspace",
})),
}));
type ControllerValue = ReturnType<typeof useAgentSettingsMutationController>;
const draft: AgentPermissionsDraft = {
commandMode: "ask",
webAccess: true,
fileTools: false,
};
const createCronDraft = (): CronCreateDraft => ({
templateId: "custom",
name: "Nightly sync",
taskText: "Sync project status.",
scheduleKind: "every",
everyAmount: 30,
everyUnit: "minutes",
deliveryMode: "announce",
deliveryChannel: "last",
});
const renderController = (overrides?: Partial<Parameters<typeof useAgentSettingsMutationController>[0]>) => {
const setError = vi.fn();
const clearInspectSidebar = vi.fn();
const setInspectSidebarCapabilities = vi.fn();
const dispatchUpdateAgent = vi.fn();
const setMobilePaneChat = vi.fn();
const loadAgents = vi.fn(async () => undefined);
const refreshGatewayConfigSnapshot = vi.fn(async () => null);
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
await run();
});
const client = {
call: vi.fn(async () => ({})),
};
const params: Parameters<typeof useAgentSettingsMutationController>[0] = {
client: client as never,
status: "connected",
isLocalGateway: false,
agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never,
hasCreateBlock: false,
enqueueConfigMutation,
gatewayConfigSnapshot: null,
settingsRouteActive: false,
inspectSidebarAgentId: null,
inspectSidebarTab: null,
loadAgents,
refreshGatewayConfigSnapshot,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
setError,
...(overrides ?? {}),
};
const valueRef: { current: ControllerValue | null } = { current: null };
const Probe = ({ onValue }: { onValue: (next: ControllerValue) => void }) => {
const value = useAgentSettingsMutationController(params);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
render(
createElement(Probe, {
onValue: (next) => {
valueRef.current = next;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("hook value unavailable");
return valueRef.current;
},
setError,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
loadAgents,
refreshGatewayConfigSnapshot,
enqueueConfigMutation,
};
};
describe("useAgentSettingsMutationController", () => {
const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentViaStudio);
const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow);
const mockedRunCronJobNow = vi.mocked(runCronJobNow);
const mockedRemoveCronJob = vi.mocked(removeCronJob);
const mockedRunLifecycle = vi.mocked(runAgentConfigMutationLifecycle);
const mockedUpdateAgentPermissions = vi.mocked(updateAgentPermissionsViaStudio);
const mockedShouldAwaitRemoteRestart = vi.mocked(shouldAwaitDisconnectRestartForRemoteMutation);
const mockedReadGatewayAgentSkillsAllowlist = vi.mocked(readGatewayAgentSkillsAllowlist);
const mockedUpdateGatewayAgentSkillsAllowlist = vi.mocked(updateGatewayAgentSkillsAllowlist);
const mockedLoadAgentSkillStatus = vi.mocked(loadAgentSkillStatus);
const mockedInstallSkill = vi.mocked(installSkill);
const mockedRemoveSkillFromGateway = vi.mocked(removeSkillFromGateway);
const mockedUpdateSkill = vi.mocked(updateSkill);
beforeEach(() => {
restartBlockHookParams = null;
mockedDeleteAgentViaStudio.mockReset();
mockedPerformCronCreateFlow.mockReset();
mockedRunCronJobNow.mockReset();
mockedRemoveCronJob.mockReset();
mockedRunLifecycle.mockReset();
mockedUpdateAgentPermissions.mockReset();
mockedShouldAwaitRemoteRestart.mockReset();
mockedReadGatewayAgentSkillsAllowlist.mockReset();
mockedUpdateGatewayAgentSkillsAllowlist.mockReset();
mockedLoadAgentSkillStatus.mockReset();
mockedInstallSkill.mockReset();
mockedRemoveSkillFromGateway.mockReset();
mockedUpdateSkill.mockReset();
mockedShouldAwaitRemoteRestart.mockResolvedValue(false);
mockedReadGatewayAgentSkillsAllowlist.mockResolvedValue(undefined);
mockedUpdateGatewayAgentSkillsAllowlist.mockResolvedValue(undefined);
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
mockedInstallSkill.mockResolvedValue({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
});
mockedRemoveSkillFromGateway.mockResolvedValue({
removed: true,
removedPath: "/tmp/workspace/skills/browser",
source: "openclaw-workspace",
});
mockedUpdateSkill.mockResolvedValue({
ok: true,
skillKey: "browser",
config: {},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => {
const ctx = renderController({ status: "disconnected" });
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
});
it("reserved_main_delete_sets_error_and_skips_enqueue", async () => {
const ctx = renderController({
agents: [{ agentId: "main", name: "Main", sessionKey: "main-session" }] as never,
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("main");
});
expect(ctx.setError).toHaveBeenCalledWith("The main agent cannot be deleted.");
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("cron_delete_is_denied_while_run_busy_without_changing_error_state", async () => {
mockedRunCronJobNow.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { ok: true, ran: true } satisfies CronRunResult;
});
const ctx = renderController();
await act(async () => {
void ctx.getValue().handleRunCronJob("agent-1", "job-running");
});
await waitFor(() => {
expect(ctx.getValue().cronRunBusyJobId).toBe("job-running");
});
await act(async () => {
await ctx.getValue().handleDeleteCronJob("agent-1", "job-delete");
});
expect(mockedRemoveCronJob).not.toHaveBeenCalled();
expect(ctx.getValue().settingsCronError).toBeNull();
});
it("allowed_rename_and_delete_delegate_to_lifecycle_runner", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.setMutatingBlock();
await deps.executeMutation();
deps.clearBlock();
return true;
});
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedRunLifecycle).toHaveBeenCalledTimes(2);
expect(mockedDeleteAgentViaStudio).toHaveBeenCalledTimes(1);
});
it("permissions_update_keeps_load_refresh_and_focus_side_effects", async () => {
mockedUpdateAgentPermissions.mockResolvedValue(undefined);
const callOrder: string[] = [];
const ctx = renderController({
loadAgents: vi.fn(async () => {
callOrder.push("loadAgents");
}),
refreshGatewayConfigSnapshot: vi.fn(async () => {
callOrder.push("refresh");
return null;
}),
});
await act(async () => {
await ctx.getValue().handleUpdateAgentPermissions("agent-1", draft);
});
expect(mockedUpdateAgentPermissions).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "session-1",
draft,
})
);
expect(callOrder).toEqual(["loadAgents", "refresh"]);
expect(ctx.setInspectSidebarCapabilities).toHaveBeenCalledWith("agent-1");
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
});
it("exposes_restart_block_state_and_timeout_completion_handlers", async () => {
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await waitFor(() => {
expect(ctx.getValue().hasRenameMutationBlock).toBe(true);
expect(ctx.getValue().restartingMutationBlock?.phase).toBe("awaiting-restart");
expect(ctx.getValue().hasRestartBlockInProgress).toBe(true);
});
await waitFor(() => {
expect(restartBlockHookParams?.block).not.toBeNull();
});
await act(async () => {
restartBlockHookParams?.onTimeout();
});
expect(ctx.setError).toHaveBeenCalledWith("Gateway restart timed out after renaming the agent.");
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed Again");
});
await waitFor(() => {
expect(restartBlockHookParams?.block?.phase).toBe("awaiting-restart");
});
await act(async () => {
await restartBlockHookParams?.onRestartComplete(
restartBlockHookParams.block as MutationBlockState,
{ isCancelled: () => false }
);
});
expect(ctx.loadAgents).toHaveBeenCalled();
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
await waitFor(() => {
expect(ctx.getValue().restartingMutationBlock).toBeNull();
});
});
it("create_cron_handler_delegates_to_create_operation", async () => {
mockedPerformCronCreateFlow.mockResolvedValue("created");
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleCreateCronJob("agent-1", createCronDraft());
});
expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1);
});
it("loads_skills_when_settings_skills_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: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1");
expect(ctx.getValue().settingsSkillsReport).toEqual(report);
});
});
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();
await act(async () => {
await ctx.getValue().handleUseAllSkills("agent-1");
await ctx.getValue().handleDisableAllSkills("agent-1");
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-agent-skills" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ agentId: "agent-1", mode: "all" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ agentId: "agent-1", mode: "none" })
);
expect(ctx.loadAgents).toHaveBeenCalledTimes(2);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(2);
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",
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().handleInstallSkill("agent-1", "browser", "browser", "install-browser");
});
expect(mockedInstallSkill).toHaveBeenCalledWith(expect.anything(), {
name: "browser",
installId: "install-browser",
timeoutMs: 120000,
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.getValue().settingsSkillsBusyKey).toBeNull();
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "Installed",
});
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",
managedSkillsDir: "/tmp/skills",
skills: [
{
name: "browser",
description: "",
source: "openclaw-workspace",
bundled: false,
filePath: "/tmp/workspace/skills/browser/SKILL.md",
baseDir: "/tmp/workspace/skills/browser",
skillKey: "browser",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
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", {
skillKey: "browser",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/browser",
});
});
expect(mockedRemoveSkillFromGateway).toHaveBeenCalledWith({
skillKey: "browser",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/browser",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.getValue().settingsSkillsBusyKey).toBeNull();
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "Skill removed from gateway files",
});
});
it("saves_skill_api_key_via_config_queue_and_refreshes_skills", 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 () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), {
skillKey: "browser",
apiKey: "token-123",
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "API key saved",
});
});
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",
managedSkillsDir: "/tmp/skills",
skills: [],
});
mockedUpdateSkill.mockRejectedValue(new Error("invalid key"));
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123");
expect(ctx.getValue().settingsSkillsError).toBe("invalid key");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "error",
message: "invalid key",
});
});
it("rejects_empty_api_key_before_gateway_call", 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 () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", " ");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(mockedUpdateSkill).not.toHaveBeenCalled();
expect(ctx.getValue().settingsSkillsError).toBe("API key cannot be empty.");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "error",
message: "API key cannot be empty.",
});
});
it("disabling_one_skill_from_implicit_all_writes_explicit_allowlist", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [
{
name: "github",
description: "",
source: "shared",
bundled: false,
filePath: "/tmp/skills/github/SKILL.md",
baseDir: "/tmp/skills/github",
skillKey: "github",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
{
name: "browser",
description: "",
source: "bundled",
bundled: true,
filePath: "/tmp/skills/browser/SKILL.md",
baseDir: "/tmp/skills/browser",
skillKey: "browser",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
{
name: "slack",
description: "",
source: "shared",
bundled: false,
filePath: "/tmp/skills/slack/SKILL.md",
baseDir: "/tmp/skills/slack",
skillKey: "slack",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
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({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(4);
});
await act(async () => {
await ctx.getValue().handleSetSkillEnabled("agent-1", "browser", false);
});
expect(mockedReadGatewayAgentSkillsAllowlist).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "agent-1" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenLastCalledWith(
expect.objectContaining({
agentId: "agent-1",
mode: "allowlist",
skillNames: ["github", "slack"],
})
);
});
});