openpencil/tests/engine/acp-agent.test.ts
2026-03-05 09:14:54 +03:00

478 lines
17 KiB
TypeScript

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()
}
})
})