diff --git a/extensions/canvas/index.test.ts b/extensions/canvas/index.test.ts new file mode 100644 index 00000000000..8e65386985e --- /dev/null +++ b/extensions/canvas/index.test.ts @@ -0,0 +1,137 @@ +import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import canvasPlugin from "./index.js"; + +const mocks = vi.hoisted(() => { + const httpHandler = { + handleHttpRequest: vi.fn(async () => true), + handleUpgrade: vi.fn(async () => true), + close: vi.fn(async () => {}), + }; + const toolExecute = vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })); + return { + httpHandler, + createCanvasHttpRouteHandler: vi.fn(() => httpHandler), + resolveCanvasHttpPathToLocalPath: vi.fn(() => "/tmp/canvas-asset"), + createDefaultCanvasCliDependencies: vi.fn(() => ({ deps: true })), + registerNodesCanvasCommands: vi.fn(), + toolExecute, + createCanvasTool: vi.fn(() => ({ + label: "Canvas", + name: "canvas", + description: "Canvas", + parameters: {}, + execute: toolExecute, + })), + }; +}); + +vi.mock("./src/http-route.js", () => ({ + createCanvasHttpRouteHandler: mocks.createCanvasHttpRouteHandler, +})); + +vi.mock("./src/documents.js", () => ({ + resolveCanvasHttpPathToLocalPath: mocks.resolveCanvasHttpPathToLocalPath, +})); + +vi.mock("./src/cli.js", () => ({ + createDefaultCanvasCliDependencies: mocks.createDefaultCanvasCliDependencies, + registerNodesCanvasCommands: mocks.registerNodesCanvasCommands, +})); + +vi.mock("./src/tool.js", () => ({ + createCanvasTool: mocks.createCanvasTool, +})); + +function registerCanvas() { + const routes: Array[0]> = []; + const services: Array[0]> = []; + const resolvers: Array[0]> = []; + const tools: Array[0]> = []; + const cliFeatures: Array<{ + registrar: Parameters[0]; + opts: Parameters[1]; + }> = []; + canvasPlugin.register?.( + createTestPluginApi({ + id: "canvas", + name: "Canvas", + config: {}, + registerHttpRoute: (route) => routes.push(route), + registerService: (service) => services.push(service), + registerHostedMediaResolver: (resolver) => resolvers.push(resolver), + registerTool: (tool) => tools.push(tool), + registerNodeCliFeature: (registrar, opts) => cliFeatures.push({ registrar, opts }), + registerNodeInvokePolicy: vi.fn(), + }), + ); + return { routes, services, resolvers, tools, cliFeatures }; +} + +describe("Canvas plugin entry", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("defers Canvas host implementation until a registered route is used", async () => { + const { routes, services } = registerCanvas(); + + expect(routes).toHaveLength(3); + expect(services).toHaveLength(1); + expect(mocks.createCanvasHttpRouteHandler).not.toHaveBeenCalled(); + + await services[0]?.stop?.({} as never); + expect(mocks.createCanvasHttpRouteHandler).not.toHaveBeenCalled(); + + await routes[0]?.handler({ url: "/__openclaw__/canvas" } as never, {} as never); + expect(mocks.createCanvasHttpRouteHandler).toHaveBeenCalledTimes(1); + expect(mocks.httpHandler.handleHttpRequest).toHaveBeenCalledTimes(1); + + await services[0]?.stop?.({} as never); + expect(mocks.httpHandler.close).toHaveBeenCalledTimes(1); + }); + + it("defers Canvas resolver, CLI, and tool implementations until use", async () => { + const { resolvers, tools, cliFeatures } = registerCanvas(); + + expect(resolvers).toHaveLength(1); + expect(tools).toHaveLength(1); + expect(cliFeatures).toHaveLength(1); + expect(mocks.resolveCanvasHttpPathToLocalPath).not.toHaveBeenCalled(); + expect(mocks.createDefaultCanvasCliDependencies).not.toHaveBeenCalled(); + expect(mocks.createCanvasTool).not.toHaveBeenCalled(); + + await expect(resolvers[0]?.("/__openclaw__/canvas/documents/id/index.html")).resolves.toBe( + "/tmp/canvas-asset", + ); + expect(mocks.resolveCanvasHttpPathToLocalPath).toHaveBeenCalledTimes(1); + + await cliFeatures[0]?.registrar({ + program: {} as never, + parentPath: ["nodes"], + config: {}, + workspaceDir: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + }); + expect(mocks.createDefaultCanvasCliDependencies).toHaveBeenCalledTimes(1); + expect(mocks.registerNodesCanvasCommands).toHaveBeenCalledTimes(1); + + const toolFactory = tools[0]; + expect(typeof toolFactory).toBe("function"); + const tool = (toolFactory as Exclude)({ + config: {}, + workspaceDir: "/tmp/workspace", + }); + expect(Array.isArray(tool)).toBe(false); + expect((tool as AnyAgentTool).name).toBe("canvas"); + expect(mocks.createCanvasTool).not.toHaveBeenCalled(); + + await (tool as AnyAgentTool).execute("tool-call", { action: "hide" }); + expect(mocks.createCanvasTool).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/tmp/workspace", + }); + expect(mocks.toolExecute).toHaveBeenCalledWith("tool-call", { action: "hide" }); + }); +}); diff --git a/extensions/canvas/index.ts b/extensions/canvas/index.ts index f99d4165eef..5c75283efeb 100644 --- a/extensions/canvas/index.ts +++ b/extensions/canvas/index.ts @@ -1,10 +1,10 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createDefaultCanvasCliDependencies, registerNodesCanvasCommands } from "./src/cli.js"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; import { canvasConfigSchema, isCanvasHostEnabled } from "./src/config.js"; -import { resolveCanvasHttpPathToLocalPath } from "./src/documents.js"; -import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "./src/host/a2ui.js"; -import { createCanvasHttpRouteHandler } from "./src/http-route.js"; -import { createCanvasTool } from "./src/tool.js"; +import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "./src/host/a2ui-shared.js"; +import { CanvasToolSchema } from "./src/tool-schema.js"; const CANVAS_NODE_COMMANDS = [ "canvas.present", @@ -17,6 +17,31 @@ const CANVAS_NODE_COMMANDS = [ "canvas.a2ui.reset", ]; +function createLazyCanvasTool(params: { + config?: OpenClawConfig; + workspaceDir?: string; +}): AnyAgentTool { + let toolPromise: Promise | undefined; + const loadTool = async () => { + toolPromise ??= import("./src/tool.js").then(({ createCanvasTool }) => + createCanvasTool({ + config: params.config, + workspaceDir: params.workspaceDir, + }), + ); + return await toolPromise; + }; + return { + label: "Canvas", + name: "canvas", + description: + "Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.", + parameters: CanvasToolSchema, + execute: async (...args: Parameters) => + await (await loadTool()).execute(...args), + }; +} + export default definePluginEntry({ id: "canvas", name: "Canvas", @@ -27,46 +52,72 @@ export default definePluginEntry({ }, register(api) { if (isCanvasHostEnabled(api.config)) { - const httpRouteHandler = createCanvasHttpRouteHandler({ - config: api.config, - pluginConfig: api.pluginConfig, - runtime: { - log: (...args) => api.logger.info(args.map(String).join(" ")), - error: (...args) => api.logger.error(args.map(String).join(" ")), - exit: (code) => { - throw new Error(`canvas host requested process exit ${code}`); - }, - }, - }); + let httpRouteHandlerPromise: + | Promise< + ReturnType<(typeof import("./src/http-route.js"))["createCanvasHttpRouteHandler"]> + > + | undefined; + const loadHttpRouteHandler = async () => { + httpRouteHandlerPromise ??= import("./src/http-route.js").then( + ({ createCanvasHttpRouteHandler }) => + createCanvasHttpRouteHandler({ + config: api.config, + pluginConfig: api.pluginConfig, + runtime: { + log: (...args) => api.logger.info(args.map(String).join(" ")), + error: (...args) => api.logger.error(args.map(String).join(" ")), + exit: (code) => { + throw new Error(`canvas host requested process exit ${code}`); + }, + }, + }), + ); + return await httpRouteHandlerPromise; + }; + const handleHttpRequest = async (req: IncomingMessage, res: ServerResponse) => + await (await loadHttpRouteHandler()).handleHttpRequest(req, res); + const handleUpgrade = async (req: IncomingMessage, socket: Duplex, head: Buffer) => + await (await loadHttpRouteHandler()).handleUpgrade(req, socket, head); const nodeCapability = { surface: "canvas" }; api.registerHttpRoute({ path: A2UI_PATH, auth: "plugin", match: "prefix", nodeCapability, - handler: httpRouteHandler.handleHttpRequest, + handler: handleHttpRequest, }); api.registerHttpRoute({ path: CANVAS_HOST_PATH, auth: "plugin", match: "prefix", nodeCapability, - handler: httpRouteHandler.handleHttpRequest, + handler: handleHttpRequest, }); api.registerHttpRoute({ path: CANVAS_WS_PATH, auth: "plugin", match: "exact", nodeCapability, - handler: httpRouteHandler.handleHttpRequest, - handleUpgrade: httpRouteHandler.handleUpgrade, + handler: handleHttpRequest, + handleUpgrade, }); api.registerService({ id: "canvas-host", start: () => {}, - stop: () => httpRouteHandler.close(), + stop: async () => { + const httpRouteHandler = httpRouteHandlerPromise ? await httpRouteHandlerPromise : null; + await httpRouteHandler?.close(); + }, + }); + let resolveCanvasHttpPathToLocalPathPromise: + | Promise<(typeof import("./src/documents.js"))["resolveCanvasHttpPathToLocalPath"]> + | undefined; + api.registerHostedMediaResolver(async (mediaUrl) => { + resolveCanvasHttpPathToLocalPathPromise ??= import("./src/documents.js").then( + ({ resolveCanvasHttpPathToLocalPath }) => resolveCanvasHttpPathToLocalPath, + ); + return (await resolveCanvasHttpPathToLocalPathPromise)(mediaUrl); }); - api.registerHostedMediaResolver((mediaUrl) => resolveCanvasHttpPathToLocalPath(mediaUrl)); } api.registerNodeInvokePolicy({ commands: CANVAS_NODE_COMMANDS, @@ -75,13 +126,15 @@ export default definePluginEntry({ handle: (ctx) => ctx.invokeNode(), }); api.registerTool((ctx) => - createCanvasTool({ + createLazyCanvasTool({ config: ctx.runtimeConfig ?? ctx.config, workspaceDir: ctx.workspaceDir, }), ); api.registerNodeCliFeature( - ({ program }) => { + async ({ program }) => { + const { createDefaultCanvasCliDependencies, registerNodesCanvasCommands } = + await import("./src/cli.js"); registerNodesCanvasCommands(program, createDefaultCanvasCliDependencies()); }, { diff --git a/extensions/canvas/src/tool-schema.ts b/extensions/canvas/src/tool-schema.ts new file mode 100644 index 00000000000..0af9387046e --- /dev/null +++ b/extensions/canvas/src/tool-schema.ts @@ -0,0 +1,41 @@ +import { Type } from "typebox"; + +export const CANVAS_ACTIONS = [ + "present", + "hide", + "navigate", + "eval", + "snapshot", + "a2ui_push", + "a2ui_reset", +] as const; + +export const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; + +function stringEnum(values: T) { + return Type.Unsafe({ + type: "string", + enum: [...values], + }); +} + +export const CanvasToolSchema = Type.Object({ + action: stringEnum(CANVAS_ACTIONS), + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + node: Type.Optional(Type.String()), + target: Type.Optional(Type.String()), + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), + width: Type.Optional(Type.Number()), + height: Type.Optional(Type.Number()), + url: Type.Optional(Type.String()), + javaScript: Type.Optional(Type.String()), + outputFormat: Type.Optional(stringEnum(CANVAS_SNAPSHOT_FORMATS)), + maxWidth: Type.Optional(Type.Number()), + quality: Type.Optional(Type.Number()), + delayMs: Type.Optional(Type.Number()), + jsonl: Type.Optional(Type.String()), + jsonlPath: Type.Optional(Type.String()), +}); diff --git a/extensions/canvas/src/tool.ts b/extensions/canvas/src/tool.ts index 3f3a4ae8d68..0a966ccd3d0 100644 --- a/extensions/canvas/src/tool.ts +++ b/extensions/canvas/src/tool.ts @@ -9,25 +9,11 @@ import { import { imageResultFromFile, jsonResult, - optionalStringEnum, readStringParam, - stringEnum, } from "openclaw/plugin-sdk/channel-actions"; import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { Type } from "typebox"; - -const CANVAS_ACTIONS = [ - "present", - "hide", - "navigate", - "eval", - "snapshot", - "a2ui_push", - "a2ui_reset", -] as const; - -const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; +import { CanvasToolSchema } from "./tool-schema.js"; type CanvasToolOptions = { config?: OpenClawConfig; @@ -115,28 +101,6 @@ function resolveCanvasImageSanitizationLimits( return { maxDimensionPx: Math.max(1, Math.floor(configured)) }; } -// Flattened schema: runtime validates per-action requirements. -const CanvasToolSchema = Type.Object({ - action: stringEnum(CANVAS_ACTIONS), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - target: Type.Optional(Type.String()), - x: Type.Optional(Type.Number()), - y: Type.Optional(Type.Number()), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), - url: Type.Optional(Type.String()), - javaScript: Type.Optional(Type.String()), - outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - jsonl: Type.Optional(Type.String()), - jsonlPath: Type.Optional(Type.String()), -}); - export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool { const imageSanitization = resolveCanvasImageSanitizationLimits(options?.config); return {