Merge remote-tracking branch 'origin/fix-render-jsx-windows'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Danila Poyarkov 2026-03-05 09:51:26 +03:00
commit 78f249a7c5
9 changed files with 961 additions and 3 deletions

View file

@ -18,6 +18,7 @@
- CJK text rendering — load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK) as fallback; falls back to Noto Sans SC from Google Fonts when no system font is available (#48)
- Font registration errors no longer cache invalid font data — `loadFont` only caches after successful CanvasKit registration
- 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.
@ -49,6 +50,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

View file

@ -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=="],

View file

@ -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",

35
packages/acp/package.json Normal file
View file

@ -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"
}
}

401
packages/acp/src/agent.ts Normal file
View file

@ -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<string, acp.ToolKind> = {
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<string, unknown>, session: AgentSession) => Promise<unknown>
}
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<string, AgentSession>()
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<string, unknown> = {}
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<string, unknown>
}
})
} 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')
}

18
packages/acp/src/index.ts Normal file
View file

@ -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<Uint8Array>
const stream = ndJsonStream(input, output)
new AgentSideConnection(
(conn) => createAgent(conn, pkg.version),
stream
)

View file

@ -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 }
}

View file

@ -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"
},

View file

@ -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<Uint8Array, Uint8Array>()
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
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<ReturnType<typeof createLinkedPair>>,
text: string
): Promise<acp.PromptResponse> {
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<ReturnType<typeof createLinkedPair>>
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()
}
})
})