diff --git a/package.json b/package.json index 13805834e..321b0c94c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/acp/package.json b/packages/acp/package.json deleted file mode 100644 index 6be28d668..000000000 --- a/packages/acp/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/acp/src/agent.ts b/packages/acp/src/agent.ts deleted file mode 100644 index 733e4c26c..000000000 --- a/packages/acp/src/agent.ts +++ /dev/null @@ -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 = { - 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: { 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 = {} - - 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 - } - }) - } 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 deleted file mode 100644 index 9ed7e3133..000000000 --- a/packages/acp/src/index.ts +++ /dev/null @@ -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 -const stream = ndJsonStream(input, output) - -new AgentSideConnection( - (conn) => createAgent(conn, pkg.version), - stream -) diff --git a/tests/engine/acp-agent.test.ts b/tests/engine/acp-agent.test.ts deleted file mode 100644 index fe74e6f72..000000000 --- a/tests/engine/acp-agent.test.ts +++ /dev/null @@ -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() - 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() - } - }) -})