From d6922caf00523d926f40153bcb0051522cc88eab Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 3 Mar 2026 20:45:58 +0300 Subject: [PATCH 1/3] Add ACP agent package with tests --- CHANGELOG.md | 1 + bun.lock | 24 ++ package.json | 1 + packages/acp/package.json | 35 +++ packages/acp/src/agent.ts | 401 +++++++++++++++++++++++++++ packages/acp/src/index.ts | 18 ++ packages/mcp/package.json | 2 +- tests/engine/acp-agent.test.ts | 477 +++++++++++++++++++++++++++++++++ 8 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 packages/acp/package.json create mode 100644 packages/acp/src/agent.ts create mode 100644 packages/acp/src/index.ts create mode 100644 tests/engine/acp-agent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fddc313fc..c0647fce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ - W/H inputs in multi-selection mode - Flip horizontal/vertical using scale transform instead of rotation - Single-node alignment aligns to parent frame bounds +- ACP agent package — Agent Communication Protocol server for AI coding tools, reusing core ToolDefs ### Build diff --git a/bun.lock b/bun.lock index e02d408ce..dddbec6d7 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "yoga-layout": "^3.2.1", }, "devDependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@iconify-json/lucide": "^1.2.94", "@modelcontextprotocol/sdk": "^1.27.1", "@playwright/test": "^1.58.2", @@ -59,6 +60,21 @@ "vite": "^7.0.4", }, }, + "packages/acp": { + "name": "@open-pencil/acp", + "version": "0.5.1", + "bin": { + "openpencil-acp": "./dist/index.js", + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", + "@open-pencil/core": "workspace:*", + "canvaskit-wasm": "^0.40.0", + }, + "devDependencies": { + "@types/node": "^22.0.0", + }, + }, "packages/cli": { "name": "@open-pencil/cli", "version": "0.6.0", @@ -118,6 +134,8 @@ }, }, "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow=="], @@ -450,6 +468,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@open-pencil/acp": ["@open-pencil/acp@workspace:packages/acp"], + "@open-pencil/cli": ["@open-pencil/cli@workspace:packages/cli"], "@open-pencil/core": ["@open-pencil/core@workspace:packages/core"], @@ -1834,6 +1854,8 @@ "@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="], + "@open-pencil/acp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], + "@open-pencil/mcp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1904,6 +1926,8 @@ "@multiformats/dns/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="], + "@open-pencil/acp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@open-pencil/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@vueuse/integrations/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="], diff --git a/package.json b/package.json index c51fc4cd4..d747bba89 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "yoga-layout": "^3.2.1" }, "devDependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@iconify-json/lucide": "^1.2.94", "@modelcontextprotocol/sdk": "^1.27.1", "@playwright/test": "^1.58.2", diff --git a/packages/acp/package.json b/packages/acp/package.json new file mode 100644 index 000000000..fc6619797 --- /dev/null +++ b/packages/acp/package.json @@ -0,0 +1,35 @@ +{ + "name": "@open-pencil/acp", + "version": "0.5.1", + "license": "MIT", + "type": "module", + "main": "./src/agent.ts", + "bin": { + "openpencil-acp": "./dist/index.js" + }, + "files": [ + "src", + "dist" + ], + "scripts": { + "build": "bunx tsgo && bunx fix-esm-import-path dist", + "prepublishOnly": "bun run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-pencil/open-pencil.git", + "directory": "packages/acp" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", + "@open-pencil/core": "workspace:*", + "canvaskit-wasm": "^0.40.0" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/packages/acp/src/agent.ts b/packages/acp/src/agent.ts new file mode 100644 index 000000000..255c29af1 --- /dev/null +++ b/packages/acp/src/agent.ts @@ -0,0 +1,401 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { isAbsolute, relative, resolve } from 'node:path' + +import type * as acp from '@agentclientprotocol/sdk' + +import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph, exportFigFile } from '@open-pencil/core' + +import type { ToolDef } from '@open-pencil/core' + +export interface CreateAgentOptions { + enableEval?: boolean + fileRoot?: string | null +} + +interface AgentSession { + graph: SceneGraph | null + currentPageId: string | null + cwd: string + pendingPrompt: AbortController | null +} + +const TOOL_KIND_MAP: Record = { + get_selection: 'read', + get_page_tree: 'read', + get_node: 'read', + find_nodes: 'search', + list_pages: 'read', + list_variables: 'read', + list_collections: 'read', + node_bounds: 'read', + render: 'read', + create_shape: 'edit', + set_fill: 'edit', + set_stroke: 'edit', + set_effects: 'edit', + update_node: 'edit', + set_layout: 'edit', + set_constraints: 'edit', + delete_node: 'delete', + clone_node: 'edit', + rename_node: 'edit', + reparent_node: 'move', + select_nodes: 'other', + group_nodes: 'edit', + ungroup_node: 'edit', + create_component: 'edit', + create_instance: 'edit', + switch_page: 'other', + eval: 'execute', + node_move: 'move', + set_rotation: 'edit', + set_opacity: 'edit', + set_radius: 'edit', + set_min_max: 'edit', + set_text: 'edit', + set_font: 'edit', + set_font_range: 'edit', + set_text_resize: 'edit', + set_visible: 'edit', + set_blend: 'edit', + set_locked: 'edit', + set_stroke_align: 'edit' +} + +function toolKind(name: string): acp.ToolKind { + return TOOL_KIND_MAP[name] ?? 'other' +} + +function resolveAndCheckPath(filePath: string, fileRoot: string | null): string { + const resolved = resolve(filePath) + if (!fileRoot) return resolved + const rel = relative(fileRoot, resolved) + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return resolved + throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`) +} + +interface BuiltinTool { + name: string + description: string + execute: (args: Record, session: AgentSession) => Promise +} + +function builtinTools(fileRoot: string | null): BuiltinTool[] { + return [ + { + name: 'open_file', + description: 'Open a .fig file for editing. Must be called before using other tools.', + async execute(args, session) { + const path = resolveAndCheckPath(args.path as string, fileRoot) + const buf = await readFile(path) + session.graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) + computeAllLayouts(session.graph) + const pages = session.graph.getPages() + session.currentPageId = pages[0]?.id ?? null + return { pages: pages.map((p) => ({ id: p.id, name: p.name })), currentPage: pages[0]?.name } + } + }, + { + name: 'save_file', + description: 'Save the current document to a .fig file.', + async execute(args, session) { + if (!session.graph) throw new Error('No document loaded') + const path = resolveAndCheckPath(args.path as string, fileRoot) + const data = await exportFigFile(session.graph) + await writeFile(path, new Uint8Array(data)) + return { saved: path, bytes: data.byteLength } + } + }, + { + name: 'new_document', + description: 'Create a new empty document with a blank page.', + async execute(_args, session) { + session.graph = new SceneGraph() + const pages = session.graph.getPages() + session.currentPageId = pages[0]?.id ?? null + return { page: pages[0]?.name, id: session.currentPageId } + } + } + ] +} + +export function createAgent( + connection: acp.AgentSideConnection, + version: string, + options: CreateAgentOptions = {} +): acp.Agent { + const enableEval = options.enableEval ?? true + const fileRoot = + options.fileRoot === null || options.fileRoot === undefined ? null : resolve(options.fileRoot) + + const sessions = new Map() + const builtins = builtinTools(fileRoot) + const coreTools = ALL_TOOLS.filter((t) => enableEval || t.name !== 'eval') + + function getSession(sessionId: string): AgentSession { + const session = sessions.get(sessionId) + if (!session) throw new Error(`Session ${sessionId} not found`) + return session + } + + function makeFigma(session: AgentSession): FigmaAPI { + if (!session.graph) throw new Error('No document loaded. Use open_file or new_document first.') + const api = new FigmaAPI(session.graph) + if (session.currentPageId) api.currentPage = api.wrapNode(session.currentPageId) + return api + } + + function allToolDescriptions(): acp.AvailableCommand[] { + const commands: acp.AvailableCommand[] = [] + + for (const bt of builtins) { + commands.push({ + name: bt.name, + description: bt.description, + input: { type: 'unstructured' } + }) + } + + for (const def of coreTools) { + commands.push({ + name: def.name, + description: def.description, + input: { type: 'unstructured' } + }) + } + + return commands + } + + return { + async initialize(_params) { + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: false, + promptCapabilities: { + embeddedContext: true + } + }, + agentInfo: { + name: 'open-pencil', + title: 'OpenPencil', + version + }, + authMethods: [] + } + }, + + async authenticate(_params) { + return {} + }, + + async newSession(params) { + const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + + sessions.set(sessionId, { + graph: null, + currentPageId: null, + cwd: params.cwd, + pendingPrompt: null + }) + + return { + sessionId, + availableCommands: allToolDescriptions() + } + }, + + async prompt(params) { + const session = getSession(params.sessionId) + + session.pendingPrompt?.abort() + session.pendingPrompt = new AbortController() + const signal = session.pendingPrompt.signal + + const userText = params.prompt + .filter((b): b is acp.TextContent => b.type === 'text') + .map((b) => b.text) + .join('\n') + + let toolName: string | undefined + let toolArgs: Record = {} + + try { + const parsed = JSON.parse(userText) + if (parsed && typeof parsed === 'object' && typeof parsed.tool === 'string') { + toolName = parsed.tool + toolArgs = parsed.args ?? {} + } + } catch { + // Not JSON — try slash-command style: `/tool_name {"arg": "val"}` + const match = userText.match(/^\/(\S+)\s*(.*)$/s) + if (match) { + toolName = match[1] + if (match[2].trim()) { + try { + toolArgs = JSON.parse(match[2]) + } catch { + toolArgs = { query: match[2].trim() } + } + } + } + } + + if (!toolName) { + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: formatHelp(builtins, coreTools) + } + } + }) + session.pendingPrompt = null + return { stopReason: 'end_turn' } + } + + if (signal.aborted) { + session.pendingPrompt = null + return { stopReason: 'cancelled' } + } + + const toolCallId = `call_${Date.now()}` + const builtin = builtins.find((b) => b.name === toolName) + const coreTool = coreTools.find((t) => t.name === toolName) + + if (!builtin && !coreTool) { + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `Unknown tool: \`${toolName}\`. Use \`/help\` to list available tools.` + } + } + }) + session.pendingPrompt = null + return { stopReason: 'end_turn' } + } + + const kind = toolKind(toolName) + + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId, + title: `${toolName}`, + kind, + status: 'pending', + rawInput: toolArgs + } + }) + + if (signal.aborted) { + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'cancelled' + } + }) + session.pendingPrompt = null + return { stopReason: 'cancelled' } + } + + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'in_progress' + } + }) + + try { + let result: unknown + + if (builtin) { + result = await builtin.execute(toolArgs, session) + } else { + result = await coreTool!.execute(makeFigma(session), toolArgs) + } + + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: JSON.stringify(result, null, 2) + } + } + ], + rawOutput: result as Record + } + }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + await connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'failed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: JSON.stringify({ error: msg }) + } + } + ] + } + }) + } + + session.pendingPrompt = null + return { stopReason: 'end_turn' } + }, + + async cancel(params) { + sessions.get(params.sessionId)?.pendingPrompt?.abort() + } + } +} + +function formatHelp(builtins: BuiltinTool[], coreTools: ToolDef[]): string { + const lines = [ + '# OpenPencil ACP Agent', + '', + 'Send commands as `/tool_name {"arg": "value"}` or JSON `{"tool": "tool_name", "args": {...}}`.', + '', + '## File operations', + '' + ] + + for (const bt of builtins) { + lines.push(`- \`/${bt.name}\` — ${bt.description}`) + } + + lines.push('', '## Design tools', '') + + for (const def of coreTools) { + lines.push(`- \`/${def.name}\` — ${def.description}`) + } + + return lines.join('\n') +} diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts new file mode 100644 index 000000000..9ed7e3133 --- /dev/null +++ b/packages/acp/src/index.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun +import { readFile } from 'node:fs/promises' +import { Readable, Writable } from 'node:stream' + +import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk' + +import { createAgent } from './agent.js' + +const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8')) + +const input = Writable.toWeb(process.stdout) +const output = Readable.toWeb(process.stdin) as ReadableStream +const stream = ndJsonStream(input, output) + +new AgentSideConnection( + (conn) => createAgent(conn, pkg.version), + stream +) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 61af9ef34..b3300b66f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -26,10 +26,10 @@ "provenance": true }, "dependencies": { + "@hono/node-server": "^1.19.9", "@modelcontextprotocol/sdk": "^1.25.2", "@open-pencil/core": "workspace:*", "canvaskit-wasm": "^0.40.0", - "@hono/node-server": "^1.19.9", "hono": "^4.11.4", "zod": "^3.25.0" }, diff --git a/tests/engine/acp-agent.test.ts b/tests/engine/acp-agent.test.ts new file mode 100644 index 000000000..fe74e6f72 --- /dev/null +++ b/tests/engine/acp-agent.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { join } from 'node:path' +import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION +} from '@agentclientprotocol/sdk' + +import type * as acp from '@agentclientprotocol/sdk' + +import { createAgent, type CreateAgentOptions } from '../../packages/acp/src/agent' +import { SceneGraph, exportFigFile } from '@open-pencil/core' + +interface CollectedUpdates { + messages: string[] + toolCalls: acp.ToolCall[] + toolUpdates: acp.ToolCallUpdate[] +} + +async function createLinkedPair(options?: CreateAgentOptions) { + const clientToAgent = new TransformStream() + const agentToClient = new TransformStream() + + const updates: CollectedUpdates = { + messages: [], + toolCalls: [], + toolUpdates: [] + } + + const client: acp.Client = { + async requestPermission(params) { + return { + outcome: { + outcome: 'selected', + optionId: params.options[0].optionId + } + } + }, + async sessionUpdate(params) { + const u = params.update + switch (u.sessionUpdate) { + case 'agent_message_chunk': + if (u.content.type === 'text') updates.messages.push(u.content.text) + break + case 'tool_call': + updates.toolCalls.push(u as acp.ToolCall) + break + case 'tool_call_update': + updates.toolUpdates.push(u as acp.ToolCallUpdate) + break + } + } + } + + const agentConn = new ClientSideConnection( + () => client, + ndJsonStream(clientToAgent.writable, agentToClient.readable) + ) + + new AgentSideConnection( + (conn) => createAgent(conn, '0.0.0-test', options), + ndJsonStream(agentToClient.writable, clientToAgent.readable) + ) + + const initResult = await agentConn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true } + } + }) + + const sessionResult = await agentConn.newSession({ + cwd: process.cwd(), + mcpServers: [] + }) + + return { + connection: agentConn, + sessionId: sessionResult.sessionId, + initResult, + sessionResult, + updates, + close: async () => { + await clientToAgent.writable.close() + await agentToClient.writable.close() + } + } +} + +async function prompt( + pair: Awaited>, + text: string +): Promise { + pair.updates.messages = [] + pair.updates.toolCalls = [] + pair.updates.toolUpdates = [] + return pair.connection.prompt({ + sessionId: pair.sessionId, + prompt: [{ type: 'text', text }] + }) +} + +describe('ACP agent', () => { + let pair: Awaited> + + beforeEach(async () => { + pair = await createLinkedPair() + }) + + afterEach(async () => { + await pair.close() + }) + + test('initialize returns correct protocol version and agent info', () => { + expect(pair.initResult.protocolVersion).toBe(PROTOCOL_VERSION) + expect(pair.initResult.agentInfo?.name).toBe('open-pencil') + expect(pair.initResult.agentInfo?.title).toBe('OpenPencil') + expect(pair.initResult.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) + }) + + test('new session returns a valid session ID and available commands', () => { + expect(pair.sessionId).toBeTruthy() + expect(pair.sessionId.length).toBe(32) + const commands = pair.sessionResult.availableCommands ?? [] + const names = commands.map((c) => c.name) + expect(names).toContain('new_document') + expect(names).toContain('open_file') + expect(names).toContain('save_file') + expect(names).toContain('create_shape') + expect(names).toContain('set_fill') + expect(names).toContain('get_page_tree') + expect(commands.length).toBeGreaterThan(70) + }) + + test('unrecognized input shows help text', async () => { + const result = await prompt(pair, 'hello world') + expect(result.stopReason).toBe('end_turn') + expect(pair.updates.messages.length).toBe(1) + expect(pair.updates.messages[0]).toContain('OpenPencil ACP Agent') + expect(pair.updates.messages[0]).toContain('/new_document') + }) + + test('unknown tool name returns error message', async () => { + const result = await prompt(pair, '/nonexistent_tool') + expect(result.stopReason).toBe('end_turn') + expect(pair.updates.messages[0]).toContain('Unknown tool') + expect(pair.updates.messages[0]).toContain('nonexistent_tool') + }) + + test('new_document via slash command', async () => { + const result = await prompt(pair, '/new_document') + expect(result.stopReason).toBe('end_turn') + expect(pair.updates.toolCalls.length).toBe(1) + expect(pair.updates.toolCalls[0].title).toBe('new_document') + expect(pair.updates.toolCalls[0].status).toBe('pending') + + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(completed).toBeDefined() + const text = (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + const data = JSON.parse(text) + expect(data.page).toBe('Page 1') + expect(data.id).toBeTruthy() + }) + + test('new_document via JSON format', async () => { + const result = await prompt(pair, JSON.stringify({ tool: 'new_document', args: {} })) + expect(result.stopReason).toBe('end_turn') + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(completed).toBeDefined() + }) + + test('tool without loaded document reports failure', async () => { + const result = await prompt(pair, '/get_page_tree') + expect(result.stopReason).toBe('end_turn') + const failed = pair.updates.toolUpdates.find((u) => u.status === 'failed') + expect(failed).toBeDefined() + const text = (failed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + expect(text).toContain('No document loaded') + }) + + test('create_shape after new_document', async () => { + await prompt(pair, '/new_document') + const result = await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 0, "y": 0, "width": 200, "height": 100, "name": "Test"}' + ) + expect(result.stopReason).toBe('end_turn') + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(completed).toBeDefined() + const text = (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + const data = JSON.parse(text) + expect(data.name).toBe('Test') + expect(data.type).toBe('FRAME') + expect(data.id).toBeTruthy() + }) + + test('set_fill validates and applies color', async () => { + await prompt(pair, '/new_document') + const createResult = await prompt( + pair, + '/create_shape {"type": "RECTANGLE", "x": 0, "y": 0, "width": 50, "height": 50}' + ) + const created = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const createdData = JSON.parse( + (created!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + + await prompt(pair, `/set_fill {"id": "${createdData.id}", "color": "#00ff00"}`) + + const getResult = await prompt(pair, `/get_node {"id": "${createdData.id}"}`) + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const nodeData = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + expect(nodeData.fills[0].color.g).toBeCloseTo(1) + }) + + test('delete_node removes created node', async () => { + await prompt(pair, '/new_document') + await prompt( + pair, + '/create_shape {"type": "RECTANGLE", "x": 0, "y": 0, "width": 50, "height": 50}' + ) + const created = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const { id } = JSON.parse( + (created!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + + await prompt(pair, `/delete_node {"id": "${id}"}`) + await prompt(pair, `/get_node {"id": "${id}"}`) + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(completed).toBeDefined() + const text = (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + const data = JSON.parse(text) + expect(data.error).toContain('not found') + }) + + test('open_file loads a .fig file', async () => { + const fixturePath = join(import.meta.dir, '..', 'fixtures', 'nuxtui.fig') + const result = await prompt(pair, `/open_file {"path": "${fixturePath}"}`) + expect(result.stopReason).toBe('end_turn') + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const data = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + expect(data.pages.length).toBeGreaterThan(0) + expect(data.currentPage).toBeTruthy() + }) + + test('open_file with invalid path reports failure', async () => { + await prompt(pair, '/open_file {"path": "/nonexistent/file.fig"}') + const failed = pair.updates.toolUpdates.find((u) => u.status === 'failed') + expect(failed).toBeDefined() + }) + + test('save_file roundtrips a document', async () => { + const tmpPath = join(import.meta.dir, '..', `_acp_test_${Date.now()}.fig`) + try { + await prompt(pair, '/new_document') + await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Saved"}' + ) + await prompt(pair, `/save_file {"path": "${tmpPath}"}`) + + const saveCompleted = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const saved = JSON.parse( + (saveCompleted!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + expect(saved.bytes).toBeGreaterThan(0) + + await prompt(pair, `/open_file {"path": "${tmpPath}"}`) + await prompt(pair, '/get_page_tree') + const treeCompleted = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const tree = JSON.parse( + (treeCompleted!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + expect(tree.children.some((c: { name: string }) => c.name === 'Saved')).toBe(true) + } finally { + await unlink(tmpPath).catch(() => {}) + } + }) + + test('save_file without document reports failure', async () => { + await prompt(pair, '/save_file {"path": "/tmp/_acp_no_doc.fig"}') + const failed = pair.updates.toolUpdates.find((u) => u.status === 'failed') + expect(failed).toBeDefined() + }) + + test('tool call lifecycle: pending → in_progress → completed', async () => { + await prompt(pair, '/new_document') + expect(pair.updates.toolCalls[0].status).toBe('pending') + const statuses = pair.updates.toolUpdates.map((u) => u.status) + expect(statuses).toContain('in_progress') + expect(statuses).toContain('completed') + const inProgressIdx = statuses.indexOf('in_progress') + const completedIdx = statuses.indexOf('completed') + expect(inProgressIdx).toBeLessThan(completedIdx) + }) + + test('tool call lifecycle: pending → in_progress → failed', async () => { + await prompt(pair, '/get_page_tree') + expect(pair.updates.toolCalls[0].status).toBe('pending') + const statuses = pair.updates.toolUpdates.map((u) => u.status) + expect(statuses).toContain('in_progress') + expect(statuses).toContain('failed') + }) + + test('tool calls have correct kinds', async () => { + await prompt(pair, '/new_document') + await prompt(pair, '/get_page_tree') + expect(pair.updates.toolCalls[0].kind).toBe('read') + + await prompt( + pair, + '/create_shape {"type": "RECTANGLE", "x": 0, "y": 0, "width": 50, "height": 50}' + ) + expect(pair.updates.toolCalls[0].kind).toBe('edit') + }) + + test('tool calls include rawInput', async () => { + await prompt(pair, '/new_document') + await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 10, "y": 20, "width": 100, "height": 50}' + ) + const call = pair.updates.toolCalls[0] + expect(call.rawInput).toEqual({ + type: 'FRAME', + x: 10, + y: 20, + width: 100, + height: 50 + }) + }) + + test('find_nodes filters by type', async () => { + await prompt(pair, '/new_document') + await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 0, "y": 0, "width": 100, "height": 100, "name": "F1"}' + ) + await prompt( + pair, + '/create_shape {"type": "RECTANGLE", "x": 0, "y": 0, "width": 50, "height": 50, "name": "R1"}' + ) + await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 0, "y": 0, "width": 100, "height": 100, "name": "F2"}' + ) + await prompt(pair, '/find_nodes {"type": "FRAME"}') + const completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const data = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + expect(data.count).toBe(2) + }) + + test('full workflow: new → create → query → delete', async () => { + await prompt(pair, '/new_document') + + await prompt( + pair, + '/create_shape {"type": "FRAME", "x": 10, "y": 20, "width": 400, "height": 300, "name": "Container"}' + ) + let completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const frame = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + + await prompt( + pair, + `/create_shape {"type": "TEXT", "x": 0, "y": 0, "width": 200, "height": 30, "name": "Label", "parent_id": "${frame.id}"}` + ) + completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const child = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + + await prompt(pair, `/set_fill {"id": "${frame.id}", "color": "#336699"}`) + + await prompt(pair, '/get_page_tree') + completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const tree = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + const container = tree.children.find((c: { name: string }) => c.name === 'Container') + expect(container).toBeDefined() + expect(container.children?.some((c: { name: string }) => c.name === 'Label')).toBe(true) + + await prompt(pair, `/delete_node {"id": "${child.id}"}`) + + await prompt(pair, '/get_page_tree') + completed = pair.updates.toolUpdates.find((u) => u.status === 'completed') + const tree2 = JSON.parse( + (completed!.content![0] as { type: 'content'; content: acp.TextContent }).content.text + ) + const container2 = tree2.children.find((c: { id: string }) => c.id === frame.id) + expect(container2.children ?? []).toHaveLength(0) + }) +}) + +describe('ACP agent options', () => { + test('enableEval=false removes eval from available commands', async () => { + const pair = await createLinkedPair({ enableEval: false }) + try { + const names = (pair.sessionResult.availableCommands ?? []).map((c) => c.name) + expect(names).not.toContain('eval') + expect(names).toContain('create_shape') + } finally { + await pair.close() + } + }) + + test('fileRoot restricts open_file and save_file paths', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'openpencil-acp-root-')) + const insidePath = join(rootDir, 'inside.fig') + const outsidePath = join(tmpdir(), `outside-${Date.now()}.fig`) + + const graph = new SceneGraph() + const bytes = await exportFigFile(graph) + await writeFile(insidePath, new Uint8Array(bytes)) + + const pair = await createLinkedPair({ fileRoot: rootDir }) + try { + await prompt(pair, `/open_file {"path": "${insidePath}"}`) + const openCompleted = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(openCompleted).toBeDefined() + + await prompt(pair, `/save_file {"path": "${outsidePath}"}`) + const saveFailed = pair.updates.toolUpdates.find((u) => u.status === 'failed') + expect(saveFailed).toBeDefined() + const errText = (saveFailed!.content![0] as { type: 'content'; content: acp.TextContent }).content + .text + expect(errText).toContain('outside allowed root') + + await prompt(pair, `/save_file {"path": "${insidePath}"}`) + const saveCompleted = pair.updates.toolUpdates.find((u) => u.status === 'completed') + expect(saveCompleted).toBeDefined() + } finally { + await pair.close() + await unlink(outsidePath).catch(() => {}) + await rm(rootDir, { recursive: true, force: true }) + } + }) + + test('sessions are independent', async () => { + const pair = await createLinkedPair() + try { + const session2 = await pair.connection.newSession({ + cwd: process.cwd(), + mcpServers: [] + }) + + await prompt(pair, '/new_document') + + pair.updates.messages = [] + pair.updates.toolCalls = [] + pair.updates.toolUpdates = [] + + const result = await pair.connection.prompt({ + sessionId: session2.sessionId, + prompt: [{ type: 'text', text: '/get_page_tree' }] + }) + + expect(result.stopReason).toBe('end_turn') + const failed = pair.updates.toolUpdates.find((u) => u.status === 'failed') + expect(failed).toBeDefined() + } finally { + await pair.close() + } + }) +}) From 3199abb4e6f114ddefb18b53dce5401a038afe49 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 5 Mar 2026 09:19:15 +0300 Subject: [PATCH 2/3] Fix render tool on Windows: add .js extension to dynamic imports ESM dynamic imports require explicit file extensions for resolution on Windows + Bun. The extensionless '../render/render-jsx' path worked on macOS/Linux but failed on Windows with 'Cannot find module' error. Fixes #43 --- packages/core/src/tools/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index 2d03afe5c..f2e8024ff 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -193,7 +193,7 @@ export const render = defineTool({ parent_id: { type: 'string', description: 'Parent node ID to render into' } }, execute: async (figma, args) => { - const { renderJsx } = await import('../render/render-jsx') + const { renderJsx } = await import('../render/render-jsx.js') const result = await renderJsx(figma.graph, args.jsx, { parentId: args.parent_id ?? figma.currentPageId, x: args.x, @@ -1046,7 +1046,7 @@ export const nodeReplaceWith = defineTool({ const x = node.x const y = node.y node.remove() - const { renderJsx } = await import('../render/render-jsx') + const { renderJsx } = await import('../render/render-jsx.js') const result = await renderJsx(figma.graph, args.jsx, { parentId, x, y }) return { id: result.id, name: result.name, type: result.type } } From 43e02742130c718ed801741cc699bba14ef96eab Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 5 Mar 2026 09:20:25 +0300 Subject: [PATCH 3/3] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0647fce6..5594660c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixes +- Fix `render` tool failing on Windows + Bun with "Cannot find module" error (#43) - Fix hover highlighting nodes from internal component pages — scope hit-test to current page - Fix hit-testing on transparent frames and groups — empty containers without fills or strokes are now click-through, clipping parents reject hits outside their bounds, matching Figma behavior - Fix instance overrides on .fig import and clipboard paste — resolve guidPaths by overrideKey, handle component swaps (`overriddenSymbolID`), propagate through nested clone chains. Import and paste now share a single override engine.