mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: add Skill Workshop revision request
This commit is contained in:
@@ -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]> = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user