diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index f4a16ae65988..f8d063b38033 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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]> = []; diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index c1d0284a22a7..998fdedce958 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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( SkillsProposalReviseParamsSchema, ); +export const validateSkillsProposalRequestRevisionParams = + lazyCompile(SkillsProposalRequestRevisionParamsSchema); export const validateSkillsProposalActionParams = lazyCompile( 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, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index ea606ce6d130..6af9e7ccd2d7 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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( { diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 119b02903511..b2771eb980fa 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index b1d68f9410a1..171ebe6f1862 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -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">; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 6c52253b5765..c3e95eb90abd 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 73f086ebd894..24a9525f89ac 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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", diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index e995b686ac33..f41e6e8f068f 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -13,6 +13,7 @@ let envSnapshot: ReturnType; 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) { @@ -60,6 +67,10 @@ function callHandler(method: string, params: Record) { 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; + req?: { method?: string; params?: Record }; + }; + 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(); + }); }); diff --git a/src/gateway/server-methods/skills.test-helpers.ts b/src/gateway/server-methods/skills.test-helpers.ts index f7e54bbe3ec4..54cbeaaed903 100644 --- a/src/gateway/server-methods/skills.test-helpers.ts +++ b/src/gateway/server-methods/skills.test-helpers.ts @@ -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, + options: { + client?: GatewayClient | null; + context?: Partial; + } = {}, ): Promise { 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; diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 058cb99ad4ed..cf99f7401499 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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>) { + 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(params: { @@ -142,6 +162,38 @@ async function runSkillsProposalWorkspaceHandler(params: { } } +async function forwardSkillWorkshopRevisionToChatSend( + opts: GatewayRequestHandlerOptions, + params: { + agentId: string; + idempotencyKey: string; + instructions: string; + proposal: NonNullable>>; + sessionId?: string; + sessionKey: string; + }, +): Promise { + 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",