Merge remote-tracking branch 'origin/fix-render-jsx-windows'
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
78f249a7c5
|
|
@ -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
|
||||
|
||||
|
|
|
|||
24
bun.lock
24
bun.lock
|
|
@ -39,6 +39,7 @@
|
|||
"yoga-layout": "^3.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@iconify-json/lucide": "^1.2.94",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
|
@ -59,6 +60,21 @@
|
|||
"vite": "^7.0.4",
|
||||
},
|
||||
},
|
||||
"packages/acp": {
|
||||
"name": "@open-pencil/acp",
|
||||
"version": "0.5.1",
|
||||
"bin": {
|
||||
"openpencil-acp": "./dist/index.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@open-pencil/core": "workspace:*",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
},
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@open-pencil/cli",
|
||||
"version": "0.6.0",
|
||||
|
|
@ -118,6 +134,8 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow=="],
|
||||
|
|
@ -450,6 +468,8 @@
|
|||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@open-pencil/acp": ["@open-pencil/acp@workspace:packages/acp"],
|
||||
|
||||
"@open-pencil/cli": ["@open-pencil/cli@workspace:packages/cli"],
|
||||
|
||||
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
|
||||
|
|
@ -1834,6 +1854,8 @@
|
|||
|
||||
"@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="],
|
||||
|
||||
"@open-pencil/acp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
|
||||
|
||||
"@open-pencil/mcp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
|
@ -1904,6 +1926,8 @@
|
|||
|
||||
"@multiformats/dns/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="],
|
||||
|
||||
"@open-pencil/acp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@open-pencil/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@vueuse/integrations/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||
|
|
|
|||
|
|
@ -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
35
packages/acp/package.json
Normal 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
401
packages/acp/src/agent.ts
Normal 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
18
packages/acp/src/index.ts
Normal 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
|
||||
)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
477
tests/engine/acp-agent.test.ts
Normal file
477
tests/engine/acp-agent.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue