feat: add Skill Workshop revision request

This commit is contained in:
Shakker
2026-06-04 00:00:32 +01:00
committed by Shakker
parent 179ff9b423
commit bf08234ee3
10 changed files with 258 additions and 4 deletions

View File

@@ -122,6 +122,34 @@ describe("lazy protocol validators", () => {
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
});
it("validates Skill Workshop revision request params", () => {
expect(
protocol.validateSkillsProposalRequestRevisionParams({
proposalId: "support-file-sampler-20260531-68207b7b7f",
instructions: "Make the support files 5",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-1",
}),
).toBe(true);
expect(
protocol.validateSkillsProposalRequestRevisionParams({
proposalId: "support-file-sampler-20260531-68207b7b7f",
instructions: "",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-1",
}),
).toBe(false);
expect(
protocol.validateSkillsProposalRequestRevisionParams({
proposalId: "support-file-sampler-20260531-68207b7b7f",
instructions: "Make the support files 5",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-1",
hiddenPrompt: "do not accept caller-provided hidden prompts",
}),
).toBe(false);
});
it("can still compile every exported protocol validator", () => {
const failures: string[] = [];
const validators: Array<[string, ProtocolValidator]> = [];

View File

@@ -385,6 +385,10 @@ import {
SkillsProposalInspectResultSchema,
type SkillsProposalRecordResult,
SkillsProposalRecordResultSchema,
type SkillsProposalRequestRevisionParams,
SkillsProposalRequestRevisionParamsSchema,
type SkillsProposalRequestRevisionResult,
SkillsProposalRequestRevisionResultSchema,
type SkillsProposalReviseParams,
SkillsProposalReviseParamsSchema,
type SkillsProposalUpdateParams,
@@ -785,6 +789,8 @@ export const validateSkillsProposalUpdateParams = lazyCompile<SkillsProposalUpda
export const validateSkillsProposalReviseParams = lazyCompile<SkillsProposalReviseParams>(
SkillsProposalReviseParamsSchema,
);
export const validateSkillsProposalRequestRevisionParams =
lazyCompile<SkillsProposalRequestRevisionParams>(SkillsProposalRequestRevisionParamsSchema);
export const validateSkillsProposalActionParams = lazyCompile<SkillsProposalActionParams>(
SkillsProposalActionParamsSchema,
);
@@ -1105,6 +1111,8 @@ export {
SkillsProposalCreateParamsSchema,
SkillsProposalUpdateParamsSchema,
SkillsProposalReviseParamsSchema,
SkillsProposalRequestRevisionParamsSchema,
SkillsProposalRequestRevisionResultSchema,
SkillsProposalActionParamsSchema,
SkillsProposalApplyResultSchema,
SkillsProposalRecordResultSchema,
@@ -1269,6 +1277,8 @@ export type {
SkillsProposalCreateParams,
SkillsProposalUpdateParams,
SkillsProposalReviseParams,
SkillsProposalRequestRevisionParams,
SkillsProposalRequestRevisionResult,
SkillsProposalActionParams,
SkillsProposalApplyResult,
SkillsProposalRecordResult,

View File

@@ -763,6 +763,28 @@ export const SkillsProposalReviseParamsSchema = Type.Object(
{ additionalProperties: false },
);
/** Starts an agent turn that revises a pending proposal from natural-language instructions. */
export const SkillsProposalRequestRevisionParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
instructions: Type.String({ minLength: 1, maxLength: 32_768 }),
sessionKey: NonEmptyString,
sessionId: Type.Optional(NonEmptyString),
idempotencyKey: NonEmptyString,
},
{ additionalProperties: false },
);
/** Chat-run acknowledgement returned after queueing a Skill Workshop revision request. */
export const SkillsProposalRequestRevisionResultSchema = Type.Object(
{
runId: NonEmptyString,
status: Type.Union([Type.Literal("started"), Type.Literal("in_flight"), Type.Literal("ok")]),
},
{ additionalProperties: true },
);
/** Shared approve/reject/quarantine action payload for one proposal. */
export const SkillsProposalActionParamsSchema = Type.Object(
{

View File

@@ -48,6 +48,8 @@ import {
SkillsProposalInspectParamsSchema,
SkillsProposalInspectResultSchema,
SkillsProposalRecordResultSchema,
SkillsProposalRequestRevisionParamsSchema,
SkillsProposalRequestRevisionResultSchema,
SkillsProposalReviseParamsSchema,
SkillsProposalUpdateParamsSchema,
SkillsProposalsListParamsSchema,
@@ -508,6 +510,8 @@ export const ProtocolSchemas = {
SkillsProposalCreateParams: SkillsProposalCreateParamsSchema,
SkillsProposalUpdateParams: SkillsProposalUpdateParamsSchema,
SkillsProposalReviseParams: SkillsProposalReviseParamsSchema,
SkillsProposalRequestRevisionParams: SkillsProposalRequestRevisionParamsSchema,
SkillsProposalRequestRevisionResult: SkillsProposalRequestRevisionResultSchema,
SkillsProposalActionParams: SkillsProposalActionParamsSchema,
SkillsProposalApplyResult: SkillsProposalApplyResultSchema,
SkillsProposalRecordResult: SkillsProposalRecordResultSchema,

View File

@@ -228,6 +228,8 @@ export type SkillsProposalInspectResult = SchemaType<"SkillsProposalInspectResul
export type SkillsProposalCreateParams = SchemaType<"SkillsProposalCreateParams">;
export type SkillsProposalUpdateParams = SchemaType<"SkillsProposalUpdateParams">;
export type SkillsProposalReviseParams = SchemaType<"SkillsProposalReviseParams">;
export type SkillsProposalRequestRevisionParams = SchemaType<"SkillsProposalRequestRevisionParams">;
export type SkillsProposalRequestRevisionResult = SchemaType<"SkillsProposalRequestRevisionResult">;
export type SkillsProposalActionParams = SchemaType<"SkillsProposalActionParams">;
export type SkillsProposalApplyResult = SchemaType<"SkillsProposalApplyResult">;
export type SkillsProposalRecordResult = SchemaType<"SkillsProposalRecordResult">;

View File

@@ -124,6 +124,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "skills.proposals.create", scope: "operator.admin" },
{ name: "skills.proposals.update", scope: "operator.admin" },
{ name: "skills.proposals.revise", scope: "operator.admin" },
{ name: "skills.proposals.requestRevision", scope: "operator.admin" },
{ name: "skills.proposals.apply", scope: "operator.admin" },
{ name: "skills.proposals.reject", scope: "operator.admin" },
{ name: "skills.proposals.quarantine", scope: "operator.admin" },

View File

@@ -452,6 +452,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
"skills.proposals.create",
"skills.proposals.update",
"skills.proposals.revise",
"skills.proposals.requestRevision",
"skills.proposals.apply",
"skills.proposals.reject",
"skills.proposals.quarantine",

View File

@@ -13,6 +13,7 @@ let envSnapshot: ReturnType<typeof captureEnv>;
let stateDir = "";
const mocks = vi.hoisted(() => ({
chatSend: vi.fn(),
workspaceDir: "",
}));
@@ -51,6 +52,12 @@ vi.mock("../../skills/security/clawhub-verdicts.js", () => ({
fetchOpenClawSkillSecurityVerdicts: vi.fn(),
}));
vi.mock("./chat.js", () => ({
chatHandlers: {
"chat.send": mocks.chatSend,
},
}));
const { skillsHandlers } = await import("./skills.js");
function callHandler(method: string, params: Record<string, unknown>) {
@@ -60,6 +67,10 @@ function callHandler(method: string, params: Record<string, unknown>) {
describe("skills proposal gateway handlers", () => {
beforeEach(async () => {
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
mocks.chatSend.mockReset();
mocks.chatSend.mockImplementation(async ({ respond }) => {
respond(true, { runId: "run-skill-workshop-revision", status: "started" }, undefined);
});
mocks.workspaceDir = await tempDirs.make("openclaw-skills-proposals-gateway-");
stateDir = await tempDirs.make("openclaw-skills-proposals-gateway-state-");
process.env.OPENCLAW_STATE_DIR = stateDir;
@@ -192,4 +203,76 @@ describe("skills proposal gateway handlers", () => {
expect((result.error as { code?: string }).code).toBe("INVALID_REQUEST");
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
});
it("starts revision chat turns with visible instructions and server-built context", async () => {
const create = await callHandler("skills.proposals.create", {
name: "Support File Sampler",
description: "Samples support files",
content: "# Support File Sampler\n\nSample support files.\n",
});
expect(create.ok).toBe(true);
const created = create.response as { record: { id: string } };
const result = await callHandler("skills.proposals.requestRevision", {
proposalId: created.record.id,
instructions: "Make the support files 5",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-1",
});
expect(result).toMatchObject({
ok: true,
response: { runId: "run-skill-workshop-revision", status: "started" },
});
expect(mocks.chatSend).toHaveBeenCalledTimes(1);
const forwarded = mocks.chatSend.mock.calls[0]?.[0] as {
params?: Record<string, unknown>;
req?: { method?: string; params?: Record<string, unknown> };
};
expect(forwarded.req?.method).toBe("chat.send");
expect(forwarded.params).toMatchObject({
agentId: "main",
deliver: false,
idempotencyKey: "revision-run-1",
message: "Make the support files 5",
sessionKey: "agent:main:session:skill-workshop",
});
expect(String(forwarded.params?.systemProvenanceReceipt)).toContain(
`Revise Skill Workshop proposal \`${created.record.id}\` (support-file-sampler).`,
);
expect(String(forwarded.params?.systemProvenanceReceipt)).toContain(
"Use `skill_workshop` with `action=inspect` first, then `action=revise`",
);
expect(String(forwarded.params?.systemProvenanceReceipt)).not.toContain(
"Make the support files 5",
);
});
it("does not start revision chat turns for non-pending proposals", async () => {
const create = await callHandler("skills.proposals.create", {
name: "Applied Sampler",
description: "Already applied proposal",
content: "# Applied Sampler\n\nSample support files.\n",
});
expect(create.ok).toBe(true);
const created = create.response as { record: { id: string } };
const apply = await callHandler("skills.proposals.apply", {
proposalId: created.record.id,
});
expect(apply.ok).toBe(true);
mocks.chatSend.mockClear();
const result = await callHandler("skills.proposals.requestRevision", {
proposalId: created.record.id,
instructions: "Make the support files 5",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-applied",
});
expect(result.ok).toBe(false);
expect((result.error as { message?: string }).message).toContain(
"Skill proposal is not pending",
);
expect(mocks.chatSend).not.toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@
* Small gateway-handler invocation harness for skills method tests.
*/
import { vi } from "vitest";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
import type { GatewayClient, GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
/** Captured JSON-RPC response tuple emitted by a gateway request handler. */
export type CapturedGatewayResponse = {
@@ -23,6 +23,10 @@ export async function callGatewayHandler(
handlers: GatewayRequestHandlers,
method: string,
params: Record<string, unknown>,
options: {
client?: GatewayClient | null;
context?: Partial<GatewayRequestContext>;
} = {},
): Promise<CapturedGatewayResponse> {
let ok: boolean | null = null;
let response: unknown;
@@ -33,12 +37,13 @@ export async function callGatewayHandler(
throw new Error(`unknown gateway handler: ${method}`);
}
const baseContext = makeGatewayHandlerTestContext();
await handler({
params,
req: {} as never,
client: null,
client: options.client ?? null,
isWebchatConnect: () => false,
context: makeGatewayHandlerTestContext(),
context: { ...baseContext, ...options.context } as GatewayRequestContext,
respond: (success, result, err) => {
ok = success;
response = result;

View File

@@ -9,6 +9,7 @@ import {
validateSkillsProposalActionParams,
validateSkillsProposalCreateParams,
validateSkillsProposalInspectParams,
validateSkillsProposalRequestRevisionParams,
validateSkillsProposalReviseParams,
validateSkillsProposalsListParams,
validateSkillsProposalUpdateParams,
@@ -57,7 +58,12 @@ import {
reviseSkillProposal,
} from "../../skills/workshop/service.js";
import { skillsUploadHandlers } from "./skills-upload.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import type {
GatewayRequestContext,
GatewayRequestHandlerOptions,
GatewayRequestHandlers,
RespondFn,
} from "./types.js";
import { assertValidParams, type Validator } from "./validation.js";
function resolveSkillsAgentWorkspace(params: unknown, context: GatewayRequestContext) {
@@ -111,6 +117,20 @@ function respondSkillWorkshopError(respond: RespondFn, err: unknown) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(err)));
}
function buildRevisionAgentInstruction(proposal: Awaited<ReturnType<typeof inspectSkillProposal>>) {
if (!proposal) {
return "";
}
return [
`Revise Skill Workshop proposal \`${proposal.record.id}\` (${proposal.record.target.skillKey}).`,
"",
"Use `skill_workshop` with `action=inspect` first, then `action=revise` for that pending proposal.",
"Do not apply, approve, reject, quarantine, or install the proposal.",
"",
"Requested changes:",
].join("\n");
}
const SKILL_PROPOSAL_RESPONSE_HANDLED = Symbol("skill proposal response handled");
async function runSkillsProposalWorkspaceHandler<TParams, TResult>(params: {
@@ -142,6 +162,38 @@ async function runSkillsProposalWorkspaceHandler<TParams, TResult>(params: {
}
}
async function forwardSkillWorkshopRevisionToChatSend(
opts: GatewayRequestHandlerOptions,
params: {
agentId: string;
idempotencyKey: string;
instructions: string;
proposal: NonNullable<Awaited<ReturnType<typeof inspectSkillProposal>>>;
sessionId?: string;
sessionKey: string;
},
): Promise<void> {
const { chatHandlers } = await import("./chat.js");
const chatSend = chatHandlers["chat.send"];
if (!chatSend) {
throw new Error("chat.send handler is unavailable");
}
const chatParams = {
sessionKey: params.sessionKey,
agentId: params.agentId,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
message: params.instructions,
deliver: false,
systemProvenanceReceipt: buildRevisionAgentInstruction(params.proposal),
idempotencyKey: params.idempotencyKey,
};
await chatSend({
...opts,
req: { ...opts.req, method: "chat.send", params: chatParams },
params: chatParams,
});
}
/** Gateway request handlers for skill status, catalogs, installs, updates, and workshop proposals. */
export const skillsHandlers: GatewayRequestHandlers = {
...skillsUploadHandlers,
@@ -370,6 +422,52 @@ export const skillsHandlers: GatewayRequestHandlers = {
}),
});
},
"skills.proposals.requestRevision": async (opts) => {
const { params, respond, context } = opts;
await runSkillsProposalWorkspaceHandler({
method: "skills.proposals.requestRevision",
rawParams: params,
respond,
context,
validate: validateSkillsProposalRequestRevisionParams,
run: async (parsedParams, resolved) => {
const proposal = await inspectSkillProposal(parsedParams.proposalId, {
workspaceDir: resolved.workspaceDir,
});
if (!proposal) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Skill proposal not found: ${parsedParams.proposalId}`,
),
);
return SKILL_PROPOSAL_RESPONSE_HANDLED;
}
if (proposal.record.status !== "pending") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Skill proposal is not pending: ${parsedParams.proposalId}`,
),
);
return SKILL_PROPOSAL_RESPONSE_HANDLED;
}
await forwardSkillWorkshopRevisionToChatSend(opts, {
agentId: resolved.agentId,
idempotencyKey: parsedParams.idempotencyKey,
instructions: parsedParams.instructions,
proposal,
sessionId: parsedParams.sessionId,
sessionKey: parsedParams.sessionKey,
});
return SKILL_PROPOSAL_RESPONSE_HANDLED;
},
});
},
"skills.proposals.apply": async ({ params, respond, context }) => {
await runSkillsProposalWorkspaceHandler({
method: "skills.proposals.apply",