2026-03-15 06:11:18 +00:00
|
|
|
import { describe, expect, test } from 'bun:test'
|
|
|
|
|
|
2026-05-05 23:22:08 +00:00
|
|
|
import type { SessionUpdate } from '@agentclientprotocol/sdk'
|
|
|
|
|
|
2026-05-05 23:05:02 +00:00
|
|
|
import { mapUpdate } from '@/app/ai/acp/map-update'
|
|
|
|
|
import { formatConnectionError, buildCrashChunks } from '@/app/ai/acp/transport'
|
2026-03-15 06:11:18 +00:00
|
|
|
|
|
|
|
|
const TEXT_ID = 'text-1'
|
|
|
|
|
|
|
|
|
|
describe('mapUpdate', () => {
|
|
|
|
|
test('agent_message_chunk with non-empty text starts text and emits delta', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'agent_message_chunk',
|
|
|
|
|
content: { type: 'text', text: 'Hello' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.textStarted).toBe(true)
|
|
|
|
|
expect(result.chunks).toEqual([
|
|
|
|
|
{ type: 'text-start', id: TEXT_ID },
|
|
|
|
|
{ type: 'text-delta', id: TEXT_ID, delta: 'Hello' }
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('agent_message_chunk with empty text is skipped', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'agent_message_chunk',
|
|
|
|
|
content: { type: 'text', text: '' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.textStarted).toBe(false)
|
|
|
|
|
expect(result.chunks).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('subsequent agent_message_chunk does not re-emit text-start', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'agent_message_chunk',
|
|
|
|
|
content: { type: 'text', text: 'world' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, true)
|
|
|
|
|
expect(result.textStarted).toBe(true)
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(result.chunks).toEqual([{ type: 'text-delta', id: TEXT_ID, delta: 'world' }])
|
2026-03-15 06:11:18 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('agent_thought_chunk emits reasoning start/delta/end', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'agent_thought_chunk',
|
|
|
|
|
content: { type: 'text', text: 'thinking...' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.chunks).toHaveLength(3)
|
|
|
|
|
expect(result.chunks[0].type).toBe('reasoning-start')
|
|
|
|
|
expect(result.chunks[1]).toEqual({
|
|
|
|
|
type: 'reasoning-delta',
|
|
|
|
|
id: `reasoning-${TEXT_ID}`,
|
|
|
|
|
delta: 'thinking...'
|
|
|
|
|
})
|
|
|
|
|
expect(result.chunks[2].type).toBe('reasoning-end')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('tool_call emits tool-input-start', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'tool_call',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
title: 'create_shape',
|
|
|
|
|
kind: 'edit',
|
|
|
|
|
status: 'pending'
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(result.chunks).toEqual([
|
|
|
|
|
{
|
|
|
|
|
type: 'tool-input-start',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
toolName: 'create_shape',
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
title: 'create_shape'
|
|
|
|
|
}
|
|
|
|
|
])
|
2026-03-15 06:11:18 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('tool_call with rawInput emits tool-input-available', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'tool_call',
|
|
|
|
|
toolCallId: 'tc-2',
|
|
|
|
|
title: 'set_fill',
|
|
|
|
|
kind: 'edit',
|
|
|
|
|
status: 'pending',
|
|
|
|
|
rawInput: { id: '1:2', color: '#ff0000' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.chunks).toHaveLength(2)
|
|
|
|
|
expect(result.chunks[1]).toEqual({
|
|
|
|
|
type: 'tool-input-available',
|
|
|
|
|
toolCallId: 'tc-2',
|
|
|
|
|
toolName: 'set_fill',
|
|
|
|
|
input: { id: '1:2', color: '#ff0000' },
|
|
|
|
|
providerExecuted: true,
|
|
|
|
|
title: 'set_fill'
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('tool_call with empty title falls back to "unknown"', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'tool_call',
|
|
|
|
|
toolCallId: 'tc-3',
|
|
|
|
|
title: '',
|
|
|
|
|
kind: 'other',
|
|
|
|
|
status: 'pending'
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.chunks[0]).toMatchObject({ toolName: 'unknown' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('tool_call_update completed emits tool-output-available', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'tool_call_update',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
status: 'completed',
|
|
|
|
|
rawOutput: { id: '1:5', type: 'RECTANGLE' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(result.chunks).toEqual([
|
|
|
|
|
{
|
|
|
|
|
type: 'tool-output-available',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
output: { id: '1:5', type: 'RECTANGLE' },
|
|
|
|
|
providerExecuted: true
|
|
|
|
|
}
|
|
|
|
|
])
|
2026-03-15 06:11:18 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('tool_call_update failed emits tool-output-error', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'tool_call_update',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
status: 'failed',
|
|
|
|
|
content: [{ type: 'content', content: { type: 'text', text: 'Node not found' } }]
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(result.chunks).toEqual([
|
|
|
|
|
{
|
|
|
|
|
type: 'tool-output-error',
|
|
|
|
|
toolCallId: 'tc-1',
|
|
|
|
|
errorText: 'Node not found',
|
|
|
|
|
providerExecuted: true
|
|
|
|
|
}
|
|
|
|
|
])
|
2026-03-15 06:11:18 +00:00
|
|
|
})
|
|
|
|
|
|
Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 13:37:15 +00:00
|
|
|
test('agent_message_chunk with non-text content produces no chunks', () => {
|
|
|
|
|
const update: SessionUpdate = {
|
|
|
|
|
sessionUpdate: 'agent_message_chunk',
|
|
|
|
|
content: { type: 'image', url: 'https://example.com/img.png' }
|
|
|
|
|
}
|
|
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.textStarted).toBe(false)
|
|
|
|
|
expect(result.chunks).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-15 06:11:18 +00:00
|
|
|
test('unhandled update type produces no chunks', () => {
|
|
|
|
|
const update = {
|
|
|
|
|
sessionUpdate: 'available_commands_update',
|
|
|
|
|
availableCommands: []
|
2026-05-05 21:29:23 +00:00
|
|
|
} as SessionUpdate
|
2026-03-15 06:11:18 +00:00
|
|
|
const result = mapUpdate(update, TEXT_ID, false)
|
|
|
|
|
expect(result.chunks).toEqual([])
|
|
|
|
|
expect(result.textStarted).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 13:37:15 +00:00
|
|
|
|
|
|
|
|
describe('formatConnectionError', () => {
|
2026-04-06 11:19:38 +00:00
|
|
|
const claudeAgent = {
|
|
|
|
|
id: 'claude-code',
|
|
|
|
|
name: 'Claude Code',
|
|
|
|
|
command: 'claude-agent-acp',
|
|
|
|
|
args: [],
|
|
|
|
|
installCommand: 'npm i -g @agentclientprotocol/claude-agent-acp'
|
|
|
|
|
} as const
|
|
|
|
|
|
Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 13:37:15 +00:00
|
|
|
test('ECONNREFUSED maps to MCP not running', () => {
|
|
|
|
|
const msg = formatConnectionError(new Error('connect ECONNREFUSED 127.0.0.1:7600'))
|
|
|
|
|
expect(msg).toBe('MCP server is not running. Make sure the editor is open.')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('fetch failed maps to MCP not running', () => {
|
|
|
|
|
const msg = formatConnectionError(new Error('fetch failed'))
|
|
|
|
|
expect(msg).toBe('MCP server is not running. Make sure the editor is open.')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('timeout maps to timeout message', () => {
|
|
|
|
|
const msg = formatConnectionError(new Error('Request timeout after 30s'))
|
|
|
|
|
expect(msg).toBe('MCP server did not respond in time.')
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-06 11:19:38 +00:00
|
|
|
test('ENOENT maps to install instructions', () => {
|
|
|
|
|
const msg = formatConnectionError(new Error('spawn claude-agent-acp ENOENT'), claudeAgent)
|
|
|
|
|
expect(msg).toBe(
|
|
|
|
|
'"claude-agent-acp" is not installed. Install it with: npm i -g @agentclientprotocol/claude-agent-acp'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 13:37:15 +00:00
|
|
|
test('other errors pass through', () => {
|
|
|
|
|
const msg = formatConnectionError(new Error('Something unexpected'))
|
|
|
|
|
expect(msg).toBe('Something unexpected')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('non-Error values converted to string', () => {
|
|
|
|
|
const msg = formatConnectionError('raw string error')
|
|
|
|
|
expect(msg).toBe('raw string error')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('buildCrashChunks', () => {
|
|
|
|
|
test('destroying=true returns empty chunks, no session null', () => {
|
|
|
|
|
const result = buildCrashChunks(true, TEXT_ID, false)
|
|
|
|
|
expect(result.chunks).toEqual([])
|
|
|
|
|
expect(result.shouldNullSession).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('destroying=false with no text emits error + finish', () => {
|
|
|
|
|
const result = buildCrashChunks(false, TEXT_ID, false)
|
|
|
|
|
expect(result.shouldNullSession).toBe(true)
|
|
|
|
|
expect(result.chunks).toEqual([
|
|
|
|
|
{ type: 'error', errorText: 'Agent process exited unexpectedly.' },
|
|
|
|
|
{ type: 'finish-step' },
|
|
|
|
|
{ type: 'finish', finishReason: 'error' }
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('destroying=false with active text emits text-end before error', () => {
|
|
|
|
|
const result = buildCrashChunks(false, TEXT_ID, true)
|
|
|
|
|
expect(result.shouldNullSession).toBe(true)
|
|
|
|
|
expect(result.chunks[0]).toEqual({ type: 'text-end', id: TEXT_ID })
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(result.chunks[1]).toEqual({
|
|
|
|
|
type: 'error',
|
|
|
|
|
errorText: 'Agent process exited unexpectedly.'
|
|
|
|
|
})
|
Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 13:37:15 +00:00
|
|
|
})
|
|
|
|
|
})
|