Remove packages/acp and its tests

Redundant — the MCP server already provides external tool access.
The desktop app uses acp-transport.ts (ACP client) + automation
bridge MCP endpoint instead of the standalone ACP agent.
This commit is contained in:
Danila Poyarkov 2026-03-15 09:13:04 +03:00 committed by Anton A S
parent 19505665f5
commit 1a86f3fa16
5 changed files with 1 additions and 919 deletions

View file

@ -12,7 +12,7 @@
"build": "bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/acp/src/",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/cli/src/ packages/mcp/src/",
"format": "oxfmt --write src/",
"check": "bun run lint",
"test": "playwright test --project=openpencil",

View file

@ -1,35 +0,0 @@
{
"name": "@open-pencil/acp",
"version": "0.8.0",
"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"
}
}

View file

@ -1,388 +0,0 @@
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: { hint: 'JSON arguments or key=value pairs' }
})
}
for (const def of coreTools) {
commands.push({
name: def.name,
description: def.description,
input: { hint: 'JSON arguments or key=value pairs' }
})
}
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 & { type: 'text' } => 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
}
})
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 if (coreTool) {
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')
}

View file

@ -1,18 +0,0 @@
#!/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

@ -1,477 +0,0 @@
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()
}
})
})