Files
openclaw/src/plugins/hooks.skill-lifecycle.test.ts
Vincent Koc 14940edf15 feat(skills): add Skill Workshop lifecycle hooks (#115606)
* feat(skills): add lifecycle hook contracts

* feat(plugins): expose skill hook contracts

* feat(plugins): identify skill evaluators

* feat(skills): persist proposal evaluation lifecycle

* feat(skills): add agent evaluation action

* feat(skills): emit committed skill lifecycle changes

* feat(gateway): expose skill proposal evaluation lifecycle

* feat(ui): add Skill Workshop evaluations

* fix(skills): bind lifecycle state to proposal revisions

* fix(skills): preserve lifecycle events without artifacts

* feat(cli): evaluate skill proposals

* fix(ui): bind evaluations to proposal revisions

* docs(skills): document lifecycle hook primitives

* chore(plugin-sdk): refresh skill hook surface

* fix(skills): harden proposal evaluator execution

* fix(plugins): isolate skill evaluator inputs

* fix(cli): align skill lifecycle deadlines

* fix(skills): preserve evaluation replay invariants

* test(ui): capture Skill Workshop evaluation proof

* fix(skills): bind apply to evaluated target tree

* fix(skills): preserve evaluation contract edges

* fix(skills): bound evaluation event storage

* chore(skills): keep lifecycle helpers internal

* refactor(skills): isolate evaluation persistence

* fix(skills): satisfy lifecycle validation gates

* chore(protocol): refresh Skill Workshop clients

* docs: refresh Skill Workshop map

* chore: keep release notes in PR metadata

* docs: refresh merged docs map

* fix(ci): type Code Mode catch errors

* fix(skills): freeze lifecycle observation payloads

* fix(protocol): keep proposal inspect backward-decodable

* fix(skills): enforce final evaluator bundle limits

* fix(skills): preserve lifecycle caller attribution

* chore: drop subsumed Code Mode formatting

* test(plugins): adapt lifecycle hook mocks
2026-07-29 15:49:44 +08:00

342 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type {
PluginHookSkillChangedEvent,
PluginHookSkillContext,
PluginHookSkillProposalEvaluateEvent,
PluginHookSkillProposalChangedEvent,
} from "./hook-types.js";
import { createHookRunner } from "./hooks.js";
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
const ctx: PluginHookSkillContext = {
workspaceDir: "/tmp/openclaw-workspace",
agentId: "main",
};
const evaluationEvent: PluginHookSkillProposalEvaluateEvent = {
proposal: {
id: "proposal-1",
kind: "update",
revision: "v2",
revisionSha256: "sha256:revision",
targetCurrentSha256: "sha256:current",
},
skill: {
name: "Demo Skill",
skillKey: "demo-skill",
description: "Demonstrates proposal evaluation.",
source: "openclaw-workspace",
},
candidate: {
skillMd: {
path: "SKILL.md",
content: "---\nname: demo-skill\n---\n",
encoding: "utf8",
sha256: "sha256:candidate",
sizeBytes: 30,
},
files: [],
treeSha256: "sha256:candidate-tree",
},
baseline: {
skillMd: {
path: "SKILL.md",
content: "---\nname: demo-skill\n---\nold\n",
encoding: "utf8",
sha256: "sha256:baseline",
sizeBytes: 34,
},
files: [],
treeSha256: "sha256:baseline-tree",
},
reason: "revised",
};
describe("skill lifecycle hooks", () => {
it("collects every proposal evaluator with stable priority and plugin attribution", async () => {
const high = vi.fn(() => ({
summary: "candidate regressed",
metrics: { score: 0.4 },
evaluatorVersion: "rules-3",
decision: "block" as const,
decisionReason: "score below baseline",
}));
const low = vi.fn(() => undefined);
const registry = createMockPluginRegistry([
{
hookName: "skill_proposal_evaluate",
pluginId: "low",
priority: 10,
handler: low,
},
{
hookName: "skill_proposal_evaluate",
pluginId: "high",
priority: 100,
registrationId: "regression-score",
handler: high,
},
]);
const highPlugin = registry.plugins.find((plugin) => plugin.id === "high")!;
highPlugin.packageVersion = "2.1.0";
highPlugin.version = "runtime-override";
const outcomes = await createHookRunner(registry).runSkillProposalEvaluate(
evaluationEvent,
ctx,
);
expect(outcomes).toEqual([
{
evaluatorId: "regression-score",
pluginId: "high",
pluginVersion: "2.1.0",
status: "completed",
result: {
summary: "candidate regressed",
metrics: { score: 0.4 },
evaluatorVersion: "rules-3",
decision: "block",
decisionReason: "score below baseline",
},
},
{
evaluatorId: "low",
pluginId: "low",
status: "skipped",
},
]);
expect(high).toHaveBeenCalledTimes(1);
expect(low).toHaveBeenCalledTimes(1);
});
it("runs evaluators concurrently while retaining priority order", async () => {
let releaseHigh: (() => void) | undefined;
let releaseLow: (() => void) | undefined;
const highGate = new Promise<void>((resolve) => {
releaseHigh = resolve;
});
const lowGate = new Promise<void>((resolve) => {
releaseLow = resolve;
});
const high = vi.fn(async () => {
await highGate;
return { summary: "high" };
});
const low = vi.fn(async () => {
await lowGate;
return { summary: "low" };
});
const registry = createMockPluginRegistry([
{
hookName: "skill_proposal_evaluate",
pluginId: "low",
priority: 10,
handler: low,
},
{
hookName: "skill_proposal_evaluate",
pluginId: "high",
priority: 100,
handler: high,
},
]);
const evaluation = createHookRunner(registry).runSkillProposalEvaluate(evaluationEvent, ctx);
await vi.waitFor(() => {
expect(high).toHaveBeenCalledTimes(1);
expect(low).toHaveBeenCalledTimes(1);
});
releaseLow?.();
releaseHigh?.();
await expect(evaluation).resolves.toEqual([
{
evaluatorId: "high",
pluginId: "high",
status: "completed",
result: { summary: "high" },
},
{
evaluatorId: "low",
pluginId: "low",
status: "completed",
result: { summary: "low" },
},
]);
});
it("passes a frozen candidate snapshot to every evaluator", async () => {
const first = vi.fn((...args: unknown[]) => {
const event = args[0] as PluginHookSkillProposalEvaluateEvent;
expect(event).not.toBe(evaluationEvent);
expect(Object.isFrozen(event)).toBe(true);
expect(Object.isFrozen(event.candidate)).toBe(true);
expect(Object.isFrozen(event.candidate.skillMd)).toBe(true);
expect(() => {
event.candidate.skillMd.content = "mutated";
}).toThrow();
return { summary: "first" };
});
const second = vi.fn((...args: unknown[]) => {
const event = args[0] as PluginHookSkillProposalEvaluateEvent;
expect(event.candidate.skillMd.content).toBe(evaluationEvent.candidate.skillMd.content);
return { summary: "second" };
});
const registry = createMockPluginRegistry([
{ hookName: "skill_proposal_evaluate", pluginId: "first", handler: first },
{ hookName: "skill_proposal_evaluate", pluginId: "second", handler: second },
]);
await expect(
createHookRunner(registry).runSkillProposalEvaluate(evaluationEvent, ctx),
).resolves.toHaveLength(2);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("returns evaluator failures and timeouts as attributed outcomes", async () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const registry = createMockPluginRegistry([
{
hookName: "skill_proposal_evaluate",
pluginId: "throws",
priority: 100,
handler: () => {
throw new Error("scanner unavailable\nprivate detail");
},
},
{
hookName: "skill_proposal_evaluate",
pluginId: "hangs",
priority: 50,
timeoutMs: 1,
handler: () =>
new Promise<void>(() => {
// Intentionally never resolves so the hook timeout owns the outcome.
}),
},
]);
const outcomes = await createHookRunner(registry, { logger }).runSkillProposalEvaluate(
evaluationEvent,
ctx,
);
expect(outcomes).toEqual([
{
evaluatorId: "throws",
pluginId: "throws",
status: "error",
error: "scanner unavailable",
},
{
evaluatorId: "hangs",
pluginId: "hangs",
status: "error",
error: "timed out after 1ms",
},
]);
expect(logger.error).toHaveBeenCalledTimes(2);
});
it("dispatches committed skill changes as observation hooks", async () => {
const first = vi.fn();
const second = vi.fn();
const registry = createMockPluginRegistry([
{ hookName: "skill_changed", pluginId: "first", handler: first },
{ hookName: "skill_changed", pluginId: "second", handler: second },
]);
const event: PluginHookSkillChangedEvent = {
action: "removed",
source: "clawhub",
occurredAt: "2026-07-29T00:00:00.000Z",
before: {
name: "Demo Skill",
skillKey: "demo-skill",
skillFile: "/workspace/skills/demo-skill/SKILL.md",
skillDir: "/workspace/skills/demo-skill",
source: "clawhub",
revision: {
contentSha256: "sha256:content",
treeSha256: "sha256:tree",
sourceVersion: "1.2.3",
},
},
};
await createHookRunner(registry).runSkillChanged(event, ctx);
const observed = first.mock.calls[0]?.[0] as PluginHookSkillChangedEvent;
expect(observed).not.toBe(event);
expect(Object.isFrozen(observed)).toBe(true);
expect(Object.isFrozen(observed.before)).toBe(true);
expect(first).toHaveBeenCalledWith(observed, ctx);
expect(second).toHaveBeenCalledWith(observed, ctx);
});
it("freezes proposal observations so handlers cannot erase blocking outcomes", async () => {
const event: PluginHookSkillProposalChangedEvent = {
eventId: "event-1",
sequence: 1,
action: "evaluation_completed",
occurredAt: "2026-07-29T00:00:00.000Z",
proposal: {
id: "proposal-1",
kind: "update",
status: "pending",
revision: "v2",
revisionSha256: "sha256:revision",
skillName: "Demo Skill",
skillKey: "demo-skill",
skillFile: "/workspace/skills/demo-skill/SKILL.md",
},
evaluations: [
{
evaluatorId: "policy",
pluginId: "policy-plugin",
status: "completed",
result: {
decision: "block",
decisionReason: "Policy denied the candidate.",
},
},
],
};
const first = vi.fn((observed: PluginHookSkillProposalChangedEvent) => {
expect(observed).not.toBe(event);
expect(Object.isFrozen(observed)).toBe(true);
expect(Object.isFrozen(observed.evaluations)).toBe(true);
expect(Object.isFrozen(observed.evaluations?.[0])).toBe(true);
expect(() => {
(observed.evaluations as unknown[]).pop();
}).toThrow();
});
const second = vi.fn((observed: PluginHookSkillProposalChangedEvent) => {
expect(observed.evaluations).toHaveLength(1);
expect(observed.evaluations?.[0]).toMatchObject({
status: "completed",
result: { decision: "block" },
});
});
const registry = createMockPluginRegistry([
{
hookName: "skill_proposal_changed",
pluginId: "first",
handler: (...args: unknown[]) => first(args[0] as PluginHookSkillProposalChangedEvent),
},
{
hookName: "skill_proposal_changed",
pluginId: "second",
handler: (...args: unknown[]) => second(args[0] as PluginHookSkillProposalChangedEvent),
},
]);
await createHookRunner(registry).runSkillProposalChanged(event, ctx);
expect(event.evaluations).toHaveLength(1);
expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledOnce();
});
});