diff --git a/.changeset/config.json b/.changeset/config.json index b821332..2c4ddf8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,6 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", + "privatePackages": false, "ignore": [] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a357d0..2d86b03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install Chromium for Vitest Browser Mode + run: pnpm exec playwright install --with-deps chromium + # ... (lint, type-check, test:ci, codecov... 保持不变) - name: Run linting run: pnpm lint diff --git a/README.md b/README.md index ca32b12..48067df 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,46 @@ - 🏗️ **工程化规范**: 采用 Monorepo 架构,使用 Changesets 管理版本 - 🧪 **质量保障**: 完善的测试流程(单元测试 + 冒烟测试) +## Axis ACP DevKit(私有工作区) + +仓库同时包含一套面向 Coding Agent 开发者的 ACP v1 测试与调试工具链。它不会随 `axis-ui` 发布,核心能力可在无浏览器环境运行:安全启动已注册的 stdio Agent、记录 Raw JSON-RPC 与归一化事件、执行三个固定场景、生成诊断与单次 JSON/HTML 报告,并从脱敏 Transcript 离线恢复状态。 + +```bash +# 构建全部工作区 +pnpm build:all + +# 确定性场景 + JSON/HTML 证据 +node packages/acp-cli/dist/main.js run \ + --target fixture-agent \ + --scenario cancel-during-permission \ + --workspace . \ + --output artifacts/runs + +# 离线回放并校验 State Hash +node packages/acp-cli/dist/main.js replay \ + --input artifacts/runs/fixture-agent-cancel-during-permission.axis-acp.json + +# 检查本机已安装的真实 OpenCode ACP Agent +node packages/acp-cli/dist/main.js inspect --target opencode --workspace . +``` + +启动可视化 Workbench 时,Node Host 与浏览器分开运行。`serve` 只绑定 loopback,使用高熵临时 Token 和 Origin 校验;浏览器只能发送已注册的 Target ID、固定 Scenario ID 与 Workspace,不能提交可执行命令或参数。 + +```bash +# 终端 1:复制输出的 ws URL 与 Token +node packages/acp-cli/dist/main.js serve --origin http://127.0.0.1:5173 + +# 终端 2 +pnpm --filter @axis-ui/acp-devtools dev + +# 打开: +# http://127.0.0.1:5173/?bridge=&token=&workspace= +``` + +范围边界:当前只支持 ACP v1、stdio、三个固定 Scenario 和单次报告;结果是 Axis 场景集内的工程证据,不是 ACP 官方认证,也不评价模型代码质量。 + +录制的本地闭环演示:[`docs/public/demos/axis-acp-devtools.webm`](./docs/public/demos/axis-acp-devtools.webm)。视频展示鉴权 Bridge 场景运行、Run Evidence 切换和 Diagnostic→Sequence 跳转,不包含 Token 或地址栏。 + ## 📦 安装 ```bash diff --git a/apps/acp-devtools/index.html b/apps/acp-devtools/index.html new file mode 100644 index 0000000..1253235 --- /dev/null +++ b/apps/acp-devtools/index.html @@ -0,0 +1,16 @@ + + + + + + + Axis ACP DevTools + + +
+ + + diff --git a/apps/acp-devtools/package.json b/apps/acp-devtools/package.json new file mode 100644 index 0000000..37e6b27 --- /dev/null +++ b/apps/acp-devtools/package.json @@ -0,0 +1,16 @@ +{ + "name": "@axis-ui/acp-devtools", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axis-ui/acp-core": "workspace:*", + "axis-ui": "workspace:*", + "vue": "^3.5.22" + } +} diff --git a/apps/acp-devtools/src/App.vue b/apps/acp-devtools/src/App.vue new file mode 100644 index 0000000..66f646d --- /dev/null +++ b/apps/acp-devtools/src/App.vue @@ -0,0 +1,578 @@ + + + + + diff --git a/apps/acp-devtools/src/bridge-client.ts b/apps/acp-devtools/src/bridge-client.ts new file mode 100644 index 0000000..3c361b2 --- /dev/null +++ b/apps/acp-devtools/src/bridge-client.ts @@ -0,0 +1,74 @@ +import type { AxisAcpTranscript } from '@axis-ui/acp-core' + +export interface BridgeScenarioRequest { + readonly bridgeUrl: string + readonly token: string + readonly targetId: string + readonly scenarioId: string + readonly workspaceRoot: string + readonly timeoutMs?: number +} + +export interface BridgeScenarioReport { + readonly runId: string + readonly scenarioId: string + readonly status: 'passed' | 'failed' + readonly transcript: AxisAcpTranscript +} + +function parseLoopbackBridgeUrl(value: string): URL { + const url = new URL(value) + const loopbackHosts = new Set(['127.0.0.1', 'localhost', '[::1]']) + if (url.protocol !== 'ws:' || !loopbackHosts.has(url.hostname)) { + throw new Error('The DevTools bridge must use ws:// on loopback') + } + return url +} + +export function runBridgeScenario( + request: BridgeScenarioRequest +): Promise { + return new Promise((resolve, reject) => { + const url = parseLoopbackBridgeUrl(request.bridgeUrl) + url.searchParams.set('token', request.token) + const socket = new WebSocket(url) + const requestId = crypto.randomUUID() + const timeout = window.setTimeout(() => { + socket.close() + reject(new Error('Timed out waiting for the local bridge')) + }, request.timeoutMs ?? 125_000) + + const finish = (): void => window.clearTimeout(timeout) + socket.addEventListener('open', () => { + socket.send( + JSON.stringify({ + type: 'scenario/run', + requestId, + targetId: request.targetId, + scenarioId: request.scenarioId, + workspaceRoot: request.workspaceRoot, + }) + ) + }) + socket.addEventListener('message', event => { + const message = JSON.parse(String(event.data)) as { + type?: string + requestId?: string + result?: BridgeScenarioReport + error?: { message?: string } + } + if (message.requestId !== requestId) return + finish() + socket.close() + if (message.type === 'response' && message.result !== undefined) { + resolve(message.result) + } else { + reject(new Error(message.error?.message ?? 'Local bridge failed')) + } + }) + socket.addEventListener('error', () => { + finish() + reject(new Error('Could not connect to the authenticated local bridge')) + }) + }) +} diff --git a/apps/acp-devtools/src/demo-data.ts b/apps/acp-devtools/src/demo-data.ts new file mode 100644 index 0000000..a267d21 --- /dev/null +++ b/apps/acp-devtools/src/demo-data.ts @@ -0,0 +1,390 @@ +import { + createTranscript, + type AxisAcpEvent, + type AxisAcpTranscript, + type Diagnostic, + type ProtocolFrameKind, + type ProtocolTraceFrame, +} from '@axis-ui/acp-core' + +export interface DevToolsRun { + readonly id: string + readonly status: 'passed' | 'failed' + readonly source: 'sample' | 'bridge' | 'import' + readonly transcript: AxisAcpTranscript +} + +type ScenarioId = + | 'normal-prompt-turn' + | 'cancel-during-permission' + | 'capability-method-mismatch' + +const timestamp = '2026-08-20T04:00:00.000Z' + +function frame( + runId: string, + sequence: number, + input: { + kind: ProtocolFrameKind + direction: ProtocolTraceFrame['direction'] + method?: string + requestId?: number | null + payload: Record + } +): ProtocolTraceFrame { + const raw = JSON.stringify(input.payload) + return { + id: `${runId}:trace:${sequence}`, + runId, + connectionId: `${runId}:connection`, + sequence, + timestamp, + protocolVersion: 'v1', + direction: input.direction, + kind: input.kind, + raw, + byteLength: new TextEncoder().encode(raw).byteLength, + parsed: input.payload as ProtocolTraceFrame['parsed'], + method: input.method, + requestId: input.requestId, + } +} + +function eventBase(runId: string, sequence: number) { + return { + id: `${runId}:event:${sequence}`, + runId, + connectionId: `${runId}:connection`, + sessionId: `${runId}:session`, + sequence, + timestamp, + protocolVersion: 'v1' as const, + sourceTraceIds: [] as readonly string[], + } +} + +async function sampleTranscript( + scenarioId: ScenarioId +): Promise { + const runId = `sample-${scenarioId}` + const sessionId = `${runId}:session` + const rawFrames: ProtocolTraceFrame[] = [ + frame(runId, 1, { + kind: 'request', + direction: 'client-to-agent', + method: 'initialize', + requestId: 1, + payload: { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1, clientCapabilities: {} }, + }, + }), + frame(runId, 2, { + kind: 'response', + direction: 'agent-to-client', + requestId: 1, + payload: { + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: 1, + agentInfo: { name: 'Axis deterministic fixture', version: '0.0.0' }, + agentCapabilities: { promptCapabilities: { image: false } }, + }, + }, + }), + frame(runId, 4, { + kind: 'request', + direction: 'client-to-agent', + method: 'session/new', + requestId: 2, + payload: { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: '[REDACTED]', mcpServers: [] }, + }, + }), + frame(runId, 5, { + kind: 'response', + direction: 'agent-to-client', + requestId: 2, + payload: { jsonrpc: '2.0', id: 2, result: { sessionId } }, + }), + frame(runId, 7, { + kind: 'request', + direction: 'client-to-agent', + method: 'session/prompt', + requestId: 3, + payload: { + jsonrpc: '2.0', + id: 3, + method: 'session/prompt', + params: { sessionId, prompt: '[REDACTED]' }, + }, + }), + ] + const events: AxisAcpEvent[] = [ + { + ...eventBase(runId, 3), + sessionId: undefined, + type: 'capability/snapshot', + agentInfo: { name: 'Axis deterministic fixture', version: '0.0.0' }, + agentCapabilities: { promptCapabilities: { image: false } }, + sourceTraceIds: [`${runId}:trace:1`, `${runId}:trace:2`], + }, + { + ...eventBase(runId, 6), + type: 'session/state-changed', + state: 'created', + sourceTraceIds: [`${runId}:trace:4`, `${runId}:trace:5`], + }, + { + ...eventBase(runId, 8), + type: 'session/state-changed', + state: 'prompting', + sourceTraceIds: [`${runId}:trace:7`], + }, + ] + const diagnostics: Diagnostic[] = [] + + if (scenarioId === 'normal-prompt-turn') { + rawFrames.push( + frame(runId, 9, { + kind: 'notification', + direction: 'agent-to-client', + method: 'session/update', + payload: { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Ready.' }, + }, + }, + }, + }), + frame(runId, 11, { + kind: 'response', + direction: 'agent-to-client', + requestId: 3, + payload: { jsonrpc: '2.0', id: 3, result: { stopReason: 'end_turn' } }, + }) + ) + events.push( + { + ...eventBase(runId, 10), + type: 'message/chunk-appended', + messageId: 'message-1', + role: 'agent', + content: { type: 'text', text: 'Ready.' }, + sourceTraceIds: [`${runId}:trace:9`], + }, + { + ...eventBase(runId, 12), + type: 'session/state-changed', + state: 'completed', + stopReason: 'end_turn', + sourceTraceIds: [`${runId}:trace:7`, `${runId}:trace:11`], + } + ) + } else if (scenarioId === 'cancel-during-permission') { + rawFrames.push( + frame(runId, 9, { + kind: 'request', + direction: 'agent-to-client', + method: 'session/request_permission', + requestId: 4, + payload: { + jsonrpc: '2.0', + id: 4, + method: 'session/request_permission', + params: { + sessionId, + toolCall: { toolCallId: 'tool-1', title: 'Run tests' }, + options: [ + { + optionId: 'allow_once', + name: 'Allow once', + kind: 'allow_once', + }, + ], + }, + }, + }), + frame(runId, 12, { + kind: 'notification', + direction: 'client-to-agent', + method: 'session/cancel', + payload: { + jsonrpc: '2.0', + method: 'session/cancel', + params: { sessionId }, + }, + }), + frame(runId, 13, { + kind: 'response', + direction: 'client-to-agent', + requestId: 4, + payload: { + jsonrpc: '2.0', + id: 4, + result: { outcome: { outcome: 'cancelled' } }, + }, + }), + frame(runId, 16, { + kind: 'response', + direction: 'agent-to-client', + requestId: 3, + payload: { jsonrpc: '2.0', id: 3, result: { stopReason: 'cancelled' } }, + }) + ) + events.push( + { + ...eventBase(runId, 10), + type: 'permission/requested', + permissionId: `${runId}:trace:9`, + toolCall: { toolCallId: 'tool-1', title: 'Run tests' }, + options: [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + ], + sourceTraceIds: [`${runId}:trace:9`], + }, + { + ...eventBase(runId, 11), + type: 'session/state-changed', + state: 'cancelling', + }, + { + ...eventBase(runId, 14), + type: 'permission/resolved', + permissionId: `${runId}:trace:9`, + outcome: { outcome: 'cancelled' }, + sourceTraceIds: [`${runId}:trace:9`, `${runId}:trace:13`], + }, + { + ...eventBase(runId, 17), + type: 'session/state-changed', + state: 'cancelled', + stopReason: 'cancelled', + sourceTraceIds: [`${runId}:trace:7`, `${runId}:trace:16`], + } + ) + } else { + rawFrames.push( + frame(runId, 9, { + kind: 'request', + direction: 'agent-to-client', + method: 'terminal/create', + requestId: 4, + payload: { + jsonrpc: '2.0', + id: 4, + method: 'terminal/create', + params: { sessionId, command: 'echo' }, + }, + }), + frame(runId, 11, { + kind: 'response', + direction: 'agent-to-client', + requestId: 3, + payload: { jsonrpc: '2.0', id: 3, result: { stopReason: 'end_turn' } }, + }) + ) + events.push({ + ...eventBase(runId, 12), + type: 'session/state-changed', + state: 'completed', + stopReason: 'end_turn', + sourceTraceIds: [`${runId}:trace:7`, `${runId}:trace:11`], + }) + diagnostics.push({ + id: `${runId}:diagnostic:capability`, + kind: 'capability-contract-mismatch', + runId, + scenarioId, + invariantId: 'omitted-capability-is-unsupported', + severity: 'error', + subject: 'agent', + message: + 'Agent called terminal/create although the capability was omitted', + sequence: 9, + traceIds: [`${runId}:trace:9`], + stateSnapshotIds: [`${runId}:event:8:state:${sessionId}`], + faultInjectionIds: ['unsupported-terminal-request'], + references: [ + { + standard: 'ACP', + protocolVersion: 1, + section: 'Initialization / Client Capabilities', + url: 'https://agentclientprotocol.com/protocol/v1/initialization', + requirement: 'MUST', + }, + ], + }) + } + + return createTranscript({ + run: { + id: runId, + scenarioId, + startedAt: timestamp, + completedAt: '2026-08-20T04:00:01.000Z', + toolkitVersion: '0.0.0', + protocolVersion: 'v1', + }, + target: { + id: 'fixture-agent', + transport: 'stdio', + protocolVersion: 'v1', + args: [], + agentInfo: { name: 'Axis deterministic fixture', version: '0.0.0' }, + }, + clientProfile: { + id: + scenarioId === 'cancel-during-permission' + ? 'permission-only' + : 'minimal', + capabilities: {}, + allowedClientMethods: + scenarioId === 'cancel-during-permission' + ? ['session/update', 'session/request_permission'] + : ['session/update'], + }, + rawFrames, + events, + assertions: [ + { + id: 'session-status', + passed: true, + message: 'Expected terminal state observed', + }, + { + id: 'no-pending-permissions', + passed: true, + message: 'No pending permissions remain', + }, + ], + diagnostics, + }) +} + +export async function loadDemoRuns(): Promise { + const scenarioIds: readonly ScenarioId[] = [ + 'normal-prompt-turn', + 'cancel-during-permission', + 'capability-method-mismatch', + ] + return Promise.all( + scenarioIds.map(async scenarioId => ({ + id: `sample-${scenarioId}`, + status: 'passed' as const, + source: 'sample' as const, + transcript: await sampleTranscript(scenarioId), + })) + ) +} diff --git a/apps/acp-devtools/src/main.ts b/apps/acp-devtools/src/main.ts new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/apps/acp-devtools/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/apps/acp-devtools/src/styles.css b/apps/acp-devtools/src/styles.css new file mode 100644 index 0000000..ed3a966 --- /dev/null +++ b/apps/acp-devtools/src/styles.css @@ -0,0 +1,578 @@ +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + --bg: #080d18; + --surface: #0f172a; + --surface-hover: #1b2942; + --border: #334155; + --border-strong: #475569; + --text: #f8fafc; + --text-muted: #a8b4c7; + --primary: #60a5fa; + --accent: #4ade80; + --warning: #fbbf24; + --danger: #f87171; + --focus: #93c5fd; + background: var(--bg); + color: var(--text); +} + +* { + box-sizing: border-box; +} +html { + min-width: 320px; + background: var(--bg); +} +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: radial-gradient(circle at 50% -20%, #172554 0, var(--bg) 42%); +} +button, +input, +select { + font: inherit; +} +button { + cursor: pointer; +} +button:focus-visible, +input:focus-visible, +select:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.devtools { + min-height: 100vh; + padding: 20px; +} +.skip-link { + position: fixed; + left: 16px; + top: -60px; + z-index: 1000; + padding: 10px 14px; + border-radius: 8px; + background: var(--text); + color: var(--bg); +} +.skip-link:focus { + top: 12px; +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + max-width: 1600px; + margin: 0 auto 16px; +} +.brand-lockup, +.topbar-actions, +.panel-heading, +.lane-legend, +.connection { + display: flex; + align-items: center; +} +.brand-lockup { + gap: 12px; +} +.brand-mark { + width: 38px; + height: 38px; + fill: var(--primary); + filter: drop-shadow(0 0 12px rgb(96 165 250 / 35%)); +} +h1, +h2, +h3, +p { + margin-top: 0; +} +h1 { + margin-bottom: 0; + font-size: clamp(20px, 3vw, 28px); + letter-spacing: -0.03em; +} +h2 { + margin-bottom: 0; + font-size: 16px; +} +h3 { + margin-bottom: 8px; + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.eyebrow { + margin-bottom: 3px; + color: var(--primary); + font: + 600 10px/1.4 ui-monospace, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.topbar-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} +.connection { + min-height: 36px; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + font-size: 12px; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); +} +.connection.online .status-dot { + background: var(--accent); + box-shadow: 0 0 9px rgb(74 222 128 / 60%); +} +.visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + max-width: 1600px; + margin: 0 auto 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgb(15 23 42 / 88%); + overflow: hidden; +} +.metric { + min-width: 0; + padding: 12px 16px; + border-right: 1px solid var(--border); +} +.metric:last-child { + border-right: 0; +} +.metric span { + display: block; + margin-bottom: 3px; + color: var(--text-muted); + font-size: 11px; +} +.metric strong { + font: + 650 18px/1.3 ui-monospace, + monospace; + text-transform: capitalize; +} +.loading-state { + max-width: 1600px; + margin: 0 auto; + padding: 48px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + text-align: center; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(250px, 0.8fr) minmax(460px, 1.55fr) minmax( + 300px, + 1fr + ); + align-items: start; + gap: 12px; + max-width: 1600px; + margin: 0 auto; +} +.left-rail, +.right-rail { + display: grid; + gap: 12px; +} +.panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: 12px; + background: rgb(15 23 42 / 94%); + box-shadow: 0 18px 50px rgb(0 0 0 / 18%); + overflow: hidden; +} +.panel-heading { + justify-content: space-between; + gap: 12px; + min-height: 60px; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} +.panel-heading p { + margin-bottom: 2px; +} +.mode-badge, +.sequence-readout { + padding: 4px 7px; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text-muted); + font: + 600 10px/1.3 ui-monospace, + monospace; +} + +.launch-form { + display: grid; + gap: 12px; + padding: 14px; +} +.field { + display: grid; + gap: 6px; +} +.field label, +.search-label { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} +.field select { + width: 100%; + min-height: 44px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: #0b1220; + color: var(--text); +} +.launch-form .ax-input__inner { + min-height: 44px; + color: var(--text); + background: #0b1220; +} +.launch-form .ax-button { + min-height: 44px; + width: 100%; +} +.helper, +.status-message, +.error-message { + margin: 0; + padding: 0 14px 14px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} +.error-message { + color: #fecaca; +} +.status-message { + color: #bfdbfe; +} + +.run-list { + display: grid; + gap: 6px; + padding: 8px; +} +.run-card { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 8px; + min-height: 58px; + padding: 9px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + text-align: left; + transition: + background-color 180ms ease, + border-color 180ms ease; +} +.run-card:hover { + background: var(--surface-hover); +} +.run-card.selected { + border-color: var(--primary); + background: rgb(37 99 235 / 14%); +} +.run-card strong { + min-width: 0; + overflow: hidden; + font: + 600 12px/1.4 ui-monospace, + monospace; + text-overflow: ellipsis; +} +.run-card small { + grid-column: 2; + color: var(--text-muted); +} +.run-status { + align-self: start; + padding: 2px 5px; + border-radius: 4px; + color: #bbf7d0; + background: #14532d; + font: + 700 9px/1.4 ui-monospace, + monospace; + text-transform: uppercase; +} +.run-status.failed { + color: #fecaca; + background: #7f1d1d; +} + +.timeline-panel { + padding-bottom: 12px; +} +.timeline-heading { + border-bottom: 0; +} +.timeline-panel > .ax-input { + margin: 0 12px 8px; + width: calc(100% - 24px); +} +.timeline-panel .ax-input__inner { + min-height: 40px; + color: var(--text); + background: #0b1220; +} +.search-label { + display: block; + margin: 0 12px 6px; +} +.lane-legend { + gap: 16px; + min-height: 32px; + padding: 0 14px; + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 11px; +} +.lane-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.lane-dot { + width: 8px; + height: 8px; + border-radius: 2px; +} +.lane-dot.wire { + background: var(--primary); +} +.lane-dot.event { + background: var(--accent); +} +.timeline-list { + width: 100%; +} +.timeline-row { + display: grid; + grid-template-columns: 52px 52px minmax(0, 1fr); + align-items: center; + width: 100%; + height: 48px; + padding: 0 12px; + border: 0; + border-left: 3px solid transparent; + border-bottom: 1px solid rgb(51 65 85 / 65%); + background: transparent; + color: var(--text); + text-align: left; + transition: background-color 160ms ease; +} +.timeline-row:hover { + background: var(--surface-hover); +} +.timeline-row.selected { + background: rgb(37 99 235 / 16%); +} +.timeline-row.lane-wire { + border-left-color: var(--primary); +} +.timeline-row.lane-event { + border-left-color: var(--accent); +} +.sequence, +.lane { + color: var(--text-muted); + font: + 600 10px/1.3 ui-monospace, + monospace; + text-transform: uppercase; +} +.row-summary { + display: grid; + min-width: 0; +} +.row-summary strong, +.row-summary small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row-summary strong { + font: + 550 12px/1.4 ui-monospace, + monospace; +} +.row-summary small { + color: var(--text-muted); + font-size: 10px; +} +.empty-state, +.empty-copy { + padding: 18px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + +.inspector-section { + padding: 12px; + border-bottom: 1px solid var(--border); +} +pre { + max-height: 220px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid #24324a; + border-radius: 7px; + background: #050914; + color: #dbeafe; + font: + 11px/1.55 ui-monospace, + monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.capability-panel .ax-tree { + padding: 8px; +} +.capability-panel .ax-vl { + width: 100%; +} +.diagnostic-card { + display: grid; + gap: 5px; + width: calc(100% - 16px); + min-height: 88px; + margin: 8px; + padding: 10px; + border: 1px solid #7f1d1d; + border-radius: 8px; + background: rgb(127 29 29 / 16%); + color: var(--text); + text-align: left; +} +.diagnostic-card:hover { + background: rgb(127 29 29 / 28%); +} +.diagnostic-card strong { + color: #fecaca; + font: + 600 11px/1.4 ui-monospace, + monospace; +} +.diagnostic-card span, +.diagnostic-card small { + font-size: 11px; + line-height: 1.4; +} +.diagnostic-meta { + color: var(--danger); + text-transform: uppercase; +} +footer { + display: flex; + justify-content: space-between; + gap: 16px; + max-width: 1600px; + margin: 14px auto 0; + color: var(--text-muted); + font-size: 11px; +} + +@media (max-width: 1180px) { + .workspace-grid { + grid-template-columns: 280px minmax(0, 1fr); + } + .right-rail { + grid-column: 1 / -1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 760px) { + .devtools { + padding: 12px; + } + .topbar { + align-items: flex-start; + flex-direction: column; + } + .topbar-actions { + justify-content: flex-start; + } + .metric-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .metric { + border-bottom: 1px solid var(--border); + } + .workspace-grid { + grid-template-columns: minmax(0, 1fr); + } + .right-rail { + grid-column: auto; + grid-template-columns: minmax(0, 1fr); + } + .timeline-row { + grid-template-columns: 44px 46px minmax(0, 1fr); + } + .topbar-actions .ax-button { + min-height: 44px; + } + footer { + flex-direction: column; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/acp-devtools/test/browser/environment.browser.spec.ts b/apps/acp-devtools/test/browser/environment.browser.spec.ts new file mode 100644 index 0000000..66e971b --- /dev/null +++ b/apps/acp-devtools/test/browser/environment.browser.spec.ts @@ -0,0 +1,27 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import App from '../../src/App.vue' + +describe('ACP DevTools real browser boundary', () => { + it('mounts the responsive timeline and replay inspector in Chromium', async () => { + const wrapper = mount(App, { attachTo: document.body }) + await flushPromises() + await vi.waitFor(() => + expect(wrapper.findAll('.timeline-row').length).toBeGreaterThan(0) + ) + + expect(wrapper.get('h1').text()).toBe('Axis Protocol Workbench') + expect(wrapper.findAll('.timeline-row').length).toBeGreaterThan(0) + await wrapper.findAll('.timeline-row').at(-1)?.trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.get('[data-testid="state-json"]').text()).toContain( + 'session' + ) + expect( + Number.parseFloat( + getComputedStyle(wrapper.get('.devtools').element).minHeight + ) + ).toBeGreaterThanOrEqual(window.innerHeight) + wrapper.unmount() + }) +}) diff --git a/apps/acp-devtools/test/unit/environment.spec.ts b/apps/acp-devtools/test/unit/environment.spec.ts new file mode 100644 index 0000000..8b6d690 --- /dev/null +++ b/apps/acp-devtools/test/unit/environment.spec.ts @@ -0,0 +1,76 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import App from '../../src/App.vue' +import { runBridgeScenario } from '../../src/bridge-client.js' + +describe('ACP DevTools workbench', () => { + it('renders three scenario artifacts with real Axis-UI components', async () => { + const wrapper = mount(App) + await flushPromises() + await vi.waitFor(() => expect(wrapper.findAll('.run-card')).toHaveLength(3)) + + expect( + wrapper + .get('[data-product="axis-acp-devtools"]') + .attributes('data-product') + ).toBe('axis-acp-devtools') + expect( + wrapper + .findAll('[data-scenario]') + .map(node => node.attributes('data-scenario')) + ).toEqual([ + 'normal-prompt-turn', + 'cancel-during-permission', + 'capability-method-mismatch', + ]) + expect(wrapper.find('.ax-form').exists()).toBe(true) + expect(wrapper.find('.ax-vl').exists()).toBe(true) + expect(wrapper.find('.ax-tree').exists()).toBe(true) + }) + + it('maps a diagnostic sequence to a generic virtual-list index', async () => { + const wrapper = mount(App) + await flushPromises() + await vi.waitFor(() => expect(wrapper.findAll('.run-card')).toHaveLength(3)) + await wrapper + .get('[data-scenario="capability-method-mismatch"]') + .trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.get('.diagnostic-card').text()).toContain( + 'omitted-capability-is-unsupported' + ) + await wrapper.get('.diagnostic-card').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.get('.sequence-readout').text()).toBe('SEQ 9') + expect(wrapper.get('[data-testid="raw-json"]').text()).toContain( + 'terminal/create' + ) + }) + + it('converges the real agent launcher to its supported scenario', async () => { + const wrapper = mount(App) + await flushPromises() + + await wrapper.get('#scenario-select').setValue('cancel-during-permission') + await wrapper.get('#target-select').setValue('opencode') + await wrapper.vm.$nextTick() + + expect( + (wrapper.get('#scenario-select').element as HTMLSelectElement).value + ).toBe('normal-prompt-turn') + }) + + it('refuses a non-loopback bridge URL before opening a socket', async () => { + await expect( + runBridgeScenario({ + bridgeUrl: 'wss://remote.example/collect', + token: 'not-a-real-token', + targetId: 'fixture-agent', + scenarioId: 'normal-prompt-turn', + workspaceRoot: '/private/workspace', + }) + ).rejects.toThrow('must use ws:// on loopback') + }) +}) diff --git a/apps/acp-devtools/tsconfig.json b/apps/acp-devtools/tsconfig.json new file mode 100644 index 0000000..6a6bb5b --- /dev/null +++ b/apps/acp-devtools/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/acp-devtools/vite.config.ts b/apps/acp-devtools/vite.config.ts new file mode 100644 index 0000000..e021223 --- /dev/null +++ b/apps/acp-devtools/vite.config.ts @@ -0,0 +1,7 @@ +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [vue()], + server: { host: '127.0.0.1', port: 5173, strictPort: true }, +}) diff --git a/docs/public/demos/axis-acp-devtools.webm b/docs/public/demos/axis-acp-devtools.webm new file mode 100644 index 0000000..3db91ac Binary files /dev/null and b/docs/public/demos/axis-acp-devtools.webm differ diff --git a/docs/src/components/virtual-list.md b/docs/src/components/virtual-list.md index a600d5a..d222d8f 100644 --- a/docs/src/components/virtual-list.md +++ b/docs/src/components/virtual-list.md @@ -38,25 +38,25 @@ ### Props -| 参数 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| items | 列表数据 | `any[]` | `[]` | -| size | 每项的固定高度(px)。传 > 0 启用固定高度模式 | `number` | `0` | -| estimatedSize | 预估高度(px)。`size` 为 0 时传 > 0 启用动态高度模式 | `number` | `0` | -| remain | 可见区域显示的项数 | `number` | `8` | +| 参数 | 说明 | 类型 | 默认值 | +| ------------- | ----------------------------------------------------- | -------- | ------ | +| items | 列表数据 | `any[]` | `[]` | +| size | 每项的固定高度(px)。传 > 0 启用固定高度模式 | `number` | `0` | +| estimatedSize | 预估高度(px)。`size` 为 0 时传 > 0 启用动态高度模式 | `number` | `0` | +| remain | 可见区域显示的项数 | `number` | `8` | ### 模式判断 -| `size` | `estimatedSize` | 模式 | -| --- | --- | --- | -| > 0 | 任意 | 固定高度(向后兼容) | -| 0 | > 0 | 动态高度 | -| 0 | 0 | 回退到默认 32px | +| `size` | `estimatedSize` | 模式 | +| ------ | --------------- | -------------------- | +| > 0 | 任意 | 固定高度(向后兼容) | +| 0 | > 0 | 动态高度 | +| 0 | 0 | 回退到默认 32px | ### Slots -| 插槽名 | 说明 | 参数 | -| --- | --- | --- | +| 插槽名 | 说明 | 参数 | +| ------- | ---------------- | --------------- | | default | 自定义列表项内容 | `{ node: any }` | ## 使用示例 @@ -103,6 +103,31 @@ const items = Array.from({ length: 10000 }, (_, i) => ({ ``` +### 定位到指定索引 + +组件实例暴露 `scrollToIndex(index, alignment?)`,用于日志、Trace 等大列表从业务标识映射到数组索引后定位。`alignment` 支持 `auto`、`start`、`center` 和 `end`,默认 `auto`:条目已完整可见时不滚动。 + +```vue + + + +``` + +`scrollToIndex` 只处理通用列表坐标,不理解 ACP Sequence。DevTools 应先用自己的 Trace/Event 索引把 `sequence` 转成当前筛选结果中的数组下标,再调用组件方法;这样协议语义不会进入 Axis-UI。 + ## 注意事项 > [!IMPORTANT] diff --git a/fixtures/acp-agents/bin/fixture-agent.mjs b/fixtures/acp-agents/bin/fixture-agent.mjs new file mode 100644 index 0000000..b60a216 --- /dev/null +++ b/fixtures/acp-agents/bin/fixture-agent.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +import * as acp from '@agentclientprotocol/sdk' +import { Readable, Writable } from 'node:stream' + +const shouldCrashAfterInitialize = process.argv.includes( + '--crash-after-initialize' +) +const shouldEmitStdoutNoise = process.argv.includes('--stdout-noise') +const shouldEmitStderr = process.argv.includes('--stderr-marker') +const shouldRequestPermission = process.argv.includes('--permission-prompt') +const shouldCrashDuringPrompt = process.argv.includes('--crash-during-prompt') +const shouldCallUnsupportedTerminal = process.argv.includes( + '--capability-method-mismatch' +) + +if (shouldEmitStdoutNoise) process.stdout.write('fixture stdout noise\n') +if (shouldEmitStderr) process.stderr.write('fixture stderr marker\n') + +let sessionCounter = 0 +const cancelledSessions = new Set() + +const app = acp + .agent({ name: 'axis-deterministic-fixture' }) + .onRequest(acp.methods.agent.initialize, () => { + if (shouldCrashAfterInitialize) setTimeout(() => process.exit(17), 25) + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + agentInfo: { + name: 'axis-deterministic-fixture', + version: '0.0.0-fixture', + }, + } + }) + .onRequest(acp.methods.agent.session.new, () => { + sessionCounter += 1 + return { sessionId: `fixture-session-${sessionCounter}` } + }) + .onRequest(acp.methods.agent.session.prompt, async context => { + const { sessionId } = context.params + await context.client.notify(acp.methods.client.session.update, { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + messageId: `message-${sessionCounter}`, + content: { type: 'text', text: 'fixture response' }, + }, + }) + + if (shouldCrashDuringPrompt) { + setTimeout(() => process.exit(17), 20) + await new Promise(() => undefined) + } + + if (shouldRequestPermission) { + await context.client.notify(acp.methods.client.session.update, { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'permission-tool', + title: 'Run deterministic command', + kind: 'execute', + status: 'pending', + }, + }) + const permission = await context.client.request( + acp.methods.client.session.requestPermission, + { + sessionId, + toolCall: { + toolCallId: 'permission-tool', + title: 'Run deterministic command', + kind: 'execute', + status: 'pending', + }, + options: [ + { + kind: 'allow_once', + name: 'Allow once', + optionId: 'allow', + }, + { + kind: 'reject_once', + name: 'Reject once', + optionId: 'reject', + }, + ], + } + ) + if ( + permission.outcome.outcome === 'cancelled' || + cancelledSessions.has(sessionId) + ) { + return { stopReason: 'cancelled' } + } + await context.client.notify(acp.methods.client.session.update, { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'permission-tool', + status: 'completed', + }, + }) + } + + if (shouldCallUnsupportedTerminal) { + try { + await context.client.request(acp.methods.client.terminal.create, { + sessionId, + command: 'echo', + args: ['capability mismatch'], + }) + } catch { + // The deterministic fault is the unsupported request itself. + } + } + + return { + stopReason: cancelledSessions.has(sessionId) ? 'cancelled' : 'end_turn', + } + }) + .onNotification(acp.methods.agent.session.cancel, context => { + cancelledSessions.add(context.params.sessionId) + }) + +const stream = acp.ndJsonStream( + Writable.toWeb(process.stdout), + Readable.toWeb(process.stdin) +) + +const connection = app.connect(stream) +await connection.closed diff --git a/fixtures/acp-agents/package.json b/fixtures/acp-agents/package.json new file mode 100644 index 0000000..5b9acb3 --- /dev/null +++ b/fixtures/acp-agents/package.json @@ -0,0 +1,22 @@ +{ + "name": "@axis-ui/acp-fixture-agents", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "axis-acp-fixture-agent": "./bin/fixture-agent.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.3.0" + } +} diff --git a/fixtures/acp-agents/src/index.ts b/fixtures/acp-agents/src/index.ts new file mode 100644 index 0000000..8442a8a --- /dev/null +++ b/fixtures/acp-agents/src/index.ts @@ -0,0 +1,2 @@ +// The executable fixture agent owns deterministic protocol behavior. +export {} diff --git a/fixtures/acp-agents/tsconfig.build.json b/fixtures/acp-agents/tsconfig.build.json new file mode 100644 index 0000000..f5bbab6 --- /dev/null +++ b/fixtures/acp-agents/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + } +} diff --git a/fixtures/acp-agents/tsconfig.json b/fixtures/acp-agents/tsconfig.json new file mode 100644 index 0000000..e5883b0 --- /dev/null +++ b/fixtures/acp-agents/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 6f0cc57..00c86a8 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,22 @@ "scripts": { "dev": "pnpm -C play dev", "build": "pnpm build:all", - "build:all": "pnpm -r --filter \"./packages/**\" build", - "check:publish": "pnpm -r --filter \"./packages/**\" exec publint && attw --pack packages/components --profile esm-only --exclude-entrypoints ./dist/style.css && attw --pack packages/utils --profile esm-only", + "build:all": "pnpm -r --if-present --filter \"./packages/**\" --filter \"./apps/**\" --filter \"./fixtures/acp-agents\" run build", + "check:publish": "pnpm -r --filter axis-ui --filter @axis-ui/theme-chalk --filter @axis-ui/utils exec publint && attw --pack packages/components --profile esm-only --exclude-entrypoints ./dist/style.css && attw --pack packages/utils --profile esm-only", "lint": "eslint", "lint:fix": "eslint --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "type-check": "vue-tsc --noEmit", + "type-check": "pnpm type-check:node && pnpm type-check:vue", + "type-check:node": "pnpm --filter @axis-ui/acp-core run type-check && pnpm --filter @axis-ui/acp-core run build && pnpm --filter @axis-ui/acp-harness run type-check && pnpm --filter @axis-ui/acp-harness run build && pnpm -r --filter @axis-ui/acp-cli --filter @axis-ui/acp-fixture-agents run type-check", + "type-check:vue": "vue-tsc --noEmit && pnpm --filter @axis-ui/acp-devtools run type-check", "test": "vitest", + "test:unit": "vitest run --project axis-ui --project acp-node --project acp-devtools", + "test:contract": "vitest run --project contract", + "test:scenario": "vitest run --project scenario", + "test:replay": "vitest run --project replay", + "test:security": "vitest run --project security", + "test:browser": "vitest run --project browser", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", @@ -29,7 +37,8 @@ "prepare": "husky", "changeset": "changeset", "version": "changeset version", - "release": "pnpm build:all && pnpm -r publish --access public --no-git-checks && changeset tag" + "release:packages": "pnpm -r --filter axis-ui --filter @axis-ui/theme-chalk --filter @axis-ui/utils publish --access public --no-git-checks", + "release": "pnpm build:all && pnpm release:packages && changeset tag" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", @@ -40,8 +49,11 @@ "@eslint/js": "^9.38.0", "@eslint/json": "^0.13.2", "@iconify/json": "^2.2.403", + "@types/node": "^20.19.43", + "@types/ws": "8.18.1", "@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue-jsx": "^5.1.1", + "@vitest/browser-playwright": "4.0.4", "@vitest/coverage-v8": "^4.0.4", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.6.0", @@ -55,6 +67,7 @@ "husky": "^9.1.7", "jsdom": "^27.0.1", "lint-staged": "^16.2.6", + "playwright": "^1.62.1", "prettier": "^3.6.2", "publint": "^0.3.22", "sass": "^1.93.2", @@ -77,5 +90,10 @@ "*.{css,scss,less,md,json}": [ "prettier --write" ] + }, + "pnpm": { + "overrides": { + "@types/node": "20.19.43" + } } } diff --git a/packages/acp-cli/package.json b/packages/acp-cli/package.json new file mode 100644 index 0000000..8dd03e2 --- /dev/null +++ b/packages/acp-cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@axis-ui/acp-cli", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "axis-acp": "./dist/main.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axis-ui/acp-core": "workspace:*", + "@axis-ui/acp-harness": "workspace:*" + } +} diff --git a/packages/acp-cli/src/cli.ts b/packages/acp-cli/src/cli.ts new file mode 100644 index 0000000..18ec3e4 --- /dev/null +++ b/packages/acp-cli/src/cli.ts @@ -0,0 +1,358 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + parseTranscript, + serializeTranscript, + TranscriptReplay, +} from '@axis-ui/acp-core' +import { + AcpHarness, + coreScenarios, + createOpenCodeTarget, + findExecutable, + LocalBridge, + ScenarioRunner, + TargetRegistry, + type BridgeMessage, + type CoreScenarioId, + type TargetDefinition, +} from '@axis-ui/acp-harness' +import { renderScenarioReport } from './report.js' + +export interface CliIo { + readonly stdout: (message: string) => void + readonly stderr: (message: string) => void +} + +const defaultIo: CliIo = { + stdout: message => console.log(message), + stderr: message => console.error(message), +} + +interface ParsedArguments { + readonly command?: string + readonly options: Readonly> +} + +export async function runCli( + argv: readonly string[], + io: CliIo = defaultIo +): Promise { + try { + const parsed = parseArguments(argv) + if (parsed.command === undefined || parsed.command === 'help') { + io.stdout(helpText) + return 0 + } + if (parsed.command === 'run') return await runScenario(parsed.options, io) + if (parsed.command === 'inspect') { + return await inspectTarget(parsed.options, io) + } + if (parsed.command === 'replay') { + return await replayArtifact(parsed.options, io) + } + if (parsed.command === 'serve') { + return await serveBridge(parsed.options, io) + } + throw new Error(`Unknown command: ${parsed.command}`) + } catch (error) { + io.stderr(error instanceof Error ? error.message : String(error)) + return 1 + } +} + +async function runScenario( + options: Readonly>, + io: CliIo +): Promise { + const workspaceRoot = resolve(options.workspace ?? process.cwd()) + const scenarioId = (options.scenario ?? + 'normal-prompt-turn') as CoreScenarioId + const definition = coreScenarios[scenarioId] + if (definition === undefined) + throw new Error(`Unknown scenario: ${scenarioId}`) + const targetId = options.target ?? 'fixture-agent' + const registry = await createRegistry(targetId, workspaceRoot) + const timeoutMs = parsePositiveInteger( + options.timeout, + targetId === 'opencode' ? 120_000 : 2_000 + ) + const runner = new ScenarioRunner({ + registry, + targetId, + workspaceRoot, + timeoutMs, + }) + const report = await runner.run(definition) + const outputDirectory = resolve(options.output ?? 'artifacts/runs') + const stem = `${targetId}-${scenarioId}` + const transcriptPath = resolve( + options.json ?? resolve(outputDirectory, `${stem}.axis-acp.json`) + ) + const reportPath = resolve( + options.html ?? resolve(outputDirectory, `${stem}.html`) + ) + await Promise.all([ + writeText(transcriptPath, serializeTranscript(report.transcript)), + writeText( + reportPath, + renderScenarioReport(report, basename(transcriptPath)) + ), + ]) + io.stdout( + JSON.stringify( + { + status: report.status, + targetId, + scenarioId, + diagnostics: report.diagnostics.length, + traceCount: report.traceCount, + eventCount: report.eventCount, + transcriptPath, + reportPath, + }, + null, + 2 + ) + ) + return report.status === 'passed' ? 0 : 1 +} + +async function inspectTarget( + options: Readonly>, + io: CliIo +): Promise { + const workspaceRoot = resolve(options.workspace ?? process.cwd()) + const targetId = options.target ?? 'opencode' + const registry = await createRegistry(targetId, workspaceRoot) + const harness = new AcpHarness(registry) + let targetHandleId: string | undefined + try { + const target = await harness.startTarget({ targetId, workspaceRoot }) + targetHandleId = target.id + const initialization = await harness.initialize(target.id) + const capability = harness.events.find( + event => event.type === 'capability/snapshot' + ) + await harness.stopTarget(target.id) + targetHandleId = undefined + io.stdout( + JSON.stringify( + { + targetId, + protocolVersion: initialization.response.protocolVersion, + agentInfo: capability?.agentInfo, + agentCapabilities: capability?.agentCapabilities, + traceCount: harness.trace.length, + eventCount: harness.events.length, + activeProcesses: harness.processes.list().length, + }, + null, + 2 + ) + ) + return 0 + } finally { + if (targetHandleId !== undefined) { + await harness.stopTarget(targetHandleId).catch(() => undefined) + } + await harness.dispose() + } +} + +async function replayArtifact( + options: Readonly>, + io: CliIo +): Promise { + const input = options.input + if (input === undefined) throw new Error('replay requires --input ') + const transcript = parseTranscript(await readFile(resolve(input), 'utf8')) + const replay = new TranscriptReplay(transcript) + const snapshot = replay.playToEnd() + const valid = await replay.verifyIntegrity() + io.stdout( + JSON.stringify( + { + runId: transcript.run.id, + scenarioId: transcript.run.scenarioId, + status: snapshot.status, + sequence: snapshot.currentSequence, + stateHashes: await replay.stateHashes(), + integrityValid: valid, + }, + null, + 2 + ) + ) + return valid ? 0 : 1 +} + +async function createRegistry( + targetId: string, + workspaceRoot: string +): Promise { + const definition = targetDefinition(targetId) + return TargetRegistry.create([definition], [workspaceRoot]) +} + +export function targetDefinition(targetId: string): TargetDefinition { + if (targetId === 'fixture-agent') { + return { + id: targetId, + command: process.execPath, + fixedArgs: [ + fileURLToPath( + new URL( + '../../../fixtures/acp-agents/bin/fixture-agent.mjs', + import.meta.url + ) + ), + ], + allowedArgs: [ + '--crash-after-initialize', + '--stdout-noise', + '--stderr-marker', + '--permission-prompt', + '--crash-during-prompt', + '--capability-method-mismatch', + ], + environmentAllowlist: ['PATH'], + maxProcesses: 2, + expectedProtocolVersion: 'v1', + } + } + if (targetId === 'opencode') { + const command = process.env.AXIS_OPENCODE_PATH ?? findExecutable('opencode') + if (command === undefined) { + throw new Error( + 'OpenCode was not found; set AXIS_OPENCODE_PATH to an absolute executable path' + ) + } + return createOpenCodeTarget(command) + } + throw new Error(`Unknown target: ${targetId}`) +} + +export function createScenarioBridgeHandler(): ( + message: BridgeMessage +) => Promise { + return async message => { + if (message.type !== 'scenario/run') { + throw new Error(`Unsupported bridge operation: ${message.type}`) + } + if (!Object.hasOwn(coreScenarios, message.scenarioId)) { + throw new Error(`Unknown scenario: ${message.scenarioId}`) + } + if ( + message.targetId === 'opencode' && + message.scenarioId !== 'normal-prompt-turn' + ) { + throw new Error('OpenCode is limited to normal-prompt-turn') + } + const definition = coreScenarios[message.scenarioId as CoreScenarioId] + const registry = await createRegistry( + message.targetId, + resolve(message.workspaceRoot) + ) + return new ScenarioRunner({ + registry, + targetId: message.targetId, + workspaceRoot: resolve(message.workspaceRoot), + timeoutMs: message.targetId === 'opencode' ? 120_000 : 2_000, + }).run(definition) + } +} + +async function serveBridge( + options: Readonly>, + io: CliIo +): Promise { + const bridge = new LocalBridge({ + host: options.host ?? '127.0.0.1', + port: parseNonNegativeInteger(options.port, 0), + allowedOrigins: [options.origin ?? 'http://127.0.0.1:5173'], + token: options.token, + onMessage: createScenarioBridgeHandler(), + }) + const address = await bridge.start() + io.stdout( + JSON.stringify( + { + url: address.url, + token: address.token, + origin: options.origin ?? 'http://127.0.0.1:5173', + }, + null, + 2 + ) + ) + await new Promise(resolveStop => { + const stop = (): void => { + process.off('SIGINT', stop) + process.off('SIGTERM', stop) + resolveStop() + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + }) + await bridge.stop() + return 0 +} + +async function writeText(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${content}\n`, 'utf8') +} + +function parsePositiveInteger( + value: string | undefined, + fallback: number +): number { + if (value === undefined) return fallback + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`Expected a positive integer, received: ${value}`) + } + return parsed +} + +function parseNonNegativeInteger( + value: string | undefined, + fallback: number +): number { + if (value === undefined) return fallback + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65_535) { + throw new Error(`Expected a valid port, received: ${value}`) + } + return parsed +} + +function parseArguments(argv: readonly string[]): ParsedArguments { + const [command, ...rest] = argv + const options: Record = {} + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index] + const value = rest[index + 1] + if (key === undefined || !key.startsWith('--') || value === undefined) { + throw new Error(`Invalid option near: ${key ?? '(end)'}`) + } + options[key.slice(2)] = value + } + return { command, options } +} + +const helpText = `Axis ACP DevKit + +Usage: + axis-acp run --target fixture-agent --scenario normal-prompt-turn --workspace . + axis-acp inspect --target opencode --workspace . + axis-acp replay --input artifacts/runs/fixture-agent-normal-prompt-turn.axis-acp.json + axis-acp serve --origin http://127.0.0.1:5173 + +Commands: + run Execute one of the three fixed scenarios and write JSON + HTML. + inspect Initialize a registered target and print capabilities without a model turn. + replay Restore recorded semantic events and verify final state hashes. + serve Start the authenticated loopback bridge for the DevTools UI.` diff --git a/packages/acp-cli/src/index.ts b/packages/acp-cli/src/index.ts new file mode 100644 index 0000000..62b2db6 --- /dev/null +++ b/packages/acp-cli/src/index.ts @@ -0,0 +1,2 @@ +export * from './cli.js' +export * from './report.js' diff --git a/packages/acp-cli/src/main.ts b/packages/acp-cli/src/main.ts new file mode 100644 index 0000000..a566d17 --- /dev/null +++ b/packages/acp-cli/src/main.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCli } from './cli.js' + +process.exitCode = await runCli(process.argv.slice(2)) diff --git a/packages/acp-cli/src/report.ts b/packages/acp-cli/src/report.ts new file mode 100644 index 0000000..ae908e9 --- /dev/null +++ b/packages/acp-cli/src/report.ts @@ -0,0 +1,109 @@ +import type { ScenarioReport } from '@axis-ui/acp-harness' + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function json(value: unknown): string { + return escapeHtml(JSON.stringify(value, null, 2)) +} + +export function renderScenarioReport( + report: ScenarioReport, + transcriptFileName: string +): string { + const diagnostics = report.diagnostics + .map( + item => ` + ${escapeHtml(item.severity)} + ${escapeHtml(item.kind)} + ${escapeHtml(item.subject)} + ${escapeHtml(item.invariantId)} + ${escapeHtml(item.message)} + ` + ) + .join('') + const assertions = report.assertions + .map( + assertion => `
  • + ${assertion.passed ? 'PASS' : 'FAIL'} + ${escapeHtml(assertion.id)} + ${escapeHtml(assertion.message)} +
  • ` + ) + .join('') + + return ` + + + + + Axis ACP Report · ${escapeHtml(report.scenarioId)} + + + +
    +
    +
    Axis ACP DevKit · Single Run
    +

    ${escapeHtml(report.scenarioId)}

    +

    ${escapeHtml(report.status.toUpperCase())}

    +

    This is an Axis scenario result, not official ACP certification.

    + Download redacted transcript +
    +
    +

    Run evidence

    +
    +
    Raw frames${report.traceCount}
    +
    Semantic events${report.eventCount}
    +
    Diagnostics${report.diagnostics.length}
    +
    State snapshots${report.stateSnapshots.length}
    +
    +
    +
    +

    Assertions

    +
      ${assertions}
    +
    +
    +

    Diagnostics

    + ${diagnostics.length === 0 ? '

    No diagnostics.

    ' : `${diagnostics}
    SeverityKindSubjectInvariantMessage
    `} +
    +
    +

    Client profile

    +
    ${json(report.profile)}
    +
    +
    +

    State hashes

    +
    ${json(report.transcript.integrity.stateHashes)}
    +
    +
    + +` +} diff --git a/packages/acp-cli/tsconfig.build.json b/packages/acp-cli/tsconfig.build.json new file mode 100644 index 0000000..f5bbab6 --- /dev/null +++ b/packages/acp-cli/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + } +} diff --git a/packages/acp-cli/tsconfig.json b/packages/acp-cli/tsconfig.json new file mode 100644 index 0000000..e5883b0 --- /dev/null +++ b/packages/acp-cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/acp-core/package.json b/packages/acp-core/package.json new file mode 100644 index 0000000..f891fc7 --- /dev/null +++ b/packages/acp-core/package.json @@ -0,0 +1,16 @@ +{ + "name": "@axis-ui/acp-core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit -p tsconfig.json" + } +} diff --git a/packages/acp-core/src/axis-event.ts b/packages/acp-core/src/axis-event.ts new file mode 100644 index 0000000..84bf8b9 --- /dev/null +++ b/packages/acp-core/src/axis-event.ts @@ -0,0 +1,90 @@ +export type AxisProtocolVersion = 'v1' + +export interface AxisAcpEventBase { + readonly id: string + readonly runId: string + readonly connectionId: string + readonly sessionId?: string + readonly sequence: number + readonly timestamp: string + readonly protocolVersion: AxisProtocolVersion + readonly sourceTraceIds: readonly string[] +} + +export interface ConnectionStateChangedEvent extends AxisAcpEventBase { + readonly type: 'connection/state-changed' + readonly state: 'connected' | 'initialized' | 'closed' | 'crashed' +} + +export interface CapabilitySnapshotEvent extends AxisAcpEventBase { + readonly type: 'capability/snapshot' + readonly agentCapabilities: Readonly> + readonly agentInfo?: Readonly> +} + +export interface ProcessExitedEvent extends AxisAcpEventBase { + readonly type: 'process/exited' + readonly code: number | null + readonly signal: string | null +} + +export type SessionStatus = + | 'created' + | 'prompting' + | 'cancelling' + | 'completed' + | 'cancelled' + | 'failed' + | 'crashed' + +export interface SessionStateChangedEvent extends AxisAcpEventBase { + readonly type: 'session/state-changed' + readonly sessionId: string + readonly state: SessionStatus + readonly stopReason?: string + readonly error?: string +} + +export interface MessageChunkAppendedEvent extends AxisAcpEventBase { + readonly type: 'message/chunk-appended' + readonly sessionId: string + readonly messageId: string + readonly role: 'user' | 'agent' | 'thought' + readonly content: unknown +} + +export interface ToolCallUpsertEvent extends AxisAcpEventBase { + readonly type: 'tool-call/upsert' + readonly sessionId: string + readonly toolCallId: string + readonly patch: Readonly> +} + +export interface PermissionRequestedEvent extends AxisAcpEventBase { + readonly type: 'permission/requested' + readonly sessionId: string + readonly permissionId: string + readonly toolCall: Readonly> + readonly options: readonly Readonly>[] +} + +export interface PermissionResolvedEvent extends AxisAcpEventBase { + readonly type: 'permission/resolved' + readonly sessionId: string + readonly permissionId: string + readonly outcome: + | { readonly outcome: 'cancelled' } + | { readonly outcome: 'selected'; readonly optionId: string } +} + +export type AxisAcpEvent = + | ConnectionStateChangedEvent + | CapabilitySnapshotEvent + | ProcessExitedEvent + | SessionStateChangedEvent + | MessageChunkAppendedEvent + | ToolCallUpsertEvent + | PermissionRequestedEvent + | PermissionResolvedEvent + +export type AxisAcpEventListener = (event: AxisAcpEvent) => void diff --git a/packages/acp-core/src/canonical-json.ts b/packages/acp-core/src/canonical-json.ts new file mode 100644 index 0000000..743d180 --- /dev/null +++ b/packages/acp-core/src/canonical-json.ts @@ -0,0 +1,49 @@ +function normalize(value: unknown, seen: WeakSet): unknown { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError('Canonical JSON does not support non-finite numbers') + } + return value + } + if (value === undefined) return undefined + if (typeof value !== 'object') { + throw new TypeError(`Canonical JSON does not support ${typeof value}`) + } + if (seen.has(value)) + throw new TypeError('Canonical JSON cannot contain cycles') + seen.add(value) + try { + if (Array.isArray(value)) { + return value.map(item => normalize(item, seen) ?? null) + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([key, item]) => { + const normalized = normalize(item, seen) + return normalized === undefined ? [] : [[key, normalized]] + }) + ) + } finally { + seen.delete(value) + } +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(normalize(value, new WeakSet())) +} + +export async function stateHash(value: unknown): Promise { + const bytes = new TextEncoder().encode(canonicalJson(value)) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)] + .map(byte => byte.toString(16).padStart(2, '0')) + .join('') +} diff --git a/packages/acp-core/src/diagnostic.ts b/packages/acp-core/src/diagnostic.ts new file mode 100644 index 0000000..c96d290 --- /dev/null +++ b/packages/acp-core/src/diagnostic.ts @@ -0,0 +1,80 @@ +import type { AcpSessionState } from './session-reducer.js' +import type { AxisAcpEvent } from './axis-event.js' +import type { ProtocolTraceFrame } from './protocol-trace.js' + +export type DiagnosticKind = + | 'protocol-violation' + | 'capability-contract-mismatch' + | 'scenario-assertion-failure' + | 'resource-leak' + | 'runtime-warning' + | 'performance-regression' + | 'harness-failure' + +export type DiagnosticSubject = + | 'agent' + | 'client-profile' + | 'harness' + | 'adapter' + | 'environment' + | 'unknown' + +export interface NormativeReference { + readonly standard: 'ACP' | 'JSON-RPC' + readonly protocolVersion?: 1 + readonly section: string + readonly url: string + readonly requirement: 'MUST' | 'MUST NOT' | 'SHOULD' | 'SHOULD NOT' | 'MAY' +} + +export interface Diagnostic { + readonly id: string + readonly kind: DiagnosticKind + readonly runId: string + readonly scenarioId: string + readonly invariantId: string + readonly severity: 'info' | 'warning' | 'error' | 'fatal' + readonly subject: DiagnosticSubject + readonly message: string + readonly sequence?: number + readonly traceIds: readonly string[] + readonly stateSnapshotIds: readonly string[] + readonly faultInjectionIds: readonly string[] + readonly references: readonly NormativeReference[] +} + +export interface SessionStateSnapshot { + readonly id: string + readonly sequence: number + readonly state: AcpSessionState +} + +export interface ClientProfileSnapshot { + readonly id: string + readonly capabilities: Readonly> + readonly allowedClientMethods: readonly string[] +} + +export interface InvariantRunContext { + readonly runId: string + readonly scenarioId: string + readonly profile: ClientProfileSnapshot + readonly trace: readonly ProtocolTraceFrame[] + readonly events: readonly AxisAcpEvent[] + readonly stateSnapshots: readonly SessionStateSnapshot[] + readonly activeProcessCount: number + readonly faultInjectionIds: readonly string[] +} + +export interface LifecycleInvariant { + readonly id: string + readonly category: + | 'protocol' + | 'capability-contract' + | 'axis-scenario' + | 'resource' + | 'performance' + readonly description: string + readonly references: readonly NormativeReference[] + evaluate(context: InvariantRunContext): readonly Diagnostic[] +} diff --git a/packages/acp-core/src/index.ts b/packages/acp-core/src/index.ts new file mode 100644 index 0000000..1e58349 --- /dev/null +++ b/packages/acp-core/src/index.ts @@ -0,0 +1,10 @@ +export * from './axis-event.js' +export * from './canonical-json.js' +export * from './diagnostic.js' +export * from './protocol-trace.js' +export * from './redaction.js' +export * from './replay.js' +export * from './session-reducer.js' +export * from './sequence.js' +export * from './transcript.js' +export * from './transcript-artifact.js' diff --git a/packages/acp-core/src/protocol-trace.ts b/packages/acp-core/src/protocol-trace.ts new file mode 100644 index 0000000..6570834 --- /dev/null +++ b/packages/acp-core/src/protocol-trace.ts @@ -0,0 +1,33 @@ +export type JsonPrimitive = boolean | number | string | null +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { readonly [key: string]: JsonValue } + +export type ProtocolDirection = 'client-to-agent' | 'agent-to-client' + +export type ProtocolFrameKind = + | 'request' + | 'response' + | 'notification' + | 'invalid-json' + | 'stdout-noise' + +export interface ProtocolTraceFrame { + readonly id: string + readonly runId: string + readonly connectionId: string + readonly sequence: number + readonly timestamp: string + readonly protocolVersion: 'v1' + readonly direction: ProtocolDirection + readonly kind: ProtocolFrameKind + readonly raw: string + readonly byteLength: number + readonly parsed?: JsonValue + readonly requestId?: string | number | null + readonly method?: string + readonly parseError?: string +} + +export type ProtocolTraceListener = (frame: ProtocolTraceFrame) => void diff --git a/packages/acp-core/src/redaction.ts b/packages/acp-core/src/redaction.ts new file mode 100644 index 0000000..3b6c663 --- /dev/null +++ b/packages/acp-core/src/redaction.ts @@ -0,0 +1,128 @@ +import type { + RedactionEntry, + RedactionManifest, + TranscriptDraft, +} from './transcript.js' + +const defaultSensitiveKeys = Object.freeze([ + 'authorization', + 'cookie', + 'env', + 'environment', + 'api-key', + 'api_key', + 'apikey', + 'password', + 'secret', + 'token', +]) + +export interface RedactionPolicy { + readonly replacement?: string + readonly sensitiveKeys?: readonly string[] + readonly secretValues?: readonly string[] + readonly explicitPaths?: readonly string[] +} + +export interface RedactionResult { + readonly value: T + readonly manifest: RedactionManifest +} + +function normalizedKey(key: string): string { + return key.toLowerCase().replaceAll(/[-_]/gu, '') +} + +function matchesPath(path: readonly string[], pattern: string): boolean { + const parts = pattern.split('.').filter(Boolean) + return ( + parts.length === path.length && + parts.every((part, index) => part === '*' || part === path[index]) + ) +} + +export function redactTranscriptDraft( + draft: TranscriptDraft, + policy: RedactionPolicy = {} +): RedactionResult { + const replacement = policy.replacement ?? '[REDACTED]' + const sensitiveKeys = new Set( + [...defaultSensitiveKeys, ...(policy.sensitiveKeys ?? [])].map( + normalizedKey + ) + ) + const secretValues = [...(policy.secretValues ?? [])] + .filter(value => value.length > 0) + .sort((left, right) => right.length - left.length) + const explicitPaths = policy.explicitPaths ?? [] + const entries: RedactionEntry[] = [] + + const record = ( + path: readonly string[], + reason: RedactionEntry['reason'] + ): void => { + const dottedPath = path.join('.') + if ( + !entries.some( + entry => entry.path === dottedPath && entry.reason === reason + ) + ) { + entries.push({ path: dottedPath, reason }) + } + } + + const redactString = (value: string, path: readonly string[]): string => { + let result = value + for (const secret of secretValues) { + if (!result.includes(secret)) continue + result = result.replaceAll(secret, replacement) + record(path, 'secret-value') + } + return result + } + + const visit = (value: unknown, path: readonly string[]): unknown => { + if (explicitPaths.some(pattern => matchesPath(path, pattern))) { + record(path, 'explicit-path') + return replacement + } + if (typeof value === 'string') { + if (path.at(-1) === 'raw') { + try { + const parsed = JSON.parse(value) as unknown + const redacted = visit(parsed, [...path, '$json']) + return JSON.stringify(redacted) + } catch { + return redactString(value, path) + } + } + return redactString(value, path) + } + if (Array.isArray(value)) { + return value.map((item, index) => visit(item, [...path, String(index)])) + } + if (typeof value !== 'object' || value === null) return value + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => { + const itemPath = [...path, key] + if (sensitiveKeys.has(normalizedKey(key))) { + record(itemPath, 'sensitive-key') + return [key, replacement] + } + return [key, visit(item, itemPath)] + }) + ) + } + + return { + value: visit(draft, []) as TranscriptDraft, + manifest: { + version: 1, + replacement, + entries: entries.sort((left, right) => + left.path.localeCompare(right.path) + ), + }, + } +} diff --git a/packages/acp-core/src/replay.ts b/packages/acp-core/src/replay.ts new file mode 100644 index 0000000..9947f61 --- /dev/null +++ b/packages/acp-core/src/replay.ts @@ -0,0 +1,138 @@ +import { canonicalJson, stateHash } from './canonical-json.js' +import type { AcpSessionState } from './session-reducer.js' +import { createSessionState, reduceSessionEvent } from './session-reducer.js' +import type { AxisAcpEvent } from './axis-event.js' +import type { AxisAcpTranscript } from './transcript.js' +import { parseTranscript } from './transcript.js' + +export interface ReplaySnapshot { + readonly status: 'paused' | 'completed' + readonly position: number + readonly currentSequence: number + readonly states: Readonly> +} + +function orderedEvents( + events: readonly AxisAcpEvent[] +): readonly AxisAcpEvent[] { + return [...events].sort( + (left, right) => + left.sequence - right.sequence || left.id.localeCompare(right.id) + ) +} + +export class TranscriptReplay { + private readonly events: readonly AxisAcpEvent[] + private readonly sessionStates = new Map() + private position = 0 + private paused = true + + constructor(readonly transcript: AxisAcpTranscript) { + this.events = orderedEvents(transcript.events) + } + + static from(input: string | unknown): TranscriptReplay { + return new TranscriptReplay(parseTranscript(input)) + } + + get snapshot(): ReplaySnapshot { + return { + status: + this.position >= this.events.length && !this.paused + ? 'completed' + : 'paused', + position: this.position, + currentSequence: this.events[this.position - 1]?.sequence ?? 0, + states: Object.fromEntries( + [...this.sessionStates.entries()].sort(([left], [right]) => + left.localeCompare(right) + ) + ), + } + } + + pause(): ReplaySnapshot { + this.paused = true + return this.snapshot + } + + reset(): ReplaySnapshot { + this.position = 0 + this.paused = true + this.sessionStates.clear() + return this.snapshot + } + + step(): ReplaySnapshot { + const event = this.events[this.position] + if (event === undefined) { + this.paused = false + return this.snapshot + } + this.apply(event) + this.position += 1 + this.paused = true + return this.snapshot + } + + seek(sequence: number): ReplaySnapshot { + this.reset() + while ( + this.position < this.events.length && + (this.events[this.position]?.sequence ?? Number.POSITIVE_INFINITY) <= + sequence + ) { + this.step() + } + return this.snapshot + } + + playToEnd(): ReplaySnapshot { + this.paused = false + while (this.position < this.events.length) { + const event = this.events[this.position] + if (event !== undefined) this.apply(event) + this.position += 1 + } + return this.snapshot + } + + async stateHashes(): Promise>> { + const entries = await Promise.all( + Object.entries(this.snapshot.states).map( + async ([sessionId, state]) => + [sessionId, await stateHash(state)] as const + ) + ) + return Object.fromEntries( + entries.sort(([left], [right]) => left.localeCompare(right)) + ) + } + + async verifyIntegrity(): Promise { + const hashes = await this.stateHashes() + return ( + canonicalJson(hashes) === + canonicalJson(this.transcript.integrity.stateHashes) + ) + } + + private apply(event: AxisAcpEvent): void { + if (event.sessionId !== undefined) { + const state = + this.sessionStates.get(event.sessionId) ?? + createSessionState(event.sessionId, event.connectionId) + this.sessionStates.set(event.sessionId, reduceSessionEvent(state, event)) + return + } + if ( + event.type !== 'process/exited' && + event.type !== 'connection/state-changed' + ) { + return + } + for (const [sessionId, state] of this.sessionStates) { + this.sessionStates.set(sessionId, reduceSessionEvent(state, event)) + } + } +} diff --git a/packages/acp-core/src/sequence.ts b/packages/acp-core/src/sequence.ts new file mode 100644 index 0000000..b0485cf --- /dev/null +++ b/packages/acp-core/src/sequence.ts @@ -0,0 +1,12 @@ +export class SequenceAllocator { + private current = 0 + + next(): number { + this.current += 1 + return this.current + } + + peek(): number { + return this.current + } +} diff --git a/packages/acp-core/src/session-reducer.spec.ts b/packages/acp-core/src/session-reducer.spec.ts new file mode 100644 index 0000000..e2221d8 --- /dev/null +++ b/packages/acp-core/src/session-reducer.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { AxisAcpEvent } from './axis-event.js' +import { + createSessionState, + reduceSessionEvent, + reduceSessionEvents, +} from './session-reducer.js' + +const metadata = { + runId: 'run-reducer', + connectionId: 'connection-reducer', + sessionId: 'session-reducer', + timestamp: '2026-08-20T00:00:00.000Z', + protocolVersion: 'v1' as const, + sourceTraceIds: [], +} + +describe('reduceSessionEvent', () => { + it('aggregates message chunks and ignores a duplicate event ID', () => { + const initial = createSessionState( + metadata.sessionId, + metadata.connectionId + ) + const first: AxisAcpEvent = { + ...metadata, + id: 'chunk-1', + sequence: 1, + type: 'message/chunk-appended', + messageId: 'message-1', + role: 'agent', + content: { type: 'text', text: 'hello' }, + } + const second: AxisAcpEvent = { + ...first, + id: 'chunk-2', + sequence: 2, + content: { type: 'text', text: ' world' }, + } + + const state = reduceSessionEvents(initial, [second, first, first]) + + expect(state.messages['message-1']?.chunks).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'text', text: ' world' }, + ]) + expect(state.appliedEventIds).toEqual(['chunk-1', 'chunk-2']) + }) + + it('uses sequence order so the first observed terminal state wins', () => { + const initial = createSessionState( + metadata.sessionId, + metadata.connectionId + ) + const cancelled: AxisAcpEvent = { + ...metadata, + id: 'cancelled', + sequence: 4, + type: 'session/state-changed', + state: 'cancelled', + stopReason: 'cancelled', + } + const completed: AxisAcpEvent = { + ...metadata, + id: 'completed', + sequence: 5, + type: 'session/state-changed', + state: 'completed', + stopReason: 'end_turn', + } + + expect(reduceSessionEvents(initial, [completed, cancelled]).status).toBe( + 'cancelled' + ) + }) + + it('clears pending permissions when the target process crashes', () => { + const initial = createSessionState( + metadata.sessionId, + metadata.connectionId + ) + const requested: AxisAcpEvent = { + ...metadata, + id: 'permission', + sequence: 1, + type: 'permission/requested', + permissionId: 'permission-1', + toolCall: { toolCallId: 'tool-1' }, + options: [], + } + const crashed: AxisAcpEvent = { + ...metadata, + sessionId: undefined, + id: 'crash', + sequence: 3, + type: 'process/exited', + code: 17, + signal: null, + } + const transportFailed: AxisAcpEvent = { + ...metadata, + id: 'transport-failed', + sequence: 2, + type: 'session/state-changed', + state: 'failed', + error: 'transport closed', + } + + const pending = reduceSessionEvent(initial, requested) + const failed = reduceSessionEvent(pending, transportFailed) + const state = reduceSessionEvent(failed, crashed) + + expect(state.status).toBe('crashed') + expect(state.pendingPermissions).toEqual({}) + }) + + it('does not apply an event from another session', () => { + const initial = createSessionState( + metadata.sessionId, + metadata.connectionId + ) + const event: AxisAcpEvent = { + ...metadata, + sessionId: 'another-session', + id: 'other-session-event', + sequence: 1, + type: 'session/state-changed', + state: 'prompting', + } + + expect(reduceSessionEvent(initial, event)).toBe(initial) + }) + + it('opens a later prompt turn after a completed turn', () => { + const initial = createSessionState( + metadata.sessionId, + metadata.connectionId + ) + const completed: AxisAcpEvent = { + ...metadata, + id: 'turn-1-completed', + sequence: 1, + type: 'session/state-changed', + state: 'completed', + stopReason: 'end_turn', + } + const nextPrompt: AxisAcpEvent = { + ...metadata, + id: 'turn-2-prompting', + sequence: 2, + type: 'session/state-changed', + state: 'prompting', + } + + const state = reduceSessionEvents(initial, [nextPrompt, completed]) + + expect(state.status).toBe('prompting') + expect(state.stopReason).toBeUndefined() + }) +}) diff --git a/packages/acp-core/src/session-reducer.ts b/packages/acp-core/src/session-reducer.ts new file mode 100644 index 0000000..1492b62 --- /dev/null +++ b/packages/acp-core/src/session-reducer.ts @@ -0,0 +1,190 @@ +import type { + AxisAcpEvent, + PermissionRequestedEvent, + SessionStatus, +} from './axis-event.js' + +export interface SessionMessage { + readonly id: string + readonly role: 'user' | 'agent' | 'thought' + readonly chunks: readonly unknown[] +} + +export interface SessionToolCall { + readonly id: string + readonly data: Readonly> +} + +export interface PendingPermission { + readonly id: string + readonly toolCall: Readonly> + readonly options: readonly Readonly>[] +} + +export interface AcpSessionState { + readonly sessionId: string + readonly connectionId: string + readonly status: SessionStatus + readonly stopReason?: string + readonly error?: string + readonly messages: Readonly> + readonly toolCalls: Readonly> + readonly pendingPermissions: Readonly> + readonly appliedEventIds: readonly string[] + readonly lastSequence: number +} + +const terminalStates = new Set([ + 'completed', + 'cancelled', + 'failed', + 'crashed', +]) + +export function createSessionState( + sessionId: string, + connectionId: string +): AcpSessionState { + return { + sessionId, + connectionId, + status: 'created', + messages: {}, + toolCalls: {}, + pendingPermissions: {}, + appliedEventIds: [], + lastSequence: 0, + } +} + +export function reduceSessionEvent( + state: AcpSessionState, + event: AxisAcpEvent +): AcpSessionState { + if (event.connectionId !== state.connectionId) return state + if (event.sessionId !== undefined && event.sessionId !== state.sessionId) { + return state + } + if (state.appliedEventIds.includes(event.id)) return state + + const base = { + ...state, + appliedEventIds: [...state.appliedEventIds, event.id], + lastSequence: Math.max(state.lastSequence, event.sequence), + } + + switch (event.type) { + case 'session/state-changed': { + if ( + terminalStates.has(state.status) && + !(event.state === 'prompting' && state.status !== 'crashed') + ) { + return base + } + return { + ...base, + status: event.state, + stopReason: event.stopReason, + error: event.error, + pendingPermissions: terminalStates.has(event.state) + ? {} + : state.pendingPermissions, + toolCalls: + event.state === 'cancelling' + ? cancelToolCalls(state.toolCalls) + : state.toolCalls, + } + } + case 'message/chunk-appended': { + const existing = state.messages[event.messageId] + return { + ...base, + messages: { + ...state.messages, + [event.messageId]: { + id: event.messageId, + role: event.role, + chunks: [...(existing?.chunks ?? []), event.content], + }, + }, + } + } + case 'tool-call/upsert': { + const existing = state.toolCalls[event.toolCallId] + return { + ...base, + toolCalls: { + ...state.toolCalls, + [event.toolCallId]: { + id: event.toolCallId, + data: { ...existing?.data, ...event.patch }, + }, + }, + } + } + case 'permission/requested': + return addPermission(base, event) + case 'permission/resolved': { + const pendingPermissions = { ...state.pendingPermissions } + delete pendingPermissions[event.permissionId] + return { ...base, pendingPermissions } + } + case 'process/exited': + if (state.status === 'completed' || state.status === 'cancelled') { + return base + } + return { ...base, status: 'crashed', pendingPermissions: {} } + case 'connection/state-changed': + if ( + event.state !== 'crashed' || + state.status === 'completed' || + state.status === 'cancelled' + ) { + return base + } + return { ...base, status: 'crashed', pendingPermissions: {} } + default: + return base + } +} + +function cancelToolCalls( + toolCalls: Readonly> +): Readonly> { + return Object.fromEntries( + Object.entries(toolCalls).map(([id, toolCall]) => { + const status = toolCall.data.status + if (status === 'completed' || status === 'failed') return [id, toolCall] + return [ + id, + { ...toolCall, data: { ...toolCall.data, status: 'cancelled' } }, + ] + }) + ) +} + +export function reduceSessionEvents( + state: AcpSessionState, + events: readonly AxisAcpEvent[] +): AcpSessionState { + return [...events] + .sort((left, right) => left.sequence - right.sequence) + .reduce(reduceSessionEvent, state) +} + +function addPermission( + state: AcpSessionState, + event: PermissionRequestedEvent +): AcpSessionState { + return { + ...state, + pendingPermissions: { + ...state.pendingPermissions, + [event.permissionId]: { + id: event.permissionId, + toolCall: event.toolCall, + options: event.options, + }, + }, + } +} diff --git a/packages/acp-core/src/transcript-artifact.ts b/packages/acp-core/src/transcript-artifact.ts new file mode 100644 index 0000000..caa9b2e --- /dev/null +++ b/packages/acp-core/src/transcript-artifact.ts @@ -0,0 +1,42 @@ +import { canonicalJson } from './canonical-json.js' +import { redactTranscriptDraft, type RedactionPolicy } from './redaction.js' +import { TranscriptReplay } from './replay.js' +import { + AXIS_ACP_TRANSCRIPT_SCHEMA_VERSION, + type AxisAcpTranscript, + type TranscriptDraft, +} from './transcript.js' + +export async function createTranscript( + draft: TranscriptDraft, + policy: RedactionPolicy = {} +): Promise { + const redacted = redactTranscriptDraft(draft, policy) + const placeholder: AxisAcpTranscript = { + schemaVersion: AXIS_ACP_TRANSCRIPT_SCHEMA_VERSION, + ...redacted.value, + redactionManifest: redacted.manifest, + integrity: { + algorithm: 'SHA-256', + stateHashes: {}, + eventCount: redacted.value.events.length, + maxSequence: Math.max( + 0, + ...redacted.value.events.map(event => event.sequence) + ), + }, + } + const replay = new TranscriptReplay(placeholder) + replay.playToEnd() + return { + ...placeholder, + integrity: { + ...placeholder.integrity, + stateHashes: await replay.stateHashes(), + }, + } +} + +export function serializeTranscript(transcript: AxisAcpTranscript): string { + return canonicalJson(transcript) +} diff --git a/packages/acp-core/src/transcript.ts b/packages/acp-core/src/transcript.ts new file mode 100644 index 0000000..ffd5786 --- /dev/null +++ b/packages/acp-core/src/transcript.ts @@ -0,0 +1,122 @@ +import type { AxisAcpEvent, AxisProtocolVersion } from './axis-event.js' +import type { ClientProfileSnapshot, Diagnostic } from './diagnostic.js' +import type { ProtocolTraceFrame } from './protocol-trace.js' + +export const AXIS_ACP_TRANSCRIPT_SCHEMA_VERSION = 1 as const + +export interface TranscriptRunMetadata { + readonly id: string + readonly scenarioId?: string + readonly startedAt: string + readonly completedAt: string + readonly toolkitVersion: string + readonly protocolVersion: AxisProtocolVersion +} + +export interface TranscriptTargetSnapshot { + readonly id: string + readonly transport: 'stdio' + readonly protocolVersion: AxisProtocolVersion + readonly args: readonly string[] + readonly agentInfo?: Readonly> +} + +export interface TranscriptAssertionResult { + readonly id: string + readonly passed: boolean + readonly message: string +} + +export interface RedactionEntry { + readonly path: string + readonly reason: 'sensitive-key' | 'secret-value' | 'explicit-path' +} + +export interface RedactionManifest { + readonly version: 1 + readonly replacement: string + readonly entries: readonly RedactionEntry[] +} + +export interface TranscriptIntegrity { + readonly algorithm: 'SHA-256' + readonly stateHashes: Readonly> + readonly eventCount: number + readonly maxSequence: number +} + +export interface AxisAcpTranscript { + readonly schemaVersion: typeof AXIS_ACP_TRANSCRIPT_SCHEMA_VERSION + readonly run: TranscriptRunMetadata + readonly target: TranscriptTargetSnapshot + readonly clientProfile: ClientProfileSnapshot + readonly rawFrames: readonly ProtocolTraceFrame[] + readonly events: readonly AxisAcpEvent[] + readonly assertions: readonly TranscriptAssertionResult[] + readonly diagnostics: readonly Diagnostic[] + readonly redactionManifest: RedactionManifest + readonly integrity: TranscriptIntegrity +} + +export type TranscriptDraft = Omit< + AxisAcpTranscript, + 'schemaVersion' | 'redactionManifest' | 'integrity' +> + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertString(record: Record, key: string): void { + if (typeof record[key] !== 'string') { + throw new TypeError(`Transcript field ${key} must be a string`) + } +} + +export function parseTranscript(input: string | unknown): AxisAcpTranscript { + const value = + typeof input === 'string' ? (JSON.parse(input) as unknown) : input + if (!isRecord(value)) throw new TypeError('Transcript must be an object') + if (value.schemaVersion !== AXIS_ACP_TRANSCRIPT_SCHEMA_VERSION) { + throw new TypeError( + `Unsupported transcript schema version: ${String(value.schemaVersion)}` + ) + } + if (!isRecord(value.run)) throw new TypeError('Transcript run is missing') + assertString(value.run, 'id') + assertString(value.run, 'startedAt') + assertString(value.run, 'completedAt') + if (!isRecord(value.target)) { + throw new TypeError('Transcript target is missing') + } + assertString(value.target, 'id') + if (!isRecord(value.clientProfile)) { + throw new TypeError('Transcript clientProfile is missing') + } + for (const field of ['rawFrames', 'events', 'assertions', 'diagnostics']) { + if (!Array.isArray(value[field])) { + throw new TypeError(`Transcript field ${field} must be an array`) + } + } + if (!isRecord(value.redactionManifest) || !isRecord(value.integrity)) { + throw new TypeError('Transcript redaction or integrity metadata is missing') + } + if (!isRecord(value.integrity.stateHashes)) { + throw new TypeError('Transcript state hashes are missing') + } + const events = value.events + if (!Array.isArray(events)) { + throw new TypeError('Transcript field events must be an array') + } + for (const event of events) { + if ( + !isRecord(event) || + typeof event.id !== 'string' || + typeof event.type !== 'string' || + typeof event.sequence !== 'number' + ) { + throw new TypeError('Transcript contains an invalid event') + } + } + return value as unknown as AxisAcpTranscript +} diff --git a/packages/acp-core/tsconfig.build.json b/packages/acp-core/tsconfig.build.json new file mode 100644 index 0000000..f5bbab6 --- /dev/null +++ b/packages/acp-core/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + } +} diff --git a/packages/acp-core/tsconfig.json b/packages/acp-core/tsconfig.json new file mode 100644 index 0000000..e5883b0 --- /dev/null +++ b/packages/acp-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/acp-harness/package.json b/packages/acp-harness/package.json new file mode 100644 index 0000000..f983cd2 --- /dev/null +++ b/packages/acp-harness/package.json @@ -0,0 +1,25 @@ +{ + "name": "@axis-ui/acp-harness", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./bridge": { + "types": "./dist/bridge/index.d.ts", + "import": "./dist/bridge/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", + "@axis-ui/acp-core": "workspace:*", + "ws": "8.18.3" + } +} diff --git a/packages/acp-harness/src/acp-v1-adapter.ts b/packages/acp-harness/src/acp-v1-adapter.ts new file mode 100644 index 0000000..bb4791d --- /dev/null +++ b/packages/acp-harness/src/acp-v1-adapter.ts @@ -0,0 +1,186 @@ +import type { + InitializeResponse, + RequestPermissionOutcome, + RequestPermissionRequest, + SessionNotification, + StopReason, +} from '@agentclientprotocol/sdk' +import type { + AxisAcpEvent, + AxisAcpEventListener, + CapabilitySnapshotEvent, + ConnectionStateChangedEvent, + MessageChunkAppendedEvent, + PermissionRequestedEvent, + PermissionResolvedEvent, + ProcessExitedEvent, + SequenceAllocator, + SessionStateChangedEvent, + ToolCallUpsertEvent, +} from '@axis-ui/acp-core' +import type { TargetExit } from './types.js' + +export interface AcpV1AdapterOptions { + readonly runId: string + readonly connectionId: string + readonly sequence: SequenceAllocator + readonly onEvent?: AxisAcpEventListener + readonly now?: () => Date +} + +export class AcpV1Adapter { + constructor(private readonly options: AcpV1AdapterOptions) {} + + connectionState( + state: ConnectionStateChangedEvent['state'], + sourceTraceIds: readonly string[] = [] + ): ConnectionStateChangedEvent { + return this.emit({ + ...this.metadata(sourceTraceIds), + type: 'connection/state-changed', + state, + }) + } + + initialized( + response: InitializeResponse, + sourceTraceIds: readonly string[] + ): void { + this.emit({ + ...this.metadata(sourceTraceIds), + type: 'capability/snapshot', + agentCapabilities: response.agentCapabilities as Readonly< + Record + >, + agentInfo: response.agentInfo as + | Readonly> + | undefined, + }) + this.connectionState('initialized', sourceTraceIds) + } + + processExited(exit: TargetExit): ProcessExitedEvent { + return this.emit({ + ...this.metadata([]), + type: 'process/exited', + code: exit.code, + signal: exit.signal, + }) + } + + sessionState( + sessionId: string, + state: SessionStateChangedEvent['state'], + sourceTraceIds: readonly string[] = [], + details: { readonly stopReason?: StopReason; readonly error?: string } = {} + ): SessionStateChangedEvent { + return this.emit({ + ...this.sessionMetadata(sourceTraceIds, sessionId), + type: 'session/state-changed', + state, + ...details, + }) + } + + sessionUpdate( + notification: SessionNotification, + sourceTraceIds: readonly string[] + ): AxisAcpEvent | undefined { + const { sessionId, update } = notification + switch (update.sessionUpdate) { + case 'user_message_chunk': + case 'agent_message_chunk': + case 'agent_thought_chunk': { + const role = + update.sessionUpdate === 'user_message_chunk' + ? 'user' + : update.sessionUpdate === 'agent_thought_chunk' + ? 'thought' + : 'agent' + return this.emit({ + ...this.sessionMetadata(sourceTraceIds, sessionId), + type: 'message/chunk-appended', + messageId: + update.messageId ?? + `${sessionId}:${role}:${sourceTraceIds[0] ?? 'local'}`, + role, + content: update.content, + }) + } + case 'tool_call': + case 'tool_call_update': + return this.emit({ + ...this.sessionMetadata(sourceTraceIds, sessionId), + type: 'tool-call/upsert', + toolCallId: update.toolCallId, + patch: update as unknown as Readonly>, + }) + default: + return undefined + } + } + + permissionRequested( + permissionId: string, + request: RequestPermissionRequest, + sourceTraceIds: readonly string[] + ): PermissionRequestedEvent { + return this.emit({ + ...this.sessionMetadata(sourceTraceIds, request.sessionId), + type: 'permission/requested', + permissionId, + toolCall: request.toolCall as Readonly>, + options: request.options as readonly Readonly>[], + }) + } + + permissionResolved( + permissionId: string, + sessionId: string, + outcome: RequestPermissionOutcome, + sourceTraceIds: readonly string[] = [] + ): PermissionResolvedEvent { + return this.emit({ + ...this.sessionMetadata(sourceTraceIds, sessionId), + type: 'permission/resolved', + permissionId, + outcome, + }) + } + + private metadata(sourceTraceIds: readonly string[]): EventMetadata { + const sequence = this.options.sequence.next() + return { + id: `${this.options.runId}:event:${sequence}`, + runId: this.options.runId, + connectionId: this.options.connectionId, + sequence, + timestamp: (this.options.now?.() ?? new Date()).toISOString(), + protocolVersion: 'v1' as const, + sourceTraceIds, + } + } + + private sessionMetadata( + sourceTraceIds: readonly string[], + sessionId: string + ): EventMetadata & { readonly sessionId: string } { + return { ...this.metadata(sourceTraceIds), sessionId } + } + + private emit(event: T): T { + this.options.onEvent?.(event) + return event + } +} + +interface EventMetadata { + readonly id: string + readonly runId: string + readonly connectionId: string + readonly sessionId?: string + readonly sequence: number + readonly timestamp: string + readonly protocolVersion: 'v1' + readonly sourceTraceIds: readonly string[] +} diff --git a/packages/acp-harness/src/bridge/index.ts b/packages/acp-harness/src/bridge/index.ts new file mode 100644 index 0000000..7e60ae9 --- /dev/null +++ b/packages/acp-harness/src/bridge/index.ts @@ -0,0 +1,2 @@ +export * from './local-bridge.js' +export * from './messages.js' diff --git a/packages/acp-harness/src/bridge/local-bridge.ts b/packages/acp-harness/src/bridge/local-bridge.ts new file mode 100644 index 0000000..a147a51 --- /dev/null +++ b/packages/acp-harness/src/bridge/local-bridge.ts @@ -0,0 +1,193 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import { isIP } from 'node:net' +import { WebSocketServer, type WebSocket } from 'ws' +import { HarnessError } from '../errors.js' +import { parseBridgeMessage, type BridgeMessage } from './messages.js' + +export interface LocalBridgeOptions { + readonly host?: string + readonly port?: number + readonly allowedOrigins: readonly string[] + readonly maxConnections?: number + readonly maxMessageBytes?: number + readonly maxMessagesPerWindow?: number + readonly rateWindowMs?: number + readonly token?: string + readonly onMessage: (message: BridgeMessage) => Promise +} + +export interface LocalBridgeAddress { + readonly host: string + readonly port: number + readonly token: string + readonly url: string +} + +function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1' +} + +function equalSecret(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ) +} + +export class LocalBridge { + readonly token: string + private readonly host: string + private readonly port: number + private readonly maxConnections: number + private readonly maxMessagesPerWindow: number + private readonly rateWindowMs: number + private readonly sockets = new Set() + private readonly server: Server + private readonly webSockets: WebSocketServer + private address?: LocalBridgeAddress + + constructor(private readonly options: LocalBridgeOptions) { + this.host = options.host ?? '127.0.0.1' + this.port = options.port ?? 0 + this.maxConnections = options.maxConnections ?? 2 + this.maxMessagesPerWindow = options.maxMessagesPerWindow ?? 30 + this.rateWindowMs = options.rateWindowMs ?? 1_000 + this.token = options.token ?? randomBytes(32).toString('base64url') + + if (!isLoopbackHost(this.host)) { + throw new HarnessError( + `Bridge must bind to loopback, received ${this.host}`, + 'NON_LOOPBACK_BRIDGE' + ) + } + if (this.token.length < 32) { + throw new HarnessError('Bridge token is too short', 'WEAK_BRIDGE_TOKEN') + } + + this.server = createServer((_request, response) => { + response.writeHead(404).end() + }) + this.webSockets = new WebSocketServer({ + noServer: true, + maxPayload: options.maxMessageBytes ?? 256 * 1024, + }) + this.server.on('upgrade', (request, socket, head) => { + if ( + !this.authorizeUpgrade(request) || + this.sockets.size >= this.maxConnections + ) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') + socket.destroy() + return + } + this.webSockets.handleUpgrade(request, socket, head, webSocket => { + this.webSockets.emit('connection', webSocket, request) + }) + }) + this.webSockets.on('connection', socket => this.handleConnection(socket)) + } + + async start(): Promise { + if (this.address) return this.address + await new Promise((resolveListen, rejectListen) => { + this.server.once('error', rejectListen) + this.server.listen(this.port, this.host, () => { + this.server.off('error', rejectListen) + resolveListen() + }) + }) + const serverAddress = this.server.address() + if (!serverAddress || typeof serverAddress === 'string') { + throw new HarnessError( + 'Bridge did not expose a TCP address', + 'BRIDGE_BIND_FAILED' + ) + } + this.address = { + host: this.host, + port: serverAddress.port, + token: this.token, + url: `ws://${this.host}:${serverAddress.port}`, + } + return this.address + } + + async stop(): Promise { + for (const socket of this.sockets) socket.close(1001, 'Bridge stopping') + await new Promise(resolveClose => + this.webSockets.close(() => resolveClose()) + ) + if (this.server.listening) { + await new Promise((resolveClose, rejectClose) => + this.server.close(error => + error ? rejectClose(error) : resolveClose() + ) + ) + } + this.address = undefined + } + + private authorizeUpgrade(request: IncomingMessage): boolean { + const remoteAddress = request.socket.remoteAddress ?? '' + if (!this.isLoopbackAddress(remoteAddress)) return false + + const origin = request.headers.origin + if (!origin || !this.options.allowedOrigins.includes(origin)) return false + + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + const presentedToken = url.searchParams.get('token') ?? '' + return equalSecret(presentedToken, this.token) + } + + private isLoopbackAddress(address: string): boolean { + if (address === '::1' || address === '127.0.0.1') return true + if (address.startsWith('::ffff:')) { + return address.slice('::ffff:'.length) === '127.0.0.1' + } + return isIP(address) === 4 && address.startsWith('127.') + } + + private handleConnection(socket: WebSocket): void { + this.sockets.add(socket) + const messageTimes: number[] = [] + socket.on('close', () => this.sockets.delete(socket)) + socket.on('message', async (data, isBinary) => { + const now = Date.now() + while ( + messageTimes[0] !== undefined && + now - messageTimes[0] > this.rateWindowMs + ) { + messageTimes.shift() + } + messageTimes.push(now) + if (messageTimes.length > this.maxMessagesPerWindow) { + socket.close(1008, 'Rate limit exceeded') + return + } + + if (isBinary) { + socket.close(1003, 'Binary messages are not supported') + return + } + + let requestId = 'unknown' + try { + const parsed = JSON.parse(data.toString()) as unknown + const message = parseBridgeMessage(parsed) + requestId = message.requestId + const result = await this.options.onMessage(message) + socket.send(JSON.stringify({ type: 'response', requestId, result })) + } catch (error) { + const code = + error instanceof HarnessError ? error.code : 'BRIDGE_FAILURE' + const message = error instanceof Error ? error.message : String(error) + socket.send( + JSON.stringify({ type: 'error', requestId, error: { code, message } }) + ) + } + }) + } +} diff --git a/packages/acp-harness/src/bridge/messages.ts b/packages/acp-harness/src/bridge/messages.ts new file mode 100644 index 0000000..5c58449 --- /dev/null +++ b/packages/acp-harness/src/bridge/messages.ts @@ -0,0 +1,88 @@ +import { HarnessError } from '../errors.js' + +export type BridgeMessage = + | { + readonly type: 'target/start' + readonly requestId: string + readonly targetId: string + readonly workspaceRoot: string + } + | { + readonly type: 'target/stop' + readonly requestId: string + readonly targetHandleId: string + } + | { + readonly type: 'scenario/run' + readonly requestId: string + readonly targetId: string + readonly scenarioId: string + readonly workspaceRoot: string + } + +function requireString(value: Record, key: string): string { + const field = value[key] + if (typeof field !== 'string' || field.length === 0) { + throw new HarnessError( + `Bridge field must be a string: ${key}`, + 'BAD_MESSAGE' + ) + } + return field +} + +export function parseBridgeMessage(input: unknown): BridgeMessage { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new HarnessError('Bridge message must be an object', 'BAD_MESSAGE') + } + const value = input as Record + const type = requireString(value, 'type') + const requestId = requireString(value, 'requestId') + + if (type === 'target/start') { + const allowed = new Set(['type', 'requestId', 'targetId', 'workspaceRoot']) + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new HarnessError('Unknown target/start field', 'BAD_MESSAGE') + } + return { + type, + requestId, + targetId: requireString(value, 'targetId'), + workspaceRoot: requireString(value, 'workspaceRoot'), + } + } + + if (type === 'target/stop') { + const allowed = new Set(['type', 'requestId', 'targetHandleId']) + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new HarnessError('Unknown target/stop field', 'BAD_MESSAGE') + } + return { + type, + requestId, + targetHandleId: requireString(value, 'targetHandleId'), + } + } + + if (type === 'scenario/run') { + const allowed = new Set([ + 'type', + 'requestId', + 'targetId', + 'scenarioId', + 'workspaceRoot', + ]) + if (Object.keys(value).some(key => !allowed.has(key))) { + throw new HarnessError('Unknown scenario/run field', 'BAD_MESSAGE') + } + return { + type, + requestId, + targetId: requireString(value, 'targetId'), + scenarioId: requireString(value, 'scenarioId'), + workspaceRoot: requireString(value, 'workspaceRoot'), + } + } + + throw new HarnessError(`Unknown bridge message type: ${type}`, 'BAD_MESSAGE') +} diff --git a/packages/acp-harness/src/client-profiles.ts b/packages/acp-harness/src/client-profiles.ts new file mode 100644 index 0000000..7ed2c45 --- /dev/null +++ b/packages/acp-harness/src/client-profiles.ts @@ -0,0 +1,27 @@ +import type { ClientCapabilities } from '@agentclientprotocol/sdk' + +export type ClientProfileId = 'minimal' | 'permission-only' + +export interface ClientCapabilityProfile { + readonly id: ClientProfileId + readonly capabilities: ClientCapabilities + readonly allowedClientMethods: readonly string[] +} + +export const clientProfiles: Readonly< + Record +> = Object.freeze({ + minimal: Object.freeze({ + id: 'minimal', + capabilities: Object.freeze({}), + allowedClientMethods: Object.freeze(['session/update']), + }), + 'permission-only': Object.freeze({ + id: 'permission-only', + capabilities: Object.freeze({}), + allowedClientMethods: Object.freeze([ + 'session/update', + 'session/request_permission', + ]), + }), +}) diff --git a/packages/acp-harness/src/errors.ts b/packages/acp-harness/src/errors.ts new file mode 100644 index 0000000..9813c85 --- /dev/null +++ b/packages/acp-harness/src/errors.ts @@ -0,0 +1,10 @@ +export class HarnessError extends Error { + constructor( + message: string, + readonly code: string, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'HarnessError' + } +} diff --git a/packages/acp-harness/src/harness.ts b/packages/acp-harness/src/harness.ts new file mode 100644 index 0000000..90c4100 --- /dev/null +++ b/packages/acp-harness/src/harness.ts @@ -0,0 +1,181 @@ +import type { + AcpSessionState, + AxisAcpEvent, + AxisAcpEventListener, + ProtocolTraceFrame, + ProtocolTraceListener, + SessionStateSnapshot, +} from '@axis-ui/acp-core' +import { + SequenceAllocator, + createSessionState, + reduceSessionEvent, +} from '@axis-ui/acp-core' +import { randomUUID } from 'node:crypto' +import { ProcessManager } from './process-manager.js' +import { AcpSdkClient } from './sdk-client.js' +import { HarnessError } from './errors.js' +import { + clientProfiles, + type ClientCapabilityProfile, +} from './client-profiles.js' +import { TargetRegistry } from './target-registry.js' +import type { + HarnessInitialization, + PermissionDecision, + PromptInput, + PromptSubmission, + SessionIdentity, + TargetHandle, + TargetStartRequest, +} from './types.js' + +export class AcpHarness { + readonly runId: string + private readonly sequence = new SequenceAllocator() + private readonly sdk: AcpSdkClient + private readonly eventListeners = new Set() + private readonly traceListeners = new Set() + private readonly recordedEvents: AxisAcpEvent[] = [] + private readonly recordedTrace: ProtocolTraceFrame[] = [] + private readonly sessionStates = new Map() + private readonly recordedStateSnapshots: SessionStateSnapshot[] = [] + + constructor( + readonly registry: TargetRegistry, + readonly processes = new ProcessManager(), + runId: string = randomUUID() + ) { + this.runId = runId + this.sdk = new AcpSdkClient({ + runId, + sequence: this.sequence, + onEvent: event => this.publishEvent(event), + onTrace: frame => this.publishTrace(frame), + }) + } + + get events(): readonly AxisAcpEvent[] { + return this.recordedEvents + } + + get trace(): readonly ProtocolTraceFrame[] { + return this.recordedTrace + } + + get stateSnapshots(): readonly SessionStateSnapshot[] { + return this.recordedStateSnapshots + } + + subscribeEvents(listener: AxisAcpEventListener): () => void { + this.eventListeners.add(listener) + return () => this.eventListeners.delete(listener) + } + + subscribeTrace(listener: ProtocolTraceListener): () => void { + this.traceListeners.add(listener) + return () => this.traceListeners.delete(listener) + } + + getSession(sessionId: string): AcpSessionState | undefined { + return this.sessionStates.get(sessionId) + } + + async startTarget(request: TargetStartRequest): Promise { + const resolved = await this.registry.resolve(request) + const target = this.processes.start(resolved) + this.sdk.connect(target) + return target + } + + async initialize( + targetHandleId: string, + profile: ClientCapabilityProfile = clientProfiles.minimal + ): Promise { + return { + targetHandleId, + response: await this.sdk.initialize(targetHandleId, profile), + } + } + + async createSession(targetHandleId: string): Promise { + const target = this.processes.get(targetHandleId) + return this.sdk.createSession(targetHandleId, target.cwd) + } + + async submitPrompt(input: PromptInput): Promise { + const state = this.sessionStates.get(input.sessionId) + if (state?.status === 'prompting' || state?.status === 'cancelling') { + throw new HarnessError( + `Session already has an active prompt: ${input.sessionId}`, + 'SESSION_BUSY' + ) + } + if (state?.status === 'crashed') { + throw new HarnessError( + `Session connection has crashed: ${input.sessionId}`, + 'SESSION_CRASHED' + ) + } + return this.sdk.submitPrompt(input) + } + + async cancelSession( + targetHandleId: string, + sessionId: string + ): Promise { + await this.sdk.cancelSession(targetHandleId, sessionId) + } + + respondPermission(decision: PermissionDecision): void { + this.sdk.respondPermission(decision) + } + + async stopTarget(targetHandleId: string): Promise { + await this.processes.stop(targetHandleId) + this.sdk.close(targetHandleId) + } + + async dispose(): Promise { + for (const target of this.processes.list()) this.sdk.close(target.id) + await this.processes.stopAll() + } + + private publishEvent(event: AxisAcpEvent): void { + this.recordedEvents.push(event) + if (event.sessionId !== undefined) { + const state = + this.sessionStates.get(event.sessionId) ?? + createSessionState(event.sessionId, event.connectionId) + const nextState = reduceSessionEvent(state, event) + this.sessionStates.set(event.sessionId, nextState) + this.recordStateSnapshot(event, nextState) + } else if ( + event.type === 'process/exited' || + event.type === 'connection/state-changed' + ) { + for (const [sessionId, state] of this.sessionStates) { + const nextState = reduceSessionEvent(state, event) + this.sessionStates.set(sessionId, nextState) + this.recordStateSnapshot(event, nextState) + } + } + for (const listener of this.eventListeners) listener(event) + } + + private recordStateSnapshot( + event: AxisAcpEvent, + state: AcpSessionState + ): void { + this.recordedStateSnapshots.push({ + id: `${event.id}:state:${state.sessionId}`, + sequence: event.sequence, + state, + }) + } + + private publishTrace(frame: ProtocolTraceFrame): void { + this.recordedTrace.push(frame) + for (const listener of this.traceListeners) listener(frame) + } +} diff --git a/packages/acp-harness/src/index.ts b/packages/acp-harness/src/index.ts new file mode 100644 index 0000000..3ef137e --- /dev/null +++ b/packages/acp-harness/src/index.ts @@ -0,0 +1,13 @@ +export * from './errors.js' +export * from './harness.js' +export * from './client-profiles.js' +export * from './invariants.js' +export * from './scenario.js' +export * from './process-manager.js' +export * from './registry-targets.js' +export * from './sdk-client.js' +export * from './target-registry.js' +export * from './transport-tap.js' +export * from './types.js' +export * from './bridge/index.js' +export * from './acp-v1-adapter.js' diff --git a/packages/acp-harness/src/invariants.ts b/packages/acp-harness/src/invariants.ts new file mode 100644 index 0000000..b947563 --- /dev/null +++ b/packages/acp-harness/src/invariants.ts @@ -0,0 +1,378 @@ +import type { + Diagnostic, + DiagnosticKind, + DiagnosticSubject, + InvariantRunContext, + LifecycleInvariant, + NormativeReference, + ProtocolTraceFrame, +} from '@axis-ui/acp-core' + +const jsonRpcReference: NormativeReference = { + standard: 'JSON-RPC', + section: 'Request Object, Notification and Response Object', + url: 'https://www.jsonrpc.org/specification', + requirement: 'MUST', +} + +const transportReference: NormativeReference = { + standard: 'ACP', + protocolVersion: 1, + section: 'Transports / stdio', + url: 'https://agentclientprotocol.com/protocol/v1/transports', + requirement: 'MUST NOT', +} + +const capabilityReference: NormativeReference = { + standard: 'ACP', + protocolVersion: 1, + section: 'Initialization / Client Capabilities', + url: 'https://agentclientprotocol.com/protocol/v1/initialization', + requirement: 'MUST', +} + +const cancelPermissionReference: NormativeReference = { + standard: 'ACP', + protocolVersion: 1, + section: 'Prompt Turn / Cancellation', + url: 'https://agentclientprotocol.com/protocol/v1/prompt-turn#cancellation', + requirement: 'MUST', +} + +const cancelledStopReference: NormativeReference = { + ...cancelPermissionReference, + section: 'Prompt Turn / Cancelled Stop Reason', +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function oppositeDirection( + frame: ProtocolTraceFrame +): ProtocolTraceFrame['direction'] { + return frame.direction === 'agent-to-client' + ? 'client-to-agent' + : 'agent-to-client' +} + +function matchingRequest( + trace: InvariantRunContext['trace'], + response: ProtocolTraceFrame +): ProtocolTraceFrame | undefined { + if (response.requestId === null || response.requestId === undefined) { + return undefined + } + return trace.find( + request => + request.sequence < response.sequence && + request.kind === 'request' && + request.direction === oppositeDirection(response) && + request.requestId === response.requestId + ) +} + +function diagnostic( + context: InvariantRunContext, + invariant: LifecycleInvariant, + input: { + readonly kind: DiagnosticKind + readonly severity: Diagnostic['severity'] + readonly subject: DiagnosticSubject + readonly message: string + readonly sequence?: number + readonly traceIds?: readonly string[] + } +): Diagnostic { + const snapshots = evidenceSnapshots(context.stateSnapshots, input.sequence) + return { + id: `${context.runId}:${invariant.id}:${input.sequence ?? 'run'}`, + kind: input.kind, + runId: context.runId, + scenarioId: context.scenarioId, + invariantId: invariant.id, + severity: input.severity, + subject: input.subject, + message: input.message, + sequence: input.sequence, + traceIds: input.traceIds ?? [], + stateSnapshotIds: snapshots.map(snapshot => snapshot.id), + faultInjectionIds: context.faultInjectionIds, + references: invariant.references, + } +} + +function evidenceSnapshots( + snapshots: InvariantRunContext['stateSnapshots'], + sequence?: number +): InvariantRunContext['stateSnapshots'] { + if (sequence === undefined) { + const last = snapshots.at(-1) + return last === undefined ? [] : [last] + } + const before = [...snapshots] + .reverse() + .find(snapshot => snapshot.sequence <= sequence) + const after = snapshots.find(snapshot => snapshot.sequence >= sequence) + if (before === undefined) return after === undefined ? [] : [after] + if (after === undefined || after.id === before.id) return [before] + return [before, after] +} + +const responseCorrelation: LifecycleInvariant = { + id: 'jsonrpc-response-correlation', + category: 'protocol', + description: 'Every non-null response ID must match an earlier request.', + references: [jsonRpcReference], + evaluate(context) { + return context.trace + .filter(frame => frame.kind === 'response' && frame.requestId !== null) + .filter( + response => matchingRequest(context.trace, response) === undefined + ) + .map(response => + diagnostic(context, responseCorrelation, { + kind: 'protocol-violation', + severity: 'error', + subject: + response.direction === 'agent-to-client' ? 'agent' : 'harness', + message: `Response ID ${String(response.requestId)} has no matching request`, + sequence: response.sequence, + traceIds: [response.id], + }) + ) + }, +} + +const notificationResponse: LifecycleInvariant = { + id: 'notification-must-not-have-response', + category: 'protocol', + description: + 'A response without request correlation must not be emitted for a notification.', + references: [jsonRpcReference], + evaluate(context) { + return context.trace + .filter(frame => frame.kind === 'response') + .flatMap(response => { + if (matchingRequest(context.trace, response) !== undefined) return [] + const precedingInput = [...context.trace] + .reverse() + .find( + frame => + frame.sequence < response.sequence && + frame.direction === oppositeDirection(response) + ) + if (precedingInput?.kind !== 'notification') return [] + return [ + diagnostic(context, notificationResponse, { + kind: 'protocol-violation', + severity: 'error', + subject: + response.direction === 'agent-to-client' ? 'agent' : 'harness', + message: `Response has no request correlation and follows notification ${precedingInput.method ?? '(unknown)'}`, + sequence: response.sequence, + traceIds: [precedingInput.id, response.id], + }), + ] + }) + }, +} + +const validStdout: LifecycleInvariant = { + id: 'stdio-stdout-valid-acp-only', + category: 'protocol', + description: 'Agent stdout must contain only valid ACP messages.', + references: [transportReference], + evaluate(context) { + return context.trace + .filter( + frame => + frame.direction === 'agent-to-client' && + (frame.kind === 'invalid-json' || frame.kind === 'stdout-noise') + ) + .map(frame => + diagnostic(context, validStdout, { + kind: 'protocol-violation', + severity: 'error', + subject: 'agent', + message: `Agent stdout contains ${frame.kind}: ${frame.raw.slice(0, 80)}`, + sequence: frame.sequence, + traceIds: [frame.id], + }) + ) + }, +} + +const omittedCapability: LifecycleInvariant = { + id: 'omitted-capability-is-unsupported', + category: 'capability-contract', + description: 'Omitted client capabilities must be treated as unsupported.', + references: [capabilityReference], + evaluate(context) { + const capabilities = context.profile.capabilities + return context.trace + .filter( + frame => + frame.direction === 'agent-to-client' && frame.kind === 'request' + ) + .filter(frame => { + if (frame.method?.startsWith('terminal/')) { + return capabilities.terminal !== true + } + if (frame.method === 'fs/read_text_file') { + return ( + !isRecord(capabilities.fs) || capabilities.fs.readTextFile !== true + ) + } + if (frame.method === 'fs/write_text_file') { + return ( + !isRecord(capabilities.fs) || capabilities.fs.writeTextFile !== true + ) + } + return false + }) + .map(frame => + diagnostic(context, omittedCapability, { + kind: 'capability-contract-mismatch', + severity: 'error', + subject: 'agent', + message: `Agent called ${frame.method} although the capability was omitted`, + sequence: frame.sequence, + traceIds: [frame.id], + }) + ) + }, +} + +const cancelPendingPermission: LifecycleInvariant = { + id: 'cancel-pending-permission-response', + category: 'protocol', + description: 'Pending permission requests must be cancelled with the turn.', + references: [cancelPermissionReference], + evaluate(context) { + const cancel = context.trace.find( + frame => + frame.direction === 'client-to-agent' && + frame.kind === 'notification' && + frame.method === 'session/cancel' + ) + if (!cancel) return [] + + return context.trace + .filter( + frame => + frame.direction === 'agent-to-client' && + frame.kind === 'request' && + frame.method === 'session/request_permission' && + frame.sequence < cancel.sequence + ) + .filter(permission => { + const response = context.trace.find( + frame => + frame.direction === 'client-to-agent' && + frame.kind === 'response' && + frame.requestId === permission.requestId + ) + if (response !== undefined && response.sequence < cancel.sequence) { + return false + } + return response === undefined || !response.raw.includes('cancelled') + }) + .map(permission => + diagnostic(context, cancelPendingPermission, { + kind: 'protocol-violation', + severity: 'error', + subject: 'harness', + message: 'Pending permission was not answered with cancelled', + sequence: cancel.sequence, + traceIds: [permission.id, cancel.id], + }) + ) + }, +} + +const cancelledStopReason: LifecycleInvariant = { + id: 'cancelled-stop-reason', + category: 'protocol', + description: 'A cancelled prompt turn must finish with cancelled StopReason.', + references: [cancelledStopReference], + evaluate(context) { + const cancel = context.trace.find( + frame => + frame.direction === 'client-to-agent' && + frame.kind === 'notification' && + frame.method === 'session/cancel' + ) + if (!cancel) return [] + const prompt = [...context.trace] + .reverse() + .find( + frame => + frame.sequence < cancel.sequence && + frame.direction === 'client-to-agent' && + frame.kind === 'request' && + frame.method === 'session/prompt' + ) + const response = context.trace.find( + frame => + frame.sequence > cancel.sequence && + frame.direction === 'agent-to-client' && + frame.kind === 'response' && + frame.requestId === prompt?.requestId + ) + const result = isRecord(response?.parsed) + ? response.parsed.result + : undefined + const stopReason = isRecord(result) ? result.stopReason : undefined + if (stopReason === 'cancelled') return [] + return [ + diagnostic(context, cancelledStopReason, { + kind: 'protocol-violation', + severity: 'error', + subject: 'agent', + message: + 'Agent did not finish the cancelled prompt with StopReason cancelled', + sequence: response?.sequence ?? cancel.sequence, + traceIds: [cancel.id, response?.id].filter( + (id): id is string => id !== undefined + ), + }), + ] + }, +} + +const noOrphanProcess: LifecycleInvariant = { + id: 'no-orphan-process-after-run', + category: 'resource', + description: 'A completed scenario must leave no managed target processes.', + references: [], + evaluate(context) { + if (context.activeProcessCount === 0) return [] + return [ + diagnostic(context, noOrphanProcess, { + kind: 'resource-leak', + severity: 'error', + subject: 'harness', + message: `${context.activeProcessCount} managed process(es) remain after the run`, + }), + ] + }, +} + +export const lifecycleInvariants: readonly LifecycleInvariant[] = Object.freeze( + [ + responseCorrelation, + notificationResponse, + validStdout, + omittedCapability, + cancelPendingPermission, + cancelledStopReason, + noOrphanProcess, + ] +) + +export function evaluateLifecycleInvariants( + context: InvariantRunContext +): readonly Diagnostic[] { + return lifecycleInvariants.flatMap(invariant => invariant.evaluate(context)) +} diff --git a/packages/acp-harness/src/process-manager.ts b/packages/acp-harness/src/process-manager.ts new file mode 100644 index 0000000..c44a4f3 --- /dev/null +++ b/packages/acp-harness/src/process-manager.ts @@ -0,0 +1,141 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { HarnessError } from './errors.js' +import type { ResolvedTarget, TargetExit, TargetHandle } from './types.js' + +export interface ManagedTarget extends TargetHandle { + readonly child: ChildProcessWithoutNullStreams +} + +export interface ProcessManagerOptions { + readonly maxProcesses?: number + readonly gracefulShutdownMs?: number + readonly stderrLineLimit?: number +} + +export class ProcessManager { + private readonly processes = new Map() + private readonly perTargetCounts = new Map() + private readonly maxProcesses: number + private readonly gracefulShutdownMs: number + private readonly stderrLineLimit: number + + constructor(options: ProcessManagerOptions = {}) { + this.maxProcesses = options.maxProcesses ?? 4 + this.gracefulShutdownMs = options.gracefulShutdownMs ?? 1_500 + this.stderrLineLimit = options.stderrLineLimit ?? 200 + } + + list(): readonly TargetHandle[] { + return [...this.processes.values()] + } + + get(handleId: string): ManagedTarget { + const target = this.processes.get(handleId) + if (!target) { + throw new HarnessError( + `Unknown target handle: ${handleId}`, + 'UNKNOWN_TARGET_HANDLE' + ) + } + return target + } + + start(target: ResolvedTarget): ManagedTarget { + if (this.processes.size >= this.maxProcesses) { + throw new HarnessError( + `Global process limit reached (${this.maxProcesses})`, + 'GLOBAL_PROCESS_LIMIT' + ) + } + + const targetCount = this.perTargetCounts.get(target.definition.id) ?? 0 + if (targetCount >= target.definition.maxProcesses) { + throw new HarnessError( + `Process limit reached for target ${target.definition.id}`, + 'TARGET_PROCESS_LIMIT' + ) + } + + const child = spawn(target.command, target.args, { + cwd: target.cwd, + env: target.environment, + detached: process.platform !== 'win32', + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + const id = randomUUID() + const stderr: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + stderr.push(...chunk.split(/\r?\n/u).filter(Boolean)) + if (stderr.length > this.stderrLineLimit) { + stderr.splice(0, stderr.length - this.stderrLineLimit) + } + }) + + const exited = new Promise(resolveExit => { + child.once('exit', (code, signal) => { + resolveExit({ code, signal }) + this.processes.delete(id) + const count = this.perTargetCounts.get(target.definition.id) ?? 1 + if (count <= 1) this.perTargetCounts.delete(target.definition.id) + else this.perTargetCounts.set(target.definition.id, count - 1) + }) + }) + + const handle: ManagedTarget = { + id, + targetId: target.definition.id, + pid: child.pid ?? -1, + cwd: target.cwd, + child, + stderr, + exited, + } + this.processes.set(id, handle) + this.perTargetCounts.set(target.definition.id, targetCount + 1) + child.once('error', () => this.processes.delete(id)) + return handle + } + + async stop(handleId: string): Promise { + const target = this.get(handleId) + if (target.child.exitCode !== null || target.child.signalCode !== null) { + return target.exited + } + + this.signalTree(target.child, 'SIGTERM') + const graceful = await Promise.race([ + target.exited.then(exit => ({ exit })), + new Promise<{ timeout: true }>(resolveTimeout => + setTimeout( + () => resolveTimeout({ timeout: true }), + this.gracefulShutdownMs + ) + ), + ]) + if ('exit' in graceful) return graceful.exit + + this.signalTree(target.child, 'SIGKILL') + return target.exited + } + + async stopAll(): Promise { + await Promise.all([...this.processes.keys()].map(id => this.stop(id))) + } + + private signalTree( + child: ChildProcessWithoutNullStreams, + signal: NodeJS.Signals + ): void { + if (child.pid === undefined) return + try { + if (process.platform === 'win32') child.kill(signal) + else process.kill(-child.pid, signal) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } +} diff --git a/packages/acp-harness/src/registry-targets.ts b/packages/acp-harness/src/registry-targets.ts new file mode 100644 index 0000000..e4dccf7 --- /dev/null +++ b/packages/acp-harness/src/registry-targets.ts @@ -0,0 +1,44 @@ +import { accessSync, constants } from 'node:fs' +import { delimiter, isAbsolute, join } from 'node:path' +import { HarnessError } from './errors.js' +import type { TargetDefinition } from './types.js' + +export function findExecutable( + executable: string, + searchPath: string | undefined = process.env.PATH +): string | undefined { + if (isAbsolute(executable)) + return canExecute(executable) ? executable : undefined + for (const directory of searchPath?.split(delimiter) ?? []) { + const candidate = join(directory, executable) + if (canExecute(candidate)) return candidate + } + return undefined +} + +export function createOpenCodeTarget(command: string): TargetDefinition { + if (!isAbsolute(command)) { + throw new HarnessError( + `OpenCode command must be absolute: ${command}`, + 'INVALID_TARGET_COMMAND' + ) + } + return { + id: 'opencode', + command, + fixedArgs: ['acp', '--pure'], + allowedArgs: [], + environmentAllowlist: ['PATH'], + maxProcesses: 1, + expectedProtocolVersion: 'v1', + } +} + +function canExecute(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/packages/acp-harness/src/scenario.ts b/packages/acp-harness/src/scenario.ts new file mode 100644 index 0000000..4ad2e72 --- /dev/null +++ b/packages/acp-harness/src/scenario.ts @@ -0,0 +1,323 @@ +import { + createTranscript, + type AxisAcpTranscript, + type Diagnostic, + type SessionStateSnapshot, +} from '@axis-ui/acp-core' +import { AcpHarness } from './harness.js' +import { evaluateLifecycleInvariants } from './invariants.js' +import { + clientProfiles, + type ClientCapabilityProfile, + type ClientProfileId, +} from './client-profiles.js' +import { TargetRegistry } from './target-registry.js' + +export type CoreScenarioId = + | 'normal-prompt-turn' + | 'cancel-during-permission' + | 'capability-method-mismatch' + +export interface ScenarioDefinition { + readonly id: CoreScenarioId + readonly profileId: ClientProfileId + readonly targetArgs: readonly string[] + readonly prompt: string + readonly flow: 'complete' | 'cancel-on-permission' + readonly expectedDiagnosticIds: readonly string[] + readonly faultInjectionIds: readonly string[] +} + +export interface ScenarioAssertionResult { + readonly id: string + readonly passed: boolean + readonly message: string +} + +export interface ScenarioReport { + readonly runId: string + readonly scenarioId: CoreScenarioId + readonly status: 'passed' | 'failed' + readonly profile: ClientCapabilityProfile + readonly assertions: readonly ScenarioAssertionResult[] + readonly diagnostics: readonly Diagnostic[] + readonly stateSnapshots: readonly SessionStateSnapshot[] + readonly transcript: AxisAcpTranscript + readonly traceCount: number + readonly eventCount: number +} + +export function scenario( + definition: ScenarioDefinition +): Readonly { + return Object.freeze({ ...definition }) +} + +export const coreScenarios: Readonly< + Record +> = Object.freeze({ + 'normal-prompt-turn': scenario({ + id: 'normal-prompt-turn', + profileId: 'minimal', + targetArgs: [], + prompt: 'Return a deterministic streaming response.', + flow: 'complete', + expectedDiagnosticIds: [], + faultInjectionIds: [], + }), + 'cancel-during-permission': scenario({ + id: 'cancel-during-permission', + profileId: 'permission-only', + targetArgs: ['--permission-prompt'], + prompt: 'Request permission before running the deterministic command.', + flow: 'cancel-on-permission', + expectedDiagnosticIds: [], + faultInjectionIds: ['permission-pending'], + }), + 'capability-method-mismatch': scenario({ + id: 'capability-method-mismatch', + profileId: 'minimal', + targetArgs: ['--capability-method-mismatch'], + prompt: 'Attempt the deterministic unsupported terminal call.', + flow: 'complete', + expectedDiagnosticIds: ['omitted-capability-is-unsupported'], + faultInjectionIds: ['unsupported-terminal-request'], + }), +}) + +export interface ScenarioRunnerOptions { + readonly registry: TargetRegistry + readonly targetId: string + readonly workspaceRoot: string + readonly timeoutMs?: number +} + +export class ScenarioRunner { + private readonly timeoutMs: number + + constructor(private readonly options: ScenarioRunnerOptions) { + this.timeoutMs = options.timeoutMs ?? 2_000 + } + + async run(definition: ScenarioDefinition): Promise { + const startedAt = new Date().toISOString() + const harness = new AcpHarness(this.options.registry) + const profile = clientProfiles[definition.profileId] + const assertions: ScenarioAssertionResult[] = [] + let harnessFailure: Diagnostic | undefined + let sessionId: string | undefined + + try { + const target = await harness.startTarget({ + targetId: this.options.targetId, + workspaceRoot: this.options.workspaceRoot, + args: definition.targetArgs, + }) + await harness.initialize(target.id, profile) + const session = await harness.createSession(target.id) + const activeSessionId = session.sessionId + sessionId = activeSessionId + await harness.submitPrompt({ + targetHandleId: target.id, + sessionId: activeSessionId, + text: definition.prompt, + }) + + if (definition.flow === 'cancel-on-permission') { + await this.waitFor( + () => + Object.keys( + harness.getSession(activeSessionId)?.pendingPermissions ?? {} + ).length > 0, + 'permission request' + ) + await harness.cancelSession(target.id, activeSessionId) + } + + await this.waitFor(() => { + const status = harness.getSession(activeSessionId)?.status + return ( + status === 'completed' || + status === 'cancelled' || + status === 'failed' || + status === 'crashed' + ) + }, 'session terminal state') + await harness.stopTarget(target.id) + } catch (error) { + harnessFailure = this.harnessFailure(harness.runId, definition, error) + } finally { + await harness.dispose() + } + + const finalState = + sessionId === undefined ? undefined : harness.getSession(sessionId) + const stateSnapshots = harness.stateSnapshots + + assertions.push(...this.assert(definition, finalState)) + const context = { + runId: harness.runId, + scenarioId: definition.id, + profile: { + id: profile.id, + capabilities: profile.capabilities, + allowedClientMethods: profile.allowedClientMethods, + }, + trace: harness.trace, + events: harness.events, + stateSnapshots, + activeProcessCount: harness.processes.list().length, + faultInjectionIds: definition.faultInjectionIds, + } + const diagnostics = [ + ...evaluateLifecycleInvariants(context), + ...this.assertionDiagnostics(context, assertions), + ...(harnessFailure === undefined ? [] : [harnessFailure]), + ] + const unexpectedErrors = diagnostics.filter( + item => + (item.severity === 'error' || item.severity === 'fatal') && + !definition.expectedDiagnosticIds.includes(item.invariantId) + ) + const expectedDiagnosticsPresent = definition.expectedDiagnosticIds.every( + id => diagnostics.some(item => item.invariantId === id) + ) + const capabilitySnapshot = harness.events.find( + event => event.type === 'capability/snapshot' + ) + const transcript = await createTranscript( + { + run: { + id: harness.runId, + scenarioId: definition.id, + startedAt, + completedAt: new Date().toISOString(), + toolkitVersion: '0.0.0', + protocolVersion: 'v1', + }, + target: { + id: this.options.targetId, + transport: 'stdio', + protocolVersion: 'v1', + args: definition.targetArgs, + agentInfo: capabilitySnapshot?.agentInfo, + }, + clientProfile: { + id: profile.id, + capabilities: profile.capabilities, + allowedClientMethods: profile.allowedClientMethods, + }, + rawFrames: harness.trace, + events: harness.events, + assertions, + diagnostics, + }, + { + secretValues: [this.options.workspaceRoot], + explicitPaths: [ + 'rawFrames.*.parsed.params.prompt', + 'rawFrames.*.raw.$json.params.prompt', + ], + } + ) + + return { + runId: harness.runId, + scenarioId: definition.id, + status: + unexpectedErrors.length === 0 && expectedDiagnosticsPresent + ? 'passed' + : 'failed', + profile, + assertions, + diagnostics, + stateSnapshots, + transcript, + traceCount: harness.trace.length, + eventCount: harness.events.length, + } + } + + private assert( + definition: ScenarioDefinition, + state: SessionStateSnapshot['state'] | undefined + ): readonly ScenarioAssertionResult[] { + const expectedStatus = + definition.flow === 'cancel-on-permission' ? 'cancelled' : 'completed' + return [ + { + id: 'session-status', + passed: state?.status === expectedStatus, + message: `Expected ${expectedStatus}, received ${state?.status ?? 'missing'}`, + }, + { + id: 'no-pending-permissions', + passed: + state !== undefined && + Object.keys(state.pendingPermissions).length === 0, + message: 'The final session state must not contain pending permissions', + }, + ] + } + + private assertionDiagnostics( + context: { + readonly runId: string + readonly scenarioId: string + readonly stateSnapshots: readonly SessionStateSnapshot[] + readonly faultInjectionIds: readonly string[] + }, + assertions: readonly ScenarioAssertionResult[] + ): readonly Diagnostic[] { + return assertions + .filter(assertion => !assertion.passed) + .map(assertion => ({ + id: `${context.runId}:assertion:${assertion.id}`, + kind: 'scenario-assertion-failure', + runId: context.runId, + scenarioId: context.scenarioId, + invariantId: assertion.id, + severity: 'error', + subject: 'unknown', + message: assertion.message, + traceIds: [], + stateSnapshotIds: context.stateSnapshots.map(snapshot => snapshot.id), + faultInjectionIds: context.faultInjectionIds, + references: [], + })) + } + + private harnessFailure( + runId: string, + definition: ScenarioDefinition, + error: unknown + ): Diagnostic { + return { + id: `${runId}:harness-failure`, + kind: 'harness-failure', + runId, + scenarioId: definition.id, + invariantId: 'scenario-runner-completed', + severity: 'fatal', + subject: 'harness', + message: error instanceof Error ? error.message : String(error), + traceIds: [], + stateSnapshotIds: [], + faultInjectionIds: definition.faultInjectionIds, + references: [], + } + } + + private async waitFor( + predicate: () => boolean, + description: string + ): Promise { + const deadline = Date.now() + this.timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${description}`) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } + } +} diff --git a/packages/acp-harness/src/sdk-client.ts b/packages/acp-harness/src/sdk-client.ts new file mode 100644 index 0000000..e8ee3ee --- /dev/null +++ b/packages/acp-harness/src/sdk-client.ts @@ -0,0 +1,388 @@ +import { + PROTOCOL_VERSION, + client, + methods, + ndJsonStream, + type ClientConnection, + type InitializeResponse, + type RequestPermissionOutcome, + type RequestPermissionRequest, + type RequestPermissionResponse, +} from '@agentclientprotocol/sdk' +import type { + AxisAcpEventListener, + ProtocolTraceFrame, + ProtocolTraceListener, + SequenceAllocator, +} from '@axis-ui/acp-core' +import { Readable, Writable } from 'node:stream' +import { randomUUID } from 'node:crypto' +import { AcpV1Adapter } from './acp-v1-adapter.js' +import { + clientProfiles, + type ClientCapabilityProfile, +} from './client-profiles.js' +import { HarnessError } from './errors.js' +import type { ManagedTarget } from './process-manager.js' +import { TransportTap } from './transport-tap.js' +import type { + PermissionDecision, + PromptInput, + PromptSubmission, + SessionIdentity, +} from './types.js' + +export interface AcpSdkClientOptions { + readonly runId: string + readonly sequence: SequenceAllocator + readonly onTrace?: ProtocolTraceListener + readonly onEvent?: AxisAcpEventListener +} + +interface ConnectionRecord { + readonly connection: ClientConnection + readonly tap: TransportTap + readonly adapter: AcpV1Adapter + readonly sessions: Set + readonly pendingPermissions: Map + profile: ClientCapabilityProfile +} + +interface PendingPermission { + readonly id: string + readonly request: RequestPermissionRequest + readonly requestTraceIds: readonly string[] + readonly resolve: (response: RequestPermissionResponse) => void +} + +export class AcpSdkClient { + private readonly connections = new Map() + + constructor(private readonly options: AcpSdkClientOptions) {} + + connect(target: ManagedTarget): ClientConnection { + const tap = new TransportTap({ + runId: this.options.runId, + connectionId: target.id, + sequence: this.options.sequence, + onFrame: this.options.onTrace, + }) + const adapter = new AcpV1Adapter({ + runId: this.options.runId, + connectionId: target.id, + sequence: this.options.sequence, + onEvent: this.options.onEvent, + }) + const stream = ndJsonStream( + tap.tapWritable( + Writable.toWeb(target.child.stdin) as WritableStream, + 'client-to-agent' + ), + tap.tapReadable( + Readable.toWeb(target.child.stdout) as ReadableStream, + 'agent-to-client' + ) + ) + const connection = client({ name: 'axis-acp-devkit' }) + .onRequest(methods.client.session.requestPermission, context => { + return this.requestPermission(this.get(target.id), context.params) + }) + .onNotification(methods.client.session.update, context => { + const record = this.get(target.id) + const source = this.findFrame( + record.tap.frames, + frame => + frame.direction === 'agent-to-client' && + frame.kind === 'notification' && + frame.method === 'session/update' + ) + record.adapter.sessionUpdate( + context.params, + source === undefined ? [] : [source.id] + ) + }) + .connect(stream) + + const record: ConnectionRecord = { + connection, + tap, + adapter, + sessions: new Set(), + pendingPermissions: new Map(), + profile: clientProfiles.minimal, + } + this.connections.set(target.id, record) + adapter.connectionState('connected') + void target.exited.then(exit => { + this.cancelPendingPermissions(record) + connection.close(new Error('Target process exited')) + this.connections.delete(target.id) + adapter.processExited(exit) + adapter.connectionState( + exit.code === 0 || exit.signal !== null ? 'closed' : 'crashed' + ) + }) + return connection + } + + async initialize( + targetHandleId: string, + profile: ClientCapabilityProfile = clientProfiles.minimal + ): Promise { + const record = this.get(targetHandleId) + record.profile = profile + const startingSequence = this.options.sequence.peek() + const response = await record.connection.agent.request( + methods.agent.initialize, + { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: profile.capabilities, + } + ) + const requestFrame = this.findFrame( + record.tap.frames, + frame => + frame.sequence > startingSequence && + frame.direction === 'client-to-agent' && + frame.kind === 'request' && + frame.method === 'initialize' + ) + const responseFrame = this.findFrame( + record.tap.frames, + frame => + frame.direction === 'agent-to-client' && + frame.kind === 'response' && + frame.requestId === requestFrame?.requestId + ) + record.adapter.initialized( + response, + [requestFrame?.id, responseFrame?.id].filter( + (id): id is string => id !== undefined + ) + ) + return response + } + + async createSession( + targetHandleId: string, + cwd: string + ): Promise { + const record = this.get(targetHandleId) + const startingSequence = this.options.sequence.peek() + const response = await record.connection.agent.request( + methods.agent.session.new, + { cwd, mcpServers: [] } + ) + record.sessions.add(response.sessionId) + const sourceTraceIds = this.correlateRequest( + record, + 'session/new', + startingSequence + ) + record.adapter.sessionState(response.sessionId, 'created', sourceTraceIds) + return { targetHandleId, sessionId: response.sessionId } + } + + submitPrompt(input: PromptInput): PromptSubmission { + const record = this.get(input.targetHandleId) + this.assertSession(record, input.sessionId) + if (input.text.length === 0) { + throw new HarnessError('Prompt must not be empty', 'EMPTY_PROMPT') + } + + const turnId = randomUUID() + const startingSequence = this.options.sequence.peek() + record.adapter.sessionState(input.sessionId, 'prompting') + const completion = record.connection.agent.request( + methods.agent.session.prompt, + { + sessionId: input.sessionId, + prompt: [{ type: 'text', text: input.text }], + } + ) + void completion.then( + response => { + const sourceTraceIds = this.correlateRequest( + record, + 'session/prompt', + startingSequence + ) + record.adapter.sessionState( + input.sessionId, + response.stopReason === 'cancelled' ? 'cancelled' : 'completed', + sourceTraceIds, + { stopReason: response.stopReason } + ) + }, + error => { + record.adapter.sessionState(input.sessionId, 'failed', [], { + error: error instanceof Error ? error.message : String(error), + }) + } + ) + return { turnId, sessionId: input.sessionId } + } + + async cancelSession( + targetHandleId: string, + sessionId: string + ): Promise { + const record = this.get(targetHandleId) + this.assertSession(record, sessionId) + record.adapter.sessionState(sessionId, 'cancelling') + const notification = record.connection.agent.notify( + methods.agent.session.cancel, + { sessionId } + ) + this.cancelPendingPermissions(record, sessionId) + await notification + } + + respondPermission(decision: PermissionDecision): void { + const record = this.get(decision.targetHandleId) + const pending = record.pendingPermissions.get(decision.permissionId) + if (!pending || pending.request.sessionId !== decision.sessionId) { + throw new HarnessError( + `Unknown pending permission: ${decision.permissionId}`, + 'UNKNOWN_PERMISSION' + ) + } + const outcome = decision.outcome + if ( + outcome.outcome === 'selected' && + !pending.request.options.some( + option => option.optionId === outcome.optionId + ) + ) { + throw new HarnessError( + `Unknown permission option: ${outcome.optionId}`, + 'UNKNOWN_PERMISSION_OPTION' + ) + } + this.resolvePermission(record, pending, outcome) + } + + close(targetHandleId: string): void { + const record = this.connections.get(targetHandleId) + if (record) this.cancelPendingPermissions(record) + record?.connection.close() + this.connections.delete(targetHandleId) + } + + private requestPermission( + record: ConnectionRecord, + request: RequestPermissionRequest + ): Promise { + const source = this.findFrame( + record.tap.frames, + frame => + frame.direction === 'agent-to-client' && + frame.kind === 'request' && + frame.method === 'session/request_permission' + ) + const permissionId = source?.id ?? randomUUID() + const requestTraceIds = source === undefined ? [] : [source.id] + if ( + !record.profile.allowedClientMethods.includes( + 'session/request_permission' + ) + ) { + record.adapter.permissionRequested(permissionId, request, requestTraceIds) + const outcome = { outcome: 'cancelled' } as const + record.adapter.permissionResolved( + permissionId, + request.sessionId, + outcome, + requestTraceIds + ) + return Promise.resolve({ outcome }) + } + return new Promise(resolve => { + const pending: PendingPermission = { + id: permissionId, + request, + requestTraceIds, + resolve, + } + record.pendingPermissions.set(permissionId, pending) + record.adapter.permissionRequested(permissionId, request, requestTraceIds) + }) + } + + private cancelPendingPermissions( + record: ConnectionRecord, + sessionId?: string + ): void { + for (const pending of [...record.pendingPermissions.values()]) { + if (sessionId !== undefined && pending.request.sessionId !== sessionId) { + continue + } + this.resolvePermission(record, pending, { outcome: 'cancelled' }) + } + } + + private resolvePermission( + record: ConnectionRecord, + pending: PendingPermission, + outcome: RequestPermissionOutcome + ): void { + record.pendingPermissions.delete(pending.id) + pending.resolve({ outcome }) + record.adapter.permissionResolved( + pending.id, + pending.request.sessionId, + outcome, + pending.requestTraceIds + ) + } + + private assertSession(record: ConnectionRecord, sessionId: string): void { + if (!record.sessions.has(sessionId)) { + throw new HarnessError(`Unknown session: ${sessionId}`, 'UNKNOWN_SESSION') + } + } + + private correlateRequest( + record: ConnectionRecord, + method: string, + startingSequence: number + ): readonly string[] { + const request = this.findFrame( + record.tap.frames, + frame => + frame.sequence > startingSequence && + frame.direction === 'client-to-agent' && + frame.kind === 'request' && + frame.method === method + ) + const response = this.findFrame( + record.tap.frames, + frame => + frame.direction === 'agent-to-client' && + frame.kind === 'response' && + frame.requestId === request?.requestId + ) + return [request?.id, response?.id].filter( + (id): id is string => id !== undefined + ) + } + + private get(targetHandleId: string): ConnectionRecord { + const record = this.connections.get(targetHandleId) + if (!record) { + throw new Error(`ACP connection is not open: ${targetHandleId}`) + } + return record + } + + private findFrame( + frames: readonly ProtocolTraceFrame[], + predicate: (frame: ProtocolTraceFrame) => boolean + ): ProtocolTraceFrame | undefined { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index] + if (frame && predicate(frame)) return frame + } + return undefined + } +} diff --git a/packages/acp-harness/src/target-registry.ts b/packages/acp-harness/src/target-registry.ts new file mode 100644 index 0000000..8f461f0 --- /dev/null +++ b/packages/acp-harness/src/target-registry.ts @@ -0,0 +1,121 @@ +import { realpath } from 'node:fs/promises' +import { isAbsolute, relative, resolve } from 'node:path' +import { HarnessError } from './errors.js' +import type { + ResolvedTarget, + TargetDefinition, + TargetStartRequest, +} from './types.js' + +function isInsideRoot(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate) + return ( + pathFromRoot === '' || + (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot)) + ) +} + +export class TargetRegistry { + private readonly definitions = new Map() + + private constructor( + private readonly allowedWorkspaceRoots: readonly string[] + ) {} + + static async create( + definitions: readonly TargetDefinition[], + allowedWorkspaceRoots: readonly string[] + ): Promise { + if (allowedWorkspaceRoots.length === 0) { + throw new HarnessError( + 'At least one allowed workspace root is required', + 'WORKSPACE_ROOTS_EMPTY' + ) + } + + const normalizedRoots = await Promise.all( + allowedWorkspaceRoots.map(async root => realpath(resolve(root))) + ) + const registry = new TargetRegistry(normalizedRoots) + for (const definition of definitions) registry.register(definition) + return registry + } + + list(): readonly TargetDefinition[] { + return [...this.definitions.values()] + } + + get(targetId: string): TargetDefinition { + const definition = this.definitions.get(targetId) + if (!definition) { + throw new HarnessError(`Unknown target ID: ${targetId}`, 'UNKNOWN_TARGET') + } + return definition + } + + async resolve(request: TargetStartRequest): Promise { + const definition = this.get(request.targetId) + const requestedArgs = request.args ?? [] + + for (const arg of requestedArgs) { + if (!definition.allowedArgs.includes(arg)) { + throw new HarnessError( + `Argument is not allowed for target ${definition.id}: ${arg}`, + 'ARGUMENT_NOT_ALLOWED' + ) + } + } + + const cwd = await realpath(resolve(request.workspaceRoot)) + if (!this.allowedWorkspaceRoots.some(root => isInsideRoot(root, cwd))) { + throw new HarnessError( + `Workspace is outside the allowed roots: ${cwd}`, + 'WORKSPACE_NOT_ALLOWED' + ) + } + + const environment: NodeJS.ProcessEnv = {} + for (const key of definition.environmentAllowlist) { + const value = request.environment?.[key] ?? process.env[key] + if (value !== undefined) environment[key] = value + } + for (const key of Object.keys(request.environment ?? {})) { + if (!definition.environmentAllowlist.includes(key)) { + throw new HarnessError( + `Environment variable is not allowed for target ${definition.id}: ${key}`, + 'ENVIRONMENT_NOT_ALLOWED' + ) + } + } + + return { + definition, + command: definition.command, + args: [...(definition.fixedArgs ?? []), ...requestedArgs], + cwd, + environment, + } + } + + private register(definition: TargetDefinition): void { + if (!definition.id || this.definitions.has(definition.id)) { + throw new HarnessError( + `Target ID must be non-empty and unique: ${definition.id}`, + 'INVALID_TARGET_DEFINITION' + ) + } + if (!isAbsolute(definition.command)) { + throw new HarnessError( + `Target command must be absolute: ${definition.command}`, + 'INVALID_TARGET_COMMAND' + ) + } + if (definition.maxProcesses < 1) { + throw new HarnessError( + `Target maxProcesses must be positive: ${definition.id}`, + 'INVALID_TARGET_LIMIT' + ) + } + this.definitions.set(definition.id, Object.freeze({ ...definition })) + } +} diff --git a/packages/acp-harness/src/transport-tap.spec.ts b/packages/acp-harness/src/transport-tap.spec.ts new file mode 100644 index 0000000..4b40173 --- /dev/null +++ b/packages/acp-harness/src/transport-tap.spec.ts @@ -0,0 +1,60 @@ +import { SequenceAllocator } from '@axis-ui/acp-core' +import { describe, expect, it } from 'vitest' +import { TransportTap } from './transport-tap.js' + +describe('TransportTap', () => { + it('preserves chunked bytes while framing complete NDJSON lines', async () => { + const chunks: Uint8Array[] = [] + const destination = new WritableStream({ + write: chunk => { + chunks.push(chunk) + }, + }) + const tap = new TransportTap({ + runId: 'run-tap', + connectionId: 'connection-tap', + sequence: new SequenceAllocator(), + }) + const writer = tap.tapWritable(destination, 'client-to-agent').getWriter() + const encoder = new TextEncoder() + + await writer.write(encoder.encode('{"jsonrpc":"2.0","id":1,')) + await writer.write(encoder.encode('"method":"initialize"}\n')) + await writer.close() + + expect(new TextDecoder().decode(Buffer.concat(chunks))).toBe( + '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' + ) + expect(tap.frames).toEqual([ + expect.objectContaining({ + direction: 'client-to-agent', + kind: 'request', + method: 'initialize', + requestId: 1, + raw: '{"jsonrpc":"2.0","id":1,"method":"initialize"}', + }), + ]) + }) + + it('keeps invalid JSON and valid non-RPC stdout as diagnostic frames', async () => { + const tap = new TransportTap({ + runId: 'run-noise', + connectionId: 'connection-noise', + sequence: new SequenceAllocator(), + }) + const writer = tap + .tapWritable(new WritableStream(), 'agent-to-client') + .getWriter() + + await writer.write( + new TextEncoder().encode('plain log line\n{"log":"structured noise"}\n') + ) + await writer.close() + + expect(tap.frames.map(frame => frame.kind)).toEqual([ + 'invalid-json', + 'stdout-noise', + ]) + expect(tap.frames[0]?.parseError).toBeTruthy() + }) +}) diff --git a/packages/acp-harness/src/transport-tap.ts b/packages/acp-harness/src/transport-tap.ts new file mode 100644 index 0000000..d22975b --- /dev/null +++ b/packages/acp-harness/src/transport-tap.ts @@ -0,0 +1,168 @@ +import type { + JsonValue, + ProtocolDirection, + ProtocolFrameKind, + ProtocolTraceFrame, + ProtocolTraceListener, + SequenceAllocator, +} from '@axis-ui/acp-core' + +export interface TransportTapOptions { + readonly runId: string + readonly connectionId: string + readonly sequence: SequenceAllocator + readonly onFrame?: ProtocolTraceListener + readonly now?: () => Date +} + +interface FrameDetails { + readonly kind: ProtocolFrameKind + readonly parsed?: JsonValue + readonly requestId?: string | number | null + readonly method?: string + readonly parseError?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function classify(raw: string): FrameDetails { + let parsed: unknown + try { + parsed = JSON.parse(raw) as unknown + } catch (error) { + return { + kind: 'invalid-json', + parseError: error instanceof Error ? error.message : String(error), + } + } + + if (!isRecord(parsed)) { + return { kind: 'stdout-noise', parsed: parsed as JsonValue } + } + + const requestId = + typeof parsed.id === 'string' || + typeof parsed.id === 'number' || + parsed.id === null + ? parsed.id + : undefined + const method = typeof parsed.method === 'string' ? parsed.method : undefined + + if (method !== undefined) { + return { + kind: requestId === undefined ? 'notification' : 'request', + parsed: parsed as JsonValue, + requestId, + method, + } + } + if ( + requestId !== undefined && + (Object.hasOwn(parsed, 'result') || Object.hasOwn(parsed, 'error')) + ) { + return { kind: 'response', parsed: parsed as JsonValue, requestId } + } + return { kind: 'stdout-noise', parsed: parsed as JsonValue } +} + +class LineObserver { + private readonly decoder = new TextDecoder() + private pending = '' + + constructor(private readonly emitLine: (line: string) => void) {} + + push(chunk: Uint8Array): void { + this.pending += this.decoder.decode(chunk, { stream: true }) + this.drain(false) + } + + finish(): void { + this.pending += this.decoder.decode() + this.drain(true) + } + + private drain(flush: boolean): void { + let newline = this.pending.indexOf('\n') + while (newline >= 0) { + const line = this.pending.slice(0, newline).replace(/\r$/u, '') + this.pending = this.pending.slice(newline + 1) + this.emitLine(line) + newline = this.pending.indexOf('\n') + } + if (flush && this.pending.length > 0) { + this.emitLine(this.pending) + this.pending = '' + } + } +} + +export class TransportTap { + private readonly recordedFrames: ProtocolTraceFrame[] = [] + + constructor(private readonly options: TransportTapOptions) {} + + get frames(): readonly ProtocolTraceFrame[] { + return this.recordedFrames + } + + tapReadable( + source: ReadableStream, + direction: ProtocolDirection + ): ReadableStream { + const observer = this.createObserver(direction) + return source.pipeThrough( + new TransformStream({ + transform: (chunk, controller) => { + observer.push(chunk) + controller.enqueue(chunk) + }, + flush: () => observer.finish(), + }) + ) + } + + tapWritable( + destination: WritableStream, + direction: ProtocolDirection + ): WritableStream { + const writer = destination.getWriter() + const observer = this.createObserver(direction) + return new WritableStream({ + write: async chunk => { + await writer.write(chunk) + observer.push(chunk) + }, + close: async () => { + observer.finish() + await writer.close() + }, + abort: async reason => { + observer.finish() + await writer.abort(reason) + }, + }) + } + + private createObserver(direction: ProtocolDirection): LineObserver { + return new LineObserver(raw => { + const sequence = this.options.sequence.next() + const details = classify(raw) + const frame: ProtocolTraceFrame = { + id: `${this.options.runId}:trace:${sequence}`, + runId: this.options.runId, + connectionId: this.options.connectionId, + sequence, + timestamp: (this.options.now?.() ?? new Date()).toISOString(), + protocolVersion: 'v1', + direction, + raw, + byteLength: new TextEncoder().encode(raw).byteLength, + ...details, + } + this.recordedFrames.push(frame) + this.options.onFrame?.(frame) + }) + } +} diff --git a/packages/acp-harness/src/types.ts b/packages/acp-harness/src/types.ts new file mode 100644 index 0000000..0937c6e --- /dev/null +++ b/packages/acp-harness/src/types.ts @@ -0,0 +1,73 @@ +import type { + InitializeResponse, + RequestPermissionOutcome, +} from '@agentclientprotocol/sdk' + +export type ProtocolVersion = 'v1' + +export interface TargetDefinition { + readonly id: string + readonly command: string + readonly fixedArgs?: readonly string[] + readonly allowedArgs: readonly string[] + readonly environmentAllowlist: readonly string[] + readonly maxProcesses: number + readonly expectedProtocolVersion: ProtocolVersion +} + +export interface TargetStartRequest { + readonly targetId: string + readonly workspaceRoot: string + readonly args?: readonly string[] + readonly environment?: Readonly> +} + +export interface ResolvedTarget { + readonly definition: TargetDefinition + readonly command: string + readonly args: readonly string[] + readonly cwd: string + readonly environment: NodeJS.ProcessEnv +} + +export type TargetExit = { + readonly code: number | null + readonly signal: NodeJS.Signals | null +} + +export interface TargetHandle { + readonly id: string + readonly targetId: string + readonly pid: number + readonly cwd: string + readonly stderr: readonly string[] + readonly exited: Promise +} + +export interface HarnessInitialization { + readonly targetHandleId: string + readonly response: InitializeResponse +} + +export interface SessionIdentity { + readonly targetHandleId: string + readonly sessionId: string +} + +export interface PromptInput { + readonly targetHandleId: string + readonly sessionId: string + readonly text: string +} + +export interface PromptSubmission { + readonly turnId: string + readonly sessionId: string +} + +export interface PermissionDecision { + readonly targetHandleId: string + readonly sessionId: string + readonly permissionId: string + readonly outcome: RequestPermissionOutcome +} diff --git a/packages/acp-harness/tsconfig.build.json b/packages/acp-harness/tsconfig.build.json new file mode 100644 index 0000000..f5bbab6 --- /dev/null +++ b/packages/acp-harness/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + } +} diff --git a/packages/acp-harness/tsconfig.json b/packages/acp-harness/tsconfig.json new file mode 100644 index 0000000..e5883b0 --- /dev/null +++ b/packages/acp-harness/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/components/virtual-list/src/virtual-list.ts b/packages/components/virtual-list/src/virtual-list.ts index a9b53b3..b829f34 100644 --- a/packages/components/virtual-list/src/virtual-list.ts +++ b/packages/components/virtual-list/src/virtual-list.ts @@ -24,3 +24,9 @@ export const virtualListProps = { } as const export type VirtualListProps = ExtractPropTypes + +export type VirtualListAlignment = 'auto' | 'start' | 'center' | 'end' + +export interface VirtualListExpose { + scrollToIndex: (index: number, alignment?: VirtualListAlignment) => void +} diff --git a/packages/components/virtual-list/src/virtual.tsx b/packages/components/virtual-list/src/virtual.tsx index 6e22225..6db2342 100644 --- a/packages/components/virtual-list/src/virtual.tsx +++ b/packages/components/virtual-list/src/virtual.tsx @@ -8,12 +8,12 @@ import { ref, watch, } from 'vue' -import { virtualListProps } from './virtual-list' +import { virtualListProps, type VirtualListAlignment } from './virtual-list' export default defineComponent({ name: 'AxVirtualList', props: virtualListProps, - setup(props, { slots }) { + setup(props, { slots, expose }) { const bem = createNamespace('vl') const wrapperRef = ref() const barRef = ref() @@ -24,7 +24,11 @@ export default defineComponent({ // ======================================== const isFixedMode = computed(() => props.size > 0) const itemSize = computed(() => - props.size > 0 ? props.size : props.estimatedSize > 0 ? props.estimatedSize : 32, + props.size > 0 + ? props.size + : props.estimatedSize > 0 + ? props.estimatedSize + : 32 ) // ======================================== @@ -85,10 +89,12 @@ export default defineComponent({ }) const prev = computed(() => Math.min(props.remain, state.start)) - const next = computed(() => Math.min(props.remain, props.items.length - state.end)) + const next = computed(() => + Math.min(props.remain, props.items.length - state.end) + ) const visibleData = computed(() => - props.items.slice(state.start - prev.value, state.end + next.value), + props.items.slice(state.start - prev.value, state.end + next.value) ) const offsetY = ref(0) @@ -121,6 +127,42 @@ export default defineComponent({ } } + function scrollToIndex( + requestedIndex: number, + alignment: VirtualListAlignment = 'auto' + ): void { + const wrapper = wrapperRef.value + if (!wrapper || props.items.length === 0) return + const index = Math.min( + props.items.length - 1, + Math.max(0, Math.trunc(requestedIndex)) + ) + const itemTop = getItemOffset(index) + const itemBottom = itemTop + getItemHeight(index) + const viewportHeight = props.remain * itemSize.value + const viewportTop = wrapper.scrollTop + const viewportBottom = viewportTop + viewportHeight + let nextScrollTop = itemTop + + if (alignment === 'auto') { + if (itemTop >= viewportTop && itemBottom <= viewportBottom) return + nextScrollTop = + itemTop < viewportTop ? itemTop : itemBottom - viewportHeight + } else if (alignment === 'center') { + nextScrollTop = itemTop - (viewportHeight - getItemHeight(index)) / 2 + } else if (alignment === 'end') { + nextScrollTop = itemBottom - viewportHeight + } + + wrapper.scrollTop = Math.min( + Math.max(0, getTotalHeight() - viewportHeight), + Math.max(0, nextScrollTop) + ) + handleScroll() + } + + expose({ scrollToIndex }) + // ======================================== // 容器初始化 // ======================================== @@ -136,18 +178,21 @@ export default defineComponent({ } } - watch(() => props.items, () => { - // 清理过期缓存 - if (!isFixedMode.value) { - // 删除超出新长度的缓存 - for (const key of measuredHeights.keys()) { - if (key >= props.items.length) { - measuredHeights.delete(key) + watch( + () => props.items, + () => { + // 清理过期缓存 + if (!isFixedMode.value) { + // 删除超出新长度的缓存 + for (const key of measuredHeights.keys()) { + if (key >= props.items.length) { + measuredHeights.delete(key) + } } } + initWrapper() } - initWrapper() - }) + ) watch(() => [props.size, props.estimatedSize, props.remain], initWrapper) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dbdf91..3fd9dca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@types/node': 20.19.43 + importers: .: @@ -13,10 +16,10 @@ importers: version: 0.18.5 '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@24.9.1) + version: 2.29.8(@types/node@20.19.43) '@commitlint/cli': specifier: ^20.1.0 - version: 20.1.0(@types/node@24.9.1)(typescript@5.9.3) + version: 20.1.0(@types/node@20.19.43)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^20.0.0 version: 20.0.0 @@ -32,15 +35,24 @@ importers: '@iconify/json': specifier: ^2.2.403 version: 2.2.403 + '@types/node': + specifier: 20.19.43 + version: 20.19.43 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 '@vitejs/plugin-vue': specifier: ^6.0.1 - version: 6.0.8(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + version: 6.0.8(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.1 - version: 5.1.1(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + version: 5.1.1(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + '@vitest/browser-playwright': + specifier: 4.0.4 + version: 4.0.4(playwright@1.62.1)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4) '@vitest/coverage-v8': specifier: ^4.0.4 - version: 4.0.4(vitest@4.0.4(@types/node@24.9.1)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + version: 4.0.4(@vitest/browser@4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4))(vitest@4.0.4) '@vue/eslint-config-prettier': specifier: ^10.2.0 version: 10.2.0(eslint@9.38.0(jiti@2.6.1))(prettier@3.6.2) @@ -77,6 +89,9 @@ importers: lint-staged: specifier: ^16.2.6 version: 16.2.6 + playwright: + specifier: ^1.62.1 + version: 1.62.1 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -100,16 +115,16 @@ importers: version: 30.0.0(@babel/parser@7.29.7)(vue@3.5.40(typescript@5.9.3)) vite: specifier: ^7.1.12 - version: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + version: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@24.9.1)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + version: 4.5.4(@types/node@20.19.43)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) vitepress: specifier: 2.0.0-alpha.15 - version: 2.0.0-alpha.15(@types/node@24.9.1)(async-validator@4.2.5)(jiti@2.6.1)(lightningcss@1.33.0)(postcss@8.5.23)(sass@1.93.2)(typescript@5.9.3)(yaml@2.8.1) + version: 2.0.0-alpha.15(@types/node@20.19.43)(async-validator@4.2.5)(jiti@2.6.1)(lightningcss@1.33.0)(postcss@8.5.23)(sass@1.93.2)(typescript@5.9.3)(yaml@2.8.1) vitest: specifier: ^4.0.4 - version: 4.0.4(@types/node@24.9.1)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + version: 4.0.4(@types/node@20.19.43)(@vitest/browser-playwright@4.0.4)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vue: specifier: ^3.5.22 version: 3.5.40(typescript@5.9.3) @@ -117,6 +132,47 @@ importers: specifier: ^3.1.2 version: 3.1.2(typescript@5.9.3) + apps/acp-devtools: + dependencies: + '@axis-ui/acp-core': + specifier: workspace:* + version: link:../../packages/acp-core + axis-ui: + specifier: workspace:* + version: link:../../packages/components + vue: + specifier: ^3.5.22 + version: 3.5.40(typescript@5.9.3) + + fixtures/acp-agents: + dependencies: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0(zod@4.4.3) + + packages/acp-cli: + dependencies: + '@axis-ui/acp-core': + specifier: workspace:* + version: link:../acp-core + '@axis-ui/acp-harness': + specifier: workspace:* + version: link:../acp-harness + + packages/acp-core: {} + + packages/acp-harness: + dependencies: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0(zod@4.4.3) + '@axis-ui/acp-core': + specifier: workspace:* + version: link:../acp-core + ws: + specifier: 8.18.3 + version: 8.18.3 + packages/components: dependencies: '@axis-ui/theme-chalk': @@ -134,16 +190,16 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.1 - version: 6.0.8(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + version: 6.0.8(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.1 - version: 5.1.1(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + version: 5.1.1(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) vite: specifier: ^7.1.10 - version: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + version: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@24.9.1)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + version: 4.5.4(@types/node@20.19.43)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) packages/theme-chalk: {} @@ -163,16 +219,21 @@ importers: version: link:../packages/utils '@vitejs/plugin-vue': specifier: ^6.0.1 - version: 6.0.8(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + version: 6.0.8(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) axis-ui: specifier: workspace:* version: link:../packages/components vite: specifier: ^7.1.10 - version: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + version: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) packages: + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@andrewbranch/untar.js@1.0.3': resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} @@ -746,7 +807,7 @@ packages: resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} peerDependencies: - '@types/node': '>=18' + '@types/node': 20.19.43 peerDependenciesMeta: '@types/node': optional: true @@ -912,6 +973,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@publint/pack@0.1.6': resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} engines: {node: '>=18'} @@ -1052,7 +1116,7 @@ packages: '@rushstack/node-core-library@5.18.0': resolution: {integrity: sha512-XDebtBdw5S3SuZIt+Ra2NieT8kQ3D2Ow1HxhDQ/2soinswnOu9e7S69VSwTOLlQnx5mpWbONu+5JJjDxMAb6Fw==} peerDependencies: - '@types/node': '*' + '@types/node': 20.19.43 peerDependenciesMeta: '@types/node': optional: true @@ -1060,7 +1124,7 @@ packages: '@rushstack/problem-matcher@0.1.1': resolution: {integrity: sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==} peerDependencies: - '@types/node': '*' + '@types/node': 20.19.43 peerDependenciesMeta: '@types/node': optional: true @@ -1071,7 +1135,7 @@ packages: '@rushstack/terminal@0.19.3': resolution: {integrity: sha512-0P8G18gK9STyO+CNBvkKPnWGMxESxecTYqOcikHOVIHXa9uAuTK+Fw8TJq2Gng1w7W6wTC9uPX6hGNvrMll2wA==} peerDependencies: - '@types/node': '*' + '@types/node': 20.19.43 peerDependenciesMeta: '@types/node': optional: true @@ -1143,14 +1207,8 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node@12.20.55': - resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - - '@types/node@20.19.23': - resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} - - '@types/node@24.9.1': - resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1161,6 +1219,9 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.46.2': resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1237,6 +1298,17 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/browser-playwright@4.0.4': + resolution: {integrity: sha512-jGKnGZ5ZKXuwQ1Ldwll/rZxk3webz4gz3kvoTYX2NH2ASPiwFGck8D09Sf2wVjCuDqebPXXd69zUIt1o4yQ5tA==} + peerDependencies: + playwright: '*' + vitest: 4.0.4 + + '@vitest/browser@4.0.4': + resolution: {integrity: sha512-1ZXztcBtRd3maKliHzWbQohsyRjam0ws6OPRWNWfGxFUOHTlNBtDnJAm8z1x7IzVkZ6JcOAumHJAbxNJh4tkDw==} + peerDependencies: + vitest: 4.0.4 + '@vitest/coverage-v8@4.0.4': resolution: {integrity: sha512-YM7gDj2TX2AXyGLz0p/B7hvTsTfaQc+kSV/LU0nEnKlep/ZfbdCDppPND4YQiQC43OXyrhkG3y8ZSTqYb2CKqQ==} peerDependencies: @@ -1700,7 +1772,7 @@ packages: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} peerDependencies: - '@types/node': '*' + '@types/node': 20.19.43 cosmiconfig: '>=9' typescript: '>=5' @@ -2029,6 +2101,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2586,6 +2663,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2763,12 +2844,30 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -2924,6 +3023,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -3075,6 +3178,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -3124,9 +3231,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -3238,7 +3342,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 + '@types/node': 20.19.43 jiti: '>=1.21.0' less: ^4.0.0 lightningcss: ^1.21.0 @@ -3295,7 +3399,7 @@ packages: peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@types/node': 20.19.43 '@vitest/browser-playwright': 4.0.4 '@vitest/browser-preview': 4.0.4 '@vitest/browser-webdriverio': 4.0.4 @@ -3464,11 +3568,18 @@ packages: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: + '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': + dependencies: + zod: 4.4.3 + '@andrewbranch/untar.js@1.0.3': {} '@antfu/install-pkg@1.1.0': @@ -3719,7 +3830,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8(@types/node@24.9.1)': + '@changesets/cli@2.29.8(@types/node@20.19.43)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -3735,7 +3846,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@24.9.1) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.43) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -3837,11 +3948,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@20.1.0(@types/node@24.9.1)(typescript@5.9.3)': + '@commitlint/cli@20.1.0(@types/node@20.19.43)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.0.0 '@commitlint/lint': 20.0.0 - '@commitlint/load': 20.1.0(@types/node@24.9.1)(typescript@5.9.3) + '@commitlint/load': 20.1.0(@types/node@20.19.43)(typescript@5.9.3) '@commitlint/read': 20.0.0 '@commitlint/types': 20.0.0 tinyexec: 1.2.4 @@ -3888,7 +3999,7 @@ snapshots: '@commitlint/rules': 20.0.0 '@commitlint/types': 20.0.0 - '@commitlint/load@20.1.0(@types/node@24.9.1)(typescript@5.9.3)': + '@commitlint/load@20.1.0(@types/node@20.19.43)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.0.0 '@commitlint/execute-rule': 20.0.0 @@ -3896,7 +4007,7 @@ snapshots: '@commitlint/types': 20.0.0 chalk: 5.6.2 cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@24.9.1)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@20.19.43)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -4163,12 +4274,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@inquirer/external-editor@1.0.3(@types/node@24.9.1)': + '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.1 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 '@isaacs/balanced-match@4.0.1': {} @@ -4211,7 +4322,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.4 - '@types/node': 12.20.55 + '@types/node': 20.19.43 find-up: 4.1.0 fs-extra: 8.1.0 @@ -4224,23 +4335,23 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@microsoft/api-extractor-model@7.31.3(@types/node@24.9.1)': + '@microsoft/api-extractor-model@7.31.3(@types/node@20.19.43)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.18.0(@types/node@24.9.1) + '@rushstack/node-core-library': 5.18.0(@types/node@20.19.43) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.53.3(@types/node@24.9.1)': + '@microsoft/api-extractor@7.53.3(@types/node@20.19.43)': dependencies: - '@microsoft/api-extractor-model': 7.31.3(@types/node@24.9.1) + '@microsoft/api-extractor-model': 7.31.3(@types/node@20.19.43) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.18.0(@types/node@24.9.1) + '@rushstack/node-core-library': 5.18.0(@types/node@20.19.43) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.19.3(@types/node@24.9.1) - '@rushstack/ts-command-line': 5.1.3(@types/node@24.9.1) + '@rushstack/terminal': 0.19.3(@types/node@20.19.43) + '@rushstack/ts-command-line': 5.1.3(@types/node@20.19.43) lodash: 4.17.21 minimatch: 10.0.3 resolve: 1.22.11 @@ -4339,6 +4450,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.29': {} + '@publint/pack@0.1.6': dependencies: tinyexec: 1.2.4 @@ -4419,7 +4532,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true - '@rushstack/node-core-library@5.18.0(@types/node@24.9.1)': + '@rushstack/node-core-library@5.18.0(@types/node@20.19.43)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -4430,28 +4543,28 @@ snapshots: resolve: 1.22.11 semver: 7.5.4 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 - '@rushstack/problem-matcher@0.1.1(@types/node@24.9.1)': + '@rushstack/problem-matcher@0.1.1(@types/node@20.19.43)': optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 '@rushstack/rig-package@0.6.0': dependencies: resolve: 1.22.11 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.19.3(@types/node@24.9.1)': + '@rushstack/terminal@0.19.3(@types/node@20.19.43)': dependencies: - '@rushstack/node-core-library': 5.18.0(@types/node@24.9.1) - '@rushstack/problem-matcher': 0.1.1(@types/node@24.9.1) + '@rushstack/node-core-library': 5.18.0(@types/node@20.19.43) + '@rushstack/problem-matcher': 0.1.1(@types/node@20.19.43) supports-color: 8.1.1 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 - '@rushstack/ts-command-line@5.1.3(@types/node@24.9.1)': + '@rushstack/ts-command-line@5.1.3(@types/node@20.19.43)': dependencies: - '@rushstack/terminal': 0.19.3(@types/node@24.9.1) + '@rushstack/terminal': 0.19.3(@types/node@20.19.43) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -4509,7 +4622,7 @@ snapshots: '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 '@types/deep-eql@4.0.2': {} @@ -4534,22 +4647,20 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node@12.20.55': {} - - '@types/node@20.19.23': + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 - '@types/node@24.9.1': - dependencies: - undici-types: 7.16.0 - '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.43 + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4645,25 +4756,55 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vue: 3.5.40(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.8(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vue: 3.5.40(typescript@5.9.3) - '@vitest/coverage-v8@4.0.4(vitest@4.0.4(@types/node@24.9.1)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))': + '@vitest/browser-playwright@4.0.4(playwright@1.62.1)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4)': + dependencies: + '@vitest/browser': 4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4) + '@vitest/mocker': 4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + playwright: 1.62.1 + tinyrainbow: 3.0.3 + vitest: 4.0.4(@types/node@20.19.43)(@vitest/browser-playwright@4.0.4)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4)': + dependencies: + '@vitest/mocker': 4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + '@vitest/utils': 4.0.4 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.4(@types/node@20.19.43)(@vitest/browser-playwright@4.0.4)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.0.4(@vitest/browser@4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4))(vitest@4.0.4)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.4 @@ -4676,7 +4817,9 @@ snapshots: magicast: 0.3.5 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.4(@types/node@24.9.1)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vitest: 4.0.4(@types/node@20.19.43)(@vitest/browser-playwright@4.0.4)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + optionalDependencies: + '@vitest/browser': 4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4) transitivePeerDependencies: - supports-color @@ -4689,13 +4832,13 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.4(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))': + '@vitest/mocker@4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) '@vitest/pretty-format@4.0.4': dependencies: @@ -5152,9 +5295,9 @@ snapshots: convert-source-map@2.0.0: {} - cosmiconfig-typescript-loader@6.2.0(@types/node@24.9.1)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@20.19.43)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -5497,6 +5640,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5556,7 +5702,7 @@ snapshots: happy-dom@20.0.8: dependencies: - '@types/node': 20.19.23 + '@types/node': 20.19.43 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 @@ -6033,6 +6179,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -6188,6 +6336,10 @@ snapshots: pify@4.0.1: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -6200,6 +6352,16 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -6361,6 +6523,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -6493,6 +6661,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tough-cookie@6.0.0: dependencies: tldts: 7.0.17 @@ -6532,8 +6702,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} - unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.1.0: {} @@ -6629,9 +6797,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-dts@4.5.4(@types/node@24.9.1)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)): + vite-plugin-dts@4.5.4(@types/node@20.19.43)(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)): dependencies: - '@microsoft/api-extractor': 7.53.3(@types/node@24.9.1) + '@microsoft/api-extractor': 7.53.3(@types/node@20.19.43) '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@volar/typescript': 2.4.23 '@vue/language-core': 2.2.0(typescript@5.9.3) @@ -6642,13 +6810,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1): + vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -6657,14 +6825,14 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.33.0 sass: 1.93.2 yaml: 2.8.1 - vitepress@2.0.0-alpha.15(@types/node@24.9.1)(async-validator@4.2.5)(jiti@2.6.1)(lightningcss@1.33.0)(postcss@8.5.23)(sass@1.93.2)(typescript@5.9.3)(yaml@2.8.1): + vitepress@2.0.0-alpha.15(@types/node@20.19.43)(async-validator@4.2.5)(jiti@2.6.1)(lightningcss@1.33.0)(postcss@8.5.23)(sass@1.93.2)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@docsearch/css': 4.6.3 '@docsearch/js': 4.6.3 @@ -6673,7 +6841,7 @@ snapshots: '@shikijs/transformers': 3.23.0 '@shikijs/types': 3.23.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 6.0.8(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.8(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vue@3.5.40(typescript@5.9.3)) '@vue/devtools-api': 8.2.1 '@vue/shared': 3.5.40 '@vueuse/core': 14.3.0(vue@3.5.40(typescript@5.9.3)) @@ -6682,7 +6850,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 3.23.0 - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) vue: 3.5.40(typescript@5.9.3) optionalDependencies: postcss: 8.5.23 @@ -6711,10 +6879,10 @@ snapshots: - universal-cookie - yaml - vitest@4.0.4(@types/node@24.9.1)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1): + vitest@4.0.4(@types/node@20.19.43)(@vitest/browser-playwright@4.0.4)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.23))(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.4 - '@vitest/mocker': 4.0.4(vite@7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) + '@vitest/mocker': 4.0.4(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.4 '@vitest/runner': 4.0.4 '@vitest/snapshot': 4.0.4 @@ -6731,10 +6899,11 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.17 tinyrainbow: 3.0.3 - vite: 7.3.6(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) + vite: 7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 20.19.43 + '@vitest/browser-playwright': 4.0.4(playwright@1.62.1)(vite@7.3.6(@types/node@20.19.43)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.93.2)(yaml@2.8.1))(vitest@4.0.4) happy-dom: 20.0.8 jsdom: 27.0.1(postcss@8.5.23) transitivePeerDependencies: @@ -6877,4 +7046,6 @@ snapshots: yocto-queue@1.2.1: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72bdb80..fed1ebb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ packages: - - 'packages/**' # 存放编写的组件 - - play \ No newline at end of file + - 'packages/**' + - 'apps/**' + - 'fixtures/acp-agents' + - 'play' diff --git a/test/components/virtual-list/virtual-list.spec.ts b/test/components/virtual-list/virtual-list.spec.ts index db32238..df0c5f3 100644 --- a/test/components/virtual-list/virtual-list.spec.ts +++ b/test/components/virtual-list/virtual-list.spec.ts @@ -84,6 +84,38 @@ describe('AxVirtualList', () => { (wrapper.find('.ax-vl__scroll-bar').element as HTMLElement).style.height ).toBe('1600px') // 50 * 32 }) + + it('scrollToIndex exposes generic index navigation with alignment', async () => { + const wrapper = mountList(createItems(100)) + const component = wrapper.vm as unknown as { + scrollToIndex: ( + index: number, + alignment?: 'auto' | 'start' | 'center' | 'end' + ) => void + } + + component.scrollToIndex(50, 'center') + await wrapper.vm.$nextTick() + + expect(wrapper.element.scrollTop).toBe(1488) + expect(itemTexts(wrapper)).toContain('Item 50') + + component.scrollToIndex(500, 'end') + await wrapper.vm.$nextTick() + expect(wrapper.element.scrollTop).toBe((100 - 8) * 32) + expect(itemTexts(wrapper).at(-1)).toBe('Item 99') + }) + + it('scrollToIndex auto keeps an already visible item stable', () => { + const wrapper = mountList(createItems(100)) + const component = wrapper.vm as unknown as { + scrollToIndex: (index: number) => void + } + + component.scrollToIndex(4) + + expect(wrapper.element.scrollTop).toBe(0) + }) }) // ======================================== diff --git a/test/contract/cli-report.contract.spec.ts b/test/contract/cli-report.contract.spec.ts new file mode 100644 index 0000000..fc3f4e5 --- /dev/null +++ b/test/contract/cli-report.contract.spec.ts @@ -0,0 +1,66 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runCli } from '../../packages/acp-cli/src/cli.js' +import { parseTranscript } from '../../packages/acp-core/src/transcript.js' +import { repositoryRoot } from '../helpers/acp-fixture.js' + +describe('Headless CLI and single-run report', () => { + it('runs a fixed scenario and writes replayable JSON plus escaped HTML', async () => { + const outputDirectory = await mkdtemp(join(tmpdir(), 'axis-acp-cli-')) + const stdout: string[] = [] + const stderr: string[] = [] + + const exitCode = await runCli( + [ + 'run', + '--target', + 'fixture-agent', + '--scenario', + 'capability-method-mismatch', + '--workspace', + repositoryRoot, + '--output', + outputDirectory, + ], + { + stdout: message => stdout.push(message), + stderr: message => stderr.push(message), + } + ) + + expect(exitCode).toBe(0) + expect(stderr).toEqual([]) + const summary = JSON.parse(stdout[0] ?? '{}') as { + transcriptPath: string + reportPath: string + } + const transcript = parseTranscript( + await readFile(summary.transcriptPath, 'utf8') + ) + const html = await readFile(summary.reportPath, 'utf8') + + expect(transcript.run.scenarioId).toBe('capability-method-mismatch') + expect(transcript.diagnostics).toContainEqual( + expect.objectContaining({ + kind: 'capability-contract-mismatch', + subject: 'agent', + }) + ) + expect(html).toContain('not official ACP certification') + expect(html).toContain('omitted-capability-is-unsupported') + + const replayOutput: string[] = [] + expect( + await runCli(['replay', '--input', summary.transcriptPath], { + stdout: message => replayOutput.push(message), + stderr: () => undefined, + }) + ).toBe(0) + expect(JSON.parse(replayOutput[0] ?? '{}')).toMatchObject({ + status: 'completed', + integrityValid: true, + }) + }) +}) diff --git a/test/contract/devtools-bridge.contract.spec.ts b/test/contract/devtools-bridge.contract.spec.ts new file mode 100644 index 0000000..17cfdf4 --- /dev/null +++ b/test/contract/devtools-bridge.contract.spec.ts @@ -0,0 +1,62 @@ +import { once } from 'node:events' +import WebSocket from 'ws' +import { afterEach, describe, expect, it } from 'vitest' +import { createScenarioBridgeHandler } from '../../packages/acp-cli/src/cli.js' +import { LocalBridge } from '../../packages/acp-harness/src/bridge/local-bridge.js' +import { parseTranscript } from '../../packages/acp-core/src/transcript.js' +import { repositoryRoot } from '../helpers/acp-fixture.js' + +const origin = 'http://127.0.0.1:5173' +const bridges: LocalBridge[] = [] +const sockets: WebSocket[] = [] + +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate() + await Promise.all(bridges.splice(0).map(bridge => bridge.stop())) +}) + +describe('DevTools scenario bridge', () => { + it('runs a fixed scenario through the authenticated WebSocket boundary', async () => { + const bridge = new LocalBridge({ + allowedOrigins: [origin], + onMessage: createScenarioBridgeHandler(), + }) + bridges.push(bridge) + const address = await bridge.start() + const socket = new WebSocket( + `${address.url}?token=${encodeURIComponent(address.token)}`, + { origin } + ) + sockets.push(socket) + await once(socket, 'open') + + socket.send( + JSON.stringify({ + type: 'scenario/run', + requestId: 'scenario-1', + targetId: 'fixture-agent', + scenarioId: 'capability-method-mismatch', + workspaceRoot: repositoryRoot, + }) + ) + + const [data] = (await once(socket, 'message')) as [Buffer] + const response = JSON.parse(data.toString()) as { + type: string + requestId: string + result: { transcript: unknown } + } + expect(response).toMatchObject({ + type: 'response', + requestId: 'scenario-1', + }) + expect( + parseTranscript(response.result.transcript).diagnostics + ).toContainEqual( + expect.objectContaining({ + kind: 'capability-contract-mismatch', + subject: 'agent', + }) + ) + }) +}) diff --git a/test/contract/harness-initialize.contract.spec.ts b/test/contract/harness-initialize.contract.spec.ts new file mode 100644 index 0000000..10aedd3 --- /dev/null +++ b/test/contract/harness-initialize.contract.spec.ts @@ -0,0 +1,64 @@ +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { afterEach, describe, expect, it } from 'vitest' +import { AcpHarness } from '../../packages/acp-harness/src/harness.js' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' + +const harnesses: AcpHarness[] = [] + +afterEach(async () => { + await Promise.all(harnesses.splice(0).map(harness => harness.dispose())) +}) + +describe('Harness ↔ official SDK ↔ fixture contract', () => { + it('starts an allowlisted stdio agent and completes ACP initialize', async () => { + const harness = new AcpHarness(await createFixtureRegistry()) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + + const initialized = await harness.initialize(target.id) + + expect(initialized.response.protocolVersion).toBe(PROTOCOL_VERSION) + expect(initialized.response.agentInfo?.name).toBe( + 'axis-deterministic-fixture' + ) + await harness.stopTarget(target.id) + expect(harness.processes.list()).toHaveLength(0) + }) + + it('observes agent crash and removes the process without an orphan handle', async () => { + const harness = new AcpHarness(await createFixtureRegistry()) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args: ['--crash-after-initialize'], + }) + + await harness.initialize(target.id) + const exit = await target.exited + + expect(exit).toEqual({ code: 17, signal: null }) + expect(harness.processes.list()).toHaveLength(0) + expect(harness.events).toContainEqual( + expect.objectContaining({ + type: 'process/exited', + connectionId: target.id, + code: 17, + signal: null, + }) + ) + expect(harness.events).toContainEqual( + expect.objectContaining({ + type: 'connection/state-changed', + connectionId: target.id, + state: 'crashed', + }) + ) + }) +}) diff --git a/test/contract/harness-session.contract.spec.ts b/test/contract/harness-session.contract.spec.ts new file mode 100644 index 0000000..b902419 --- /dev/null +++ b/test/contract/harness-session.contract.spec.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { AcpHarness } from '../../packages/acp-harness/src/harness.js' +import { + clientProfiles, + type ClientCapabilityProfile, +} from '../../packages/acp-harness/src/client-profiles.js' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' + +const harnesses: AcpHarness[] = [] + +afterEach(async () => { + await Promise.all(harnesses.splice(0).map(harness => harness.dispose())) +}) + +async function waitFor( + predicate: () => boolean, + timeoutMs = 2_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) + throw new Error('Timed out waiting for condition') + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +async function startHarness( + args: readonly string[] = [], + profile: ClientCapabilityProfile = clientProfiles.minimal +): Promise<{ + harness: AcpHarness + targetId: string +}> { + const harness = new AcpHarness(await createFixtureRegistry()) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args, + }) + await harness.initialize(target.id, profile) + return { harness, targetId: target.id } +} + +describe('Harness session lifecycle contract', () => { + it('reduces session/new, streaming update and prompt completion', async () => { + const { harness, targetId } = await startHarness() + const session = await harness.createSession(targetId) + + const submission = await harness.submitPrompt({ + targetHandleId: targetId, + sessionId: session.sessionId, + text: 'reply deterministically', + }) + expect(submission.sessionId).toBe(session.sessionId) + await expect( + harness.submitPrompt({ + targetHandleId: targetId, + sessionId: session.sessionId, + text: 'must not overlap', + }) + ).rejects.toMatchObject({ code: 'SESSION_BUSY' }) + await waitFor( + () => harness.getSession(session.sessionId)?.status === 'completed' + ) + + const state = harness.getSession(session.sessionId) + expect(state).toMatchObject({ + status: 'completed', + stopReason: 'end_turn', + }) + expect(state?.messages['message-1']?.chunks).toEqual([ + { type: 'text', text: 'fixture response' }, + ]) + + await harness.submitPrompt({ + targetHandleId: targetId, + sessionId: session.sessionId, + text: 'continue the same session', + }) + await waitFor( + () => + harness.getSession(session.sessionId)?.status === 'completed' && + harness.getSession(session.sessionId)?.messages['message-1']?.chunks + .length === 2 + ) + + expect(harness.trace.map(frame => frame.method)).toContain('session/new') + expect(harness.trace.map(frame => frame.method)).toContain('session/prompt') + }) + + it('cancels the prompt and every pending permission together', async () => { + const { harness, targetId } = await startHarness( + ['--permission-prompt'], + clientProfiles['permission-only'] + ) + const session = await harness.createSession(targetId) + await harness.submitPrompt({ + targetHandleId: targetId, + sessionId: session.sessionId, + text: 'request permission', + }) + await waitFor( + () => + Object.keys( + harness.getSession(session.sessionId)?.pendingPermissions ?? {} + ).length === 1 + ) + const permissionId = Object.keys( + harness.getSession(session.sessionId)?.pendingPermissions ?? {} + )[0] + expect(permissionId).toBeDefined() + if (permissionId === undefined) throw new Error('Permission ID is missing') + expect(() => + harness.respondPermission({ + targetHandleId: targetId, + sessionId: session.sessionId, + permissionId, + outcome: { outcome: 'selected', optionId: 'not-allowlisted' }, + }) + ).toThrowError( + expect.objectContaining({ code: 'UNKNOWN_PERMISSION_OPTION' }) + ) + + await harness.cancelSession(targetId, session.sessionId) + await waitFor( + () => harness.getSession(session.sessionId)?.status === 'cancelled' + ) + + const state = harness.getSession(session.sessionId) + expect(state?.pendingPermissions).toEqual({}) + expect(state?.stopReason).toBe('cancelled') + expect(state?.toolCalls['permission-tool']?.data.status).toBe('cancelled') + expect(harness.trace).toContainEqual( + expect.objectContaining({ + direction: 'client-to-agent', + kind: 'notification', + method: 'session/cancel', + }) + ) + expect( + harness.trace.some( + frame => + frame.direction === 'client-to-agent' && + frame.kind === 'response' && + frame.raw.includes('cancelled') + ) + ).toBe(true) + }) + + it('converges an in-flight prompt to crashed when the agent exits', async () => { + const { harness, targetId } = await startHarness(['--crash-during-prompt']) + const target = harness.processes.get(targetId) + const session = await harness.createSession(targetId) + await harness.submitPrompt({ + targetHandleId: targetId, + sessionId: session.sessionId, + text: 'crash now', + }) + + expect(await target.exited).toEqual({ code: 17, signal: null }) + await waitFor( + () => harness.getSession(session.sessionId)?.status === 'crashed' + ) + + expect(harness.getSession(session.sessionId)).toMatchObject({ + status: 'crashed', + pendingPermissions: {}, + }) + }) +}) diff --git a/test/contract/harness-trace.contract.spec.ts b/test/contract/harness-trace.contract.spec.ts new file mode 100644 index 0000000..cb2d72f --- /dev/null +++ b/test/contract/harness-trace.contract.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { AcpHarness } from '../../packages/acp-harness/src/harness.js' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' + +const harnesses: AcpHarness[] = [] + +afterEach(async () => { + await Promise.all(harnesses.splice(0).map(harness => harness.dispose())) +}) + +async function waitFor( + predicate: () => boolean, + timeoutMs = 1_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) + throw new Error('Timed out waiting for condition') + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +describe('Harness raw trace and semantic event contract', () => { + it('correlates initialize frames with a normalized capability event', async () => { + const harness = new AcpHarness( + await createFixtureRegistry(), + undefined, + 'trace-contract-run' + ) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + + await harness.initialize(target.id) + + const request = harness.trace.find( + frame => frame.direction === 'client-to-agent' && frame.kind === 'request' + ) + const response = harness.trace.find( + frame => + frame.direction === 'agent-to-client' && frame.kind === 'response' + ) + const capabilities = harness.events.find( + event => event.type === 'capability/snapshot' + ) + + expect(request).toMatchObject({ + runId: 'trace-contract-run', + connectionId: target.id, + method: 'initialize', + }) + expect(response?.requestId).toBe(request?.requestId) + expect(capabilities).toMatchObject({ + runId: 'trace-contract-run', + connectionId: target.id, + agentInfo: { name: 'axis-deterministic-fixture' }, + sourceTraceIds: [request?.id, response?.id], + }) + + const sequences = [...harness.trace, ...harness.events] + .map(item => item.sequence) + .sort((left, right) => left - right) + expect(new Set(sequences).size).toBe(sequences.length) + expect(sequences).toEqual( + Array.from({ length: sequences.length }, (_, index) => index + 1) + ) + }) + + it('records invalid stdout even when the SDK continues with later frames', async () => { + const harness = new AcpHarness(await createFixtureRegistry()) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args: ['--stdout-noise'], + }) + + const initialized = await harness.initialize(target.id) + + expect(initialized.response.agentInfo?.name).toBe( + 'axis-deterministic-fixture' + ) + expect(harness.trace).toContainEqual( + expect.objectContaining({ + direction: 'agent-to-client', + kind: 'invalid-json', + raw: 'fixture stdout noise', + }) + ) + }) + + it('keeps agent stderr outside the ACP protocol ledger', async () => { + const harness = new AcpHarness(await createFixtureRegistry()) + harnesses.push(harness) + const target = await harness.startTarget({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args: ['--stderr-marker'], + }) + + await harness.initialize(target.id) + await waitFor(() => target.stderr.includes('fixture stderr marker')) + + expect(target.stderr).toContain('fixture stderr marker') + expect( + harness.trace.every(frame => !frame.raw.includes('stderr marker')) + ).toBe(true) + }) +}) diff --git a/test/contract/project-boundary.contract.spec.ts b/test/contract/project-boundary.contract.spec.ts new file mode 100644 index 0000000..f4a9136 --- /dev/null +++ b/test/contract/project-boundary.contract.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('contract test project boundary', () => { + it('runs independently in Node', () => { + expect(typeof process.versions.node).toBe('string') + }) +}) diff --git a/test/contract/real-agent.contract.spec.ts b/test/contract/real-agent.contract.spec.ts new file mode 100644 index 0000000..7ec4167 --- /dev/null +++ b/test/contract/real-agent.contract.spec.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { AcpHarness } from '../../packages/acp-harness/src/harness.js' +import { createOpenCodeTarget } from '../../packages/acp-harness/src/registry-targets.js' +import { TargetRegistry } from '../../packages/acp-harness/src/target-registry.js' +import { repositoryRoot } from '../helpers/acp-fixture.js' + +const openCodePath = + process.env.AXIS_OPENCODE_PATH ?? '/opt/homebrew/bin/opencode' + +describe.skipIf(!existsSync(openCodePath))('OpenCode real ACP agent', () => { + it('negotiates initialize and records a capability snapshot without a model turn', async () => { + const registry = await TargetRegistry.create( + [createOpenCodeTarget(openCodePath)], + [repositoryRoot] + ) + const harness = new AcpHarness(registry) + + try { + const target = await harness.startTarget({ + targetId: 'opencode', + workspaceRoot: repositoryRoot, + }) + const initialization = await harness.initialize(target.id) + + expect(initialization.response.protocolVersion).toBe(1) + expect(harness.trace).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'request', method: 'initialize' }), + expect.objectContaining({ kind: 'response' }), + ]) + ) + expect(harness.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'capability/snapshot' }), + ]) + ) + + await harness.stopTarget(target.id) + expect(harness.processes.list()).toEqual([]) + } finally { + await harness.dispose() + } + }, 15_000) +}) diff --git a/test/helpers/acp-fixture.ts b/test/helpers/acp-fixture.ts new file mode 100644 index 0000000..3348ab0 --- /dev/null +++ b/test/helpers/acp-fixture.ts @@ -0,0 +1,36 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + TargetRegistry, + type TargetDefinition, +} from '../../packages/acp-harness/src/index.js' + +export const repositoryRoot = resolve( + fileURLToPath(new URL('../..', import.meta.url)) +) + +export const fixtureAgentPath = resolve( + repositoryRoot, + 'fixtures/acp-agents/bin/fixture-agent.mjs' +) + +export const fixtureTarget: TargetDefinition = { + id: 'fixture-agent', + command: process.execPath, + fixedArgs: [fixtureAgentPath], + allowedArgs: [ + '--crash-after-initialize', + '--stdout-noise', + '--stderr-marker', + '--permission-prompt', + '--crash-during-prompt', + '--capability-method-mismatch', + ], + environmentAllowlist: ['PATH'], + maxProcesses: 2, + expectedProtocolVersion: 'v1', +} + +export async function createFixtureRegistry(): Promise { + return TargetRegistry.create([fixtureTarget], [repositoryRoot]) +} diff --git a/test/replay/project-boundary.replay.spec.ts b/test/replay/project-boundary.replay.spec.ts new file mode 100644 index 0000000..5b9cfac --- /dev/null +++ b/test/replay/project-boundary.replay.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('replay test project boundary', () => { + it('runs independently in Node', () => { + expect(typeof process.versions.node).toBe('string') + }) +}) diff --git a/test/replay/scenario-transcript.replay.spec.ts b/test/replay/scenario-transcript.replay.spec.ts new file mode 100644 index 0000000..7fb3bb2 --- /dev/null +++ b/test/replay/scenario-transcript.replay.spec.ts @@ -0,0 +1,62 @@ +import { + serializeTranscript, + stateHash, + TranscriptReplay, +} from '@axis-ui/acp-core' +import { describe, expect, it } from 'vitest' +import { + coreScenarios, + ScenarioRunner, +} from '../../packages/acp-harness/src/scenario.js' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' + +describe('Scenario transcript integration', () => { + it('replays recorded facts to the same live session state hash', async () => { + const runner = new ScenarioRunner({ + registry: await createFixtureRegistry(), + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + const report = await runner.run(coreScenarios['normal-prompt-turn']) + const finalSnapshot = report.stateSnapshots.at(-1) + expect(finalSnapshot).toBeDefined() + + const replay = TranscriptReplay.from(serializeTranscript(report.transcript)) + const replayed = replay.playToEnd() + const sessionId = finalSnapshot?.state.sessionId ?? '' + + expect(replayed.states[sessionId]).toEqual(finalSnapshot?.state) + expect(report.transcript.integrity.stateHashes[sessionId]).toBe( + await stateHash(finalSnapshot?.state) + ) + expect(await replay.verifyIntegrity()).toBe(true) + expect(serializeTranscript(report.transcript)).not.toContain(repositoryRoot) + }) + + it('preserves Raw Frame links while replaying semantic events only', async () => { + const runner = new ScenarioRunner({ + registry: await createFixtureRegistry(), + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + const report = await runner.run(coreScenarios['cancel-during-permission']) + const traceIds = new Set(report.transcript.rawFrames.map(frame => frame.id)) + const linkedEvents = report.transcript.events.filter( + event => event.sourceTraceIds.length > 0 + ) + + expect(linkedEvents.length).toBeGreaterThan(0) + expect( + linkedEvents.every(event => + event.sourceTraceIds.every(traceId => traceIds.has(traceId)) + ) + ).toBe(true) + + const replay = new TranscriptReplay(report.transcript) + expect(replay.seek(0).states).toEqual({}) + expect(replay.playToEnd().states).not.toEqual({}) + }) +}) diff --git a/test/replay/transcript.replay.spec.ts b/test/replay/transcript.replay.spec.ts new file mode 100644 index 0000000..ab1a644 --- /dev/null +++ b/test/replay/transcript.replay.spec.ts @@ -0,0 +1,172 @@ +import type { + AxisAcpEvent, + ProtocolTraceFrame, + TranscriptDraft, +} from '@axis-ui/acp-core' +import { + createTranscript, + parseTranscript, + serializeTranscript, + stateHash, + TranscriptReplay, +} from '@axis-ui/acp-core' +import { describe, expect, it } from 'vitest' + +const eventMetadata = { + runId: 'run-replay', + connectionId: 'connection-replay', + sessionId: 'session-replay', + timestamp: '2026-08-20T00:00:00.000Z', + protocolVersion: 'v1' as const, + sourceTraceIds: [] as readonly string[], +} + +function transcriptDraft(secret = 'sk-test-secret'): TranscriptDraft { + const prompting: AxisAcpEvent = { + ...eventMetadata, + id: 'event-1', + sequence: 1, + type: 'session/state-changed', + state: 'prompting', + } + const message: AxisAcpEvent = { + ...eventMetadata, + id: 'event-2', + sequence: 2, + type: 'message/chunk-appended', + messageId: 'message-1', + role: 'agent', + content: { type: 'text', text: `result for ${secret}` }, + } + const completed: AxisAcpEvent = { + ...eventMetadata, + id: 'event-3', + sequence: 3, + type: 'session/state-changed', + state: 'completed', + stopReason: 'end_turn', + } + const rawFrame: ProtocolTraceFrame = { + id: 'trace-1', + runId: eventMetadata.runId, + connectionId: eventMetadata.connectionId, + sequence: 1, + timestamp: eventMetadata.timestamp, + protocolVersion: 'v1', + direction: 'client-to-agent', + kind: 'request', + method: 'initialize', + requestId: 1, + raw: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { token: secret }, + }), + byteLength: 100, + parsed: { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { token: secret }, + }, + } + + return { + run: { + id: eventMetadata.runId, + scenarioId: 'normal-prompt-turn', + startedAt: eventMetadata.timestamp, + completedAt: '2026-08-20T00:00:01.000Z', + toolkitVersion: '0.0.0', + protocolVersion: 'v1', + }, + target: { + id: 'fixture-agent', + transport: 'stdio', + protocolVersion: 'v1', + args: ['/Users/example/private-workspace'], + }, + clientProfile: { + id: 'minimal', + capabilities: {}, + allowedClientMethods: ['session/update'], + }, + rawFrames: [rawFrame], + events: [completed, message, prompting], + assertions: [{ id: 'session-status', passed: true, message: 'completed' }], + diagnostics: [], + } +} + +describe('Axis ACP transcript replay', () => { + it('steps, seeks and restores the same state hash without side effects', async () => { + const transcript = await createTranscript(transcriptDraft(), { + secretValues: ['sk-test-secret', '/Users/example/private-workspace'], + }) + const replay = new TranscriptReplay(transcript) + + expect(replay.step().states['session-replay']?.status).toBe('prompting') + expect( + replay.seek(2).states['session-replay']?.messages['message-1'] + ).toBeDefined() + expect(replay.playToEnd().states['session-replay']?.status).toBe( + 'completed' + ) + expect(await replay.verifyIntegrity()).toBe(true) + + const serialized = serializeTranscript(transcript) + const imported = TranscriptReplay.from(serialized) + imported.playToEnd() + expect(await imported.stateHashes()).toEqual( + transcript.integrity.stateHashes + ) + expect(await imported.verifyIntegrity()).toBe(true) + }) + + it('redacts keys and literal secrets from parsed and raw evidence', async () => { + const transcript = await createTranscript(transcriptDraft(), { + secretValues: ['sk-test-secret', '/Users/example/private-workspace'], + }) + const serialized = serializeTranscript(transcript) + + expect(serialized).not.toContain('sk-test-secret') + expect(serialized).not.toContain('/Users/example/private-workspace') + expect(serialized).toContain('[REDACTED]') + expect(transcript.redactionManifest.entries.length).toBeGreaterThan(2) + expect(transcript.redactionManifest.entries).toContainEqual( + expect.objectContaining({ reason: 'sensitive-key' }) + ) + expect(transcript.redactionManifest.entries).toContainEqual( + expect.objectContaining({ reason: 'secret-value' }) + ) + }) + + it('uses canonical object ordering and detects transcript tampering', async () => { + expect(await stateHash({ b: 2, a: 1 })).toBe( + await stateHash({ a: 1, b: 2 }) + ) + + const transcript = await createTranscript(transcriptDraft()) + const tampered = { + ...transcript, + events: transcript.events.map(event => + event.id === 'event-3' && event.type === 'session/state-changed' + ? { ...event, state: 'failed' as const } + : event + ), + } + const replay = new TranscriptReplay(tampered) + replay.playToEnd() + + expect(await replay.verifyIntegrity()).toBe(false) + }) + + it('rejects an unsupported schema version before replay', async () => { + const transcript = await createTranscript(transcriptDraft()) + + expect(() => parseTranscript({ ...transcript, schemaVersion: 2 })).toThrow( + 'Unsupported transcript schema version: 2' + ) + }) +}) diff --git a/test/repository/repository-boundary.spec.ts b/test/repository/repository-boundary.spec.ts new file mode 100644 index 0000000..eb2a489 --- /dev/null +++ b/test/repository/repository-boundary.spec.ts @@ -0,0 +1,117 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +interface PackageManifest { + name: string + private?: boolean + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + scripts?: Record +} + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +const publicPackages = [ + ['packages/components', 'axis-ui'], + ['packages/theme-chalk', '@axis-ui/theme-chalk'], + ['packages/utils', '@axis-ui/utils'], +] as const + +const privateDevKitPackages = [ + ['packages/acp-core', '@axis-ui/acp-core'], + ['packages/acp-harness', '@axis-ui/acp-harness'], + ['packages/acp-cli', '@axis-ui/acp-cli'], + ['apps/acp-devtools', '@axis-ui/acp-devtools'], + ['fixtures/acp-agents', '@axis-ui/acp-fixture-agents'], +] as const + +function readText(path: string): string { + return readFileSync(resolve(repositoryRoot, path), 'utf8') +} + +function readJson(path: string): T { + return JSON.parse(readText(path)) as T +} + +function readManifest(path: string): PackageManifest { + return readJson(`${path}/package.json`) +} + +describe('repository and release boundaries', () => { + it('uses only the precise workspace roots from the ACP plan', () => { + const entries = [ + ...readText('pnpm-workspace.yaml').matchAll(/- '([^']+)'/g), + ] + .map(match => match[1]) + .sort() + + expect(entries).toEqual( + ['./apps/**', './fixtures/acp-agents', './packages/**', './play'].map( + entry => entry.replace(/^\.\//, '') + ) + ) + }) + + it('keeps the three existing public package names unchanged', () => { + for (const [path, expectedName] of publicPackages) { + const manifest = readManifest(path) + + expect(manifest.name).toBe(expectedName) + expect(manifest.private).not.toBe(true) + } + }) + + it('marks every ACP workspace package as private', () => { + for (const [path, expectedName] of privateDevKitPackages) { + const manifest = readManifest(path) + + expect(manifest.name).toBe(expectedName) + expect(manifest.private).toBe(true) + } + }) + + it('prevents public packages from depending on private ACP packages', () => { + for (const [path] of publicPackages) { + const manifest = readManifest(path) + const dependencies = { + ...manifest.dependencies, + ...manifest.devDependencies, + ...manifest.peerDependencies, + } + + expect(Object.keys(dependencies)).not.toContainEqual( + expect.stringMatching(/^@axis-ui\/acp-/) + ) + } + }) + + it('builds all workspaces but publish-checks and releases an allowlist', () => { + const root = readJson('package.json') + const buildAll = root.scripts?.['build:all'] ?? '' + const checkPublish = root.scripts?.['check:publish'] ?? '' + const releasePackages = root.scripts?.['release:packages'] ?? '' + + expect(buildAll).toContain('--filter "./packages/**"') + expect(buildAll).toContain('--filter "./apps/**"') + expect(buildAll).toContain('--filter "./fixtures/acp-agents"') + + for (const [, packageName] of publicPackages) { + expect(checkPublish).toContain(`--filter ${packageName}`) + expect(releasePackages).toContain(`--filter ${packageName}`) + } + + expect(checkPublish).not.toContain('--filter "./packages/**"') + expect(releasePackages).not.toContain('pnpm -r publish') + }) + + it('disables Changesets versioning and tags for private packages', () => { + const changesets = readJson<{ privatePackages?: boolean }>( + '.changeset/config.json' + ) + + expect(changesets.privatePackages).toBe(false) + }) +}) diff --git a/test/scenario/core-scenarios.scenario.spec.ts b/test/scenario/core-scenarios.scenario.spec.ts new file mode 100644 index 0000000..5bf11b2 --- /dev/null +++ b/test/scenario/core-scenarios.scenario.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' +import { + coreScenarios, + ScenarioRunner, + type CoreScenarioId, +} from '../../packages/acp-harness/src/scenario.js' + +describe('three fixed ACP scenarios', () => { + it.each(Object.keys(coreScenarios) as CoreScenarioId[])( + 'runs %s deterministically', + async scenarioId => { + const runner = new ScenarioRunner({ + registry: await createFixtureRegistry(), + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + + const report = await runner.run(coreScenarios[scenarioId]) + + expect(report.status).toBe('passed') + expect(report.assertions.every(assertion => assertion.passed)).toBe(true) + expect(report.traceCount).toBeGreaterThan(0) + expect(report.eventCount).toBeGreaterThan(0) + if (scenarioId === 'capability-method-mismatch') { + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ + kind: 'capability-contract-mismatch', + subject: 'agent', + invariantId: 'omitted-capability-is-unsupported', + faultInjectionIds: ['unsupported-terminal-request'], + stateSnapshotIds: expect.arrayContaining([expect.any(String)]), + }) + ) + } else { + expect(report.diagnostics).toEqual([]) + } + } + ) +}) diff --git a/test/scenario/lifecycle-invariants.scenario.spec.ts b/test/scenario/lifecycle-invariants.scenario.spec.ts new file mode 100644 index 0000000..aff764d --- /dev/null +++ b/test/scenario/lifecycle-invariants.scenario.spec.ts @@ -0,0 +1,162 @@ +import type { InvariantRunContext, ProtocolTraceFrame } from '@axis-ui/acp-core' +import { describe, expect, it } from 'vitest' +import { + evaluateLifecycleInvariants, + lifecycleInvariants, +} from '../../packages/acp-harness/src/invariants.js' + +describe('seven lifecycle invariants', () => { + it('keeps the fixed rule set explicit', () => { + expect(lifecycleInvariants.map(invariant => invariant.id)).toEqual([ + 'jsonrpc-response-correlation', + 'notification-must-not-have-response', + 'stdio-stdout-valid-acp-only', + 'omitted-capability-is-unsupported', + 'cancel-pending-permission-response', + 'cancelled-stop-reason', + 'no-orphan-process-after-run', + ]) + }) + + it('classifies protocol, capability and resource evidence separately', () => { + const trace: ProtocolTraceFrame[] = [ + frame(1, { + kind: 'request', + direction: 'client-to-agent', + requestId: 2, + method: 'session/prompt', + }), + frame(2, { + kind: 'request', + direction: 'agent-to-client', + requestId: 3, + method: 'session/request_permission', + }), + frame(3, { + kind: 'request', + direction: 'agent-to-client', + requestId: 4, + method: 'terminal/create', + }), + frame(4, { + kind: 'notification', + direction: 'client-to-agent', + method: 'session/cancel', + }), + frame(5, { + kind: 'invalid-json', + direction: 'agent-to-client', + raw: 'agent log on stdout', + }), + frame(6, { + kind: 'response', + direction: 'agent-to-client', + requestId: 99, + }), + frame(7, { + kind: 'response', + direction: 'agent-to-client', + requestId: null, + }), + ] + const context: InvariantRunContext = { + runId: 'diagnostic-run', + scenarioId: 'fault-matrix', + profile: { + id: 'minimal', + capabilities: {}, + allowedClientMethods: ['session/update'], + }, + trace, + events: [], + stateSnapshots: [], + activeProcessCount: 1, + faultInjectionIds: ['synthetic-seven-rule-matrix'], + } + + const diagnostics = evaluateLifecycleInvariants(context) + + expect(new Set(diagnostics.map(item => item.invariantId))).toEqual( + new Set(lifecycleInvariants.map(invariant => invariant.id)) + ) + expect( + diagnostics.find( + item => item.invariantId === 'stdio-stdout-valid-acp-only' + ) + ).toMatchObject({ + kind: 'protocol-violation', + subject: 'agent', + references: [expect.objectContaining({ requirement: 'MUST NOT' })], + }) + expect( + diagnostics.find( + item => item.invariantId === 'no-orphan-process-after-run' + ) + ).toMatchObject({ + kind: 'resource-leak', + subject: 'harness', + references: [], + }) + }) + + it('does not treat an already resolved permission as pending at cancel', () => { + const trace: ProtocolTraceFrame[] = [ + frame(1, { + kind: 'request', + direction: 'agent-to-client', + requestId: 1, + method: 'session/request_permission', + }), + frame(2, { + kind: 'response', + direction: 'client-to-agent', + requestId: 1, + raw: '{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"allow"}}}', + }), + frame(3, { + kind: 'notification', + direction: 'client-to-agent', + method: 'session/cancel', + }), + ] + const diagnostics = evaluateLifecycleInvariants({ + runId: 'resolved-permission-run', + scenarioId: 'resolved-before-cancel', + profile: { + id: 'permission-only', + capabilities: {}, + allowedClientMethods: ['session/update', 'session/request_permission'], + }, + trace, + events: [], + stateSnapshots: [], + activeProcessCount: 0, + faultInjectionIds: [], + }) + + expect(diagnostics).not.toContainEqual( + expect.objectContaining({ + invariantId: 'cancel-pending-permission-response', + }) + ) + }) +}) + +function frame( + sequence: number, + overrides: Partial +): ProtocolTraceFrame { + return { + id: `trace-${sequence}`, + runId: 'diagnostic-run', + connectionId: 'diagnostic-connection', + sequence, + timestamp: '2026-08-20T00:00:00.000Z', + protocolVersion: 'v1', + direction: 'agent-to-client', + kind: 'stdout-noise', + raw: '{}', + byteLength: 2, + ...overrides, + } +} diff --git a/test/scenario/project-boundary.scenario.spec.ts b/test/scenario/project-boundary.scenario.spec.ts new file mode 100644 index 0000000..5f977b2 --- /dev/null +++ b/test/scenario/project-boundary.scenario.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('scenario test project boundary', () => { + it('runs independently in Node', () => { + expect(typeof process.versions.node).toBe('string') + }) +}) diff --git a/test/security/local-bridge.security.spec.ts b/test/security/local-bridge.security.spec.ts new file mode 100644 index 0000000..a04a6a1 --- /dev/null +++ b/test/security/local-bridge.security.spec.ts @@ -0,0 +1,183 @@ +import { once } from 'node:events' +import WebSocket from 'ws' +import { afterEach, describe, expect, it } from 'vitest' +import { LocalBridge } from '../../packages/acp-harness/src/bridge/local-bridge.js' +import { TargetRegistry } from '../../packages/acp-harness/src/target-registry.js' +import { fixtureTarget, repositoryRoot } from '../helpers/acp-fixture.js' + +const origin = 'http://127.0.0.1:5173' +const bridges: LocalBridge[] = [] +const sockets: WebSocket[] = [] + +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate() + await Promise.all(bridges.splice(0).map(bridge => bridge.stop())) +}) + +async function connect(url: string): Promise { + const socket = new WebSocket(url, { origin }) + sockets.push(socket) + await once(socket, 'open') + return socket +} + +async function nextJson(socket: WebSocket): Promise> { + const [data] = (await once(socket, 'message')) as [Buffer] + return JSON.parse(data.toString()) as Record +} + +describe('LocalBridge security boundary', () => { + it('refuses a non-loopback bind address and weak token', () => { + expect( + () => + new LocalBridge({ + host: '0.0.0.0', + allowedOrigins: [origin], + onMessage: async () => ({}), + }) + ).toThrowError(expect.objectContaining({ code: 'NON_LOOPBACK_BRIDGE' })) + + expect( + () => + new LocalBridge({ + token: 'short', + allowedOrigins: [origin], + onMessage: async () => ({}), + }) + ).toThrowError(expect.objectContaining({ code: 'WEAK_BRIDGE_TOKEN' })) + }) + + it('requires both an allowed Origin and the high-entropy token', async () => { + const bridge = new LocalBridge({ + allowedOrigins: [origin], + onMessage: async () => ({}), + }) + bridges.push(bridge) + const address = await bridge.start() + + const wrongToken = new WebSocket(`${address.url}?token=${'x'.repeat(43)}`, { + origin, + }) + wrongToken.on('error', () => undefined) + const [request, response] = (await once( + wrongToken, + 'unexpected-response' + )) as [unknown, { statusCode: number }] + void request + expect(response.statusCode).toBe(401) + + const wrongOrigin = new WebSocket( + `${address.url}?token=${encodeURIComponent(address.token)}`, + { origin: 'https://evil.example' } + ) + wrongOrigin.on('error', () => undefined) + const [, originResponse] = (await once( + wrongOrigin, + 'unexpected-response' + )) as [unknown, { statusCode: number }] + expect(originResponse.statusCode).toBe(401) + }) + + it('accepts only the strict target message schema and known target IDs', async () => { + const registry = await TargetRegistry.create( + [fixtureTarget], + [repositoryRoot] + ) + const bridge = new LocalBridge({ + allowedOrigins: [origin], + onMessage: async message => { + if (message.type === 'target/start') registry.get(message.targetId) + return { accepted: true } + }, + }) + bridges.push(bridge) + const address = await bridge.start() + const socket = await connect( + `${address.url}?token=${encodeURIComponent(address.token)}` + ) + + socket.send( + JSON.stringify({ + type: 'target/start', + requestId: 'bad-fields', + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + command: '/bin/sh', + }) + ) + expect(await nextJson(socket)).toMatchObject({ + type: 'error', + error: { code: 'BAD_MESSAGE' }, + }) + + socket.send( + JSON.stringify({ + type: 'scenario/run', + requestId: 'arbitrary-args', + targetId: 'fixture-agent', + scenarioId: 'normal-prompt-turn', + workspaceRoot: repositoryRoot, + args: ['--execute-arbitrary-command'], + }) + ) + expect(await nextJson(socket)).toMatchObject({ + type: 'error', + requestId: 'unknown', + error: { code: 'BAD_MESSAGE' }, + }) + + socket.send( + JSON.stringify({ + type: 'target/start', + requestId: 'unknown-target', + targetId: 'shell', + workspaceRoot: repositoryRoot, + }) + ) + expect(await nextJson(socket)).toMatchObject({ + type: 'error', + requestId: 'unknown-target', + error: { code: 'UNKNOWN_TARGET' }, + }) + + socket.send( + JSON.stringify({ + type: 'target/start', + requestId: 'known-target', + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + }) + ) + expect(await nextJson(socket)).toMatchObject({ + type: 'response', + requestId: 'known-target', + result: { accepted: true }, + }) + }) + + it('closes a connection that exceeds the configured message rate', async () => { + const bridge = new LocalBridge({ + allowedOrigins: [origin], + maxMessagesPerWindow: 1, + rateWindowMs: 10_000, + onMessage: async () => ({}), + }) + bridges.push(bridge) + const address = await bridge.start() + const socket = await connect( + `${address.url}?token=${encodeURIComponent(address.token)}` + ) + const message = JSON.stringify({ + type: 'target/stop', + requestId: 'rate', + targetHandleId: 'handle', + }) + + socket.send(message) + await nextJson(socket) + socket.send(message) + const [code] = (await once(socket, 'close')) as [number] + + expect(code).toBe(1008) + }) +}) diff --git a/test/security/process-manager.security.spec.ts b/test/security/process-manager.security.spec.ts new file mode 100644 index 0000000..c9d74d9 --- /dev/null +++ b/test/security/process-manager.security.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { ProcessManager } from '../../packages/acp-harness/src/process-manager.js' +import type { ResolvedTarget } from '../../packages/acp-harness/src/types.js' +import { fixtureTarget, repositoryRoot } from '../helpers/acp-fixture.js' + +function hangingTarget(): ResolvedTarget { + return { + definition: { ...fixtureTarget, id: 'hanging', maxProcesses: 1 }, + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + cwd: repositoryRoot, + environment: {}, + } +} + +describe('ProcessManager resource boundary', () => { + it('enforces per-target and global quotas', async () => { + const manager = new ProcessManager({ maxProcesses: 1 }) + const first = manager.start(hangingTarget()) + + expect(() => manager.start(hangingTarget())).toThrowError( + expect.objectContaining({ code: 'GLOBAL_PROCESS_LIMIT' }) + ) + + await manager.stop(first.id) + expect(manager.list()).toHaveLength(0) + }) + + it('terminates the isolated process group and removes the handle', async () => { + const manager = new ProcessManager({ gracefulShutdownMs: 100 }) + const target = manager.start(hangingTarget()) + + const exit = await manager.stop(target.id) + + expect(exit.signal).toMatch(/^SIG(?:TERM|KILL)$/u) + expect(manager.list()).toHaveLength(0) + if (process.platform !== 'win32') { + expect(() => process.kill(target.pid, 0)).toThrowError( + expect.objectContaining({ code: 'ESRCH' }) + ) + } + }) +}) diff --git a/test/security/project-boundary.security.spec.ts b/test/security/project-boundary.security.spec.ts new file mode 100644 index 0000000..29b28a7 --- /dev/null +++ b/test/security/project-boundary.security.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('security test project boundary', () => { + it('runs independently in Node', () => { + expect(typeof process.versions.node).toBe('string') + }) +}) diff --git a/test/security/target-registry.security.spec.ts b/test/security/target-registry.security.spec.ts new file mode 100644 index 0000000..eda4a2b --- /dev/null +++ b/test/security/target-registry.security.spec.ts @@ -0,0 +1,67 @@ +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { HarnessError } from '../../packages/acp-harness/src/errors.js' +import { + createFixtureRegistry, + repositoryRoot, +} from '../helpers/acp-fixture.js' + +describe('TargetRegistry security boundary', () => { + it('rejects an unknown browser-provided target ID', async () => { + const registry = await createFixtureRegistry() + + await expect( + registry.resolve({ targetId: 'shell', workspaceRoot: repositoryRoot }) + ).rejects.toMatchObject({ + code: 'UNKNOWN_TARGET', + } satisfies Partial) + }) + + it('rejects arbitrary arguments and environment variables', async () => { + const registry = await createFixtureRegistry() + + await expect( + registry.resolve({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args: ['-e', 'process.exit()'], + }) + ).rejects.toMatchObject({ + code: 'ARGUMENT_NOT_ALLOWED', + } satisfies Partial) + + await expect( + registry.resolve({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + environment: { NODE_OPTIONS: '--inspect' }, + }) + ).rejects.toMatchObject({ + code: 'ENVIRONMENT_NOT_ALLOWED', + } satisfies Partial) + }) + + it('rejects a workspace outside the configured realpath roots', async () => { + const registry = await createFixtureRegistry() + + await expect( + registry.resolve({ targetId: 'fixture-agent', workspaceRoot: tmpdir() }) + ).rejects.toMatchObject({ + code: 'WORKSPACE_NOT_ALLOWED', + } satisfies Partial) + }) + + it('resolves only fixed command data plus allowlisted values', async () => { + const registry = await createFixtureRegistry() + const resolved = await registry.resolve({ + targetId: 'fixture-agent', + workspaceRoot: repositoryRoot, + args: ['--crash-after-initialize'], + environment: { PATH: '/usr/bin' }, + }) + + expect(resolved.command).toBe(process.execPath) + expect(resolved.args.at(-1)).toBe('--crash-after-initialize') + expect(resolved.environment).toEqual({ PATH: '/usr/bin' }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index bd7b3f0..f55cdef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,11 @@ "**/__tests__", "dist/**", "docs/**", + "apps/acp-devtools/**", + "fixtures/acp-agents/**", + "packages/acp-core/**", + "packages/acp-harness/**", + "packages/acp-cli/**", "**/dist/**" ] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..b7c45ad --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "noEmit": true + }, + "exclude": ["node_modules", "**/dist/**"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 75378b7..60a66e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,23 +1,95 @@ -import { defineConfig } from 'vitest/config' -import { resolve } from 'path' +import { playwright } from '@vitest/browser-playwright' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +const aliases = { + '@': resolve(__dirname), + '@test': resolve(__dirname, './test'), + '@packages': resolve(__dirname, './packages'), + '@docs': resolve(__dirname, './docs'), + 'axis-ui': resolve(__dirname, './packages/components/index.ts'), + '@axis-ui/utils': resolve(__dirname, './packages/utils/index.ts'), + '@axis-ui/theme-chalk/src': resolve(__dirname, './packages/theme-chalk/src'), + '@axis-ui/theme-chalk': resolve(__dirname, './packages/theme-chalk'), + '@axis-ui/acp-core': resolve(__dirname, './packages/acp-core/src/index.ts'), +} export default defineConfig({ - // vueJsx 必须显式配置:esbuild 对 .tsx 的默认产物是 React JSX, - // 缺了它 virtual.tsx 这类 TSX 组件在运行时会抛 "React is not defined" - plugins: [vue(), vueJsx()], test: { - // 启用类似Jest的测试API - globals: true, - // 模拟DOM环境 - environment: 'happy-dom', - // 支持Vue文件 - include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'], - exclude: ['node_modules', 'dist', '.output'], - // 测试设置文件 - setupFiles: ['./test/setup/index.ts'], - // 测试覆盖率配置 + projects: [ + { + plugins: [vue(), vueJsx()], + resolve: { alias: aliases }, + test: { + name: 'axis-ui', + environment: 'happy-dom', + include: ['test/components/**/*.spec.ts', 'test/utils/**/*.spec.ts'], + setupFiles: ['./test/setup/index.ts'], + }, + }, + { + test: { + name: 'acp-node', + environment: 'node', + include: [ + 'test/repository/**/*.spec.ts', + 'packages/{acp-core,acp-harness,acp-cli}/**/*.spec.ts', + 'fixtures/acp-agents/**/*.spec.ts', + ], + }, + }, + { + plugins: [vue()], + test: { + name: 'acp-devtools', + environment: 'happy-dom', + include: ['apps/acp-devtools/test/unit/**/*.spec.ts'], + }, + }, + { + test: { + name: 'contract', + environment: 'node', + include: ['test/contract/**/*.contract.spec.ts'], + }, + }, + { + test: { + name: 'scenario', + environment: 'node', + include: ['test/scenario/**/*.scenario.spec.ts'], + }, + }, + { + test: { + name: 'replay', + environment: 'node', + include: ['test/replay/**/*.replay.spec.ts'], + }, + }, + { + test: { + name: 'security', + environment: 'node', + include: ['test/security/**/*.security.spec.ts'], + }, + }, + { + plugins: [vue()], + test: { + name: 'browser', + include: ['apps/acp-devtools/test/browser/**/*.browser.spec.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], @@ -35,8 +107,6 @@ export default defineConfig({ '**/*.scss', '**/*.css', ], - // 覆盖率阈值:按当前真实水平设定门槛,随测试补齐逐步上调 - // 注意:Vitest 的阈值直接写在 thresholds 下,嵌套 global 是 Jest 语法(会被当作 glob 忽略) thresholds: { statements: 78, branches: 48, @@ -44,29 +114,11 @@ export default defineConfig({ lines: 78, }, }, - // 快照测试配置 snapshotFormat: { escapeString: true, printBasicPrototype: false, }, - // 测试超时时间 testTimeout: 10000, - // 钩子超时时间 hookTimeout: 10000, }, - resolve: { - alias: { - '@': resolve(__dirname), - '@test': resolve(__dirname, './test'), - '@packages': resolve(__dirname, './packages'), - '@docs': resolve(__dirname, './docs'), - 'axis-ui': resolve(__dirname, './packages/components/index.ts'), - '@axis-ui/utils': resolve(__dirname, './packages/utils/index.ts'), - '@axis-ui/theme-chalk/src': resolve( - __dirname, - './packages/theme-chalk/src' - ), - '@axis-ui/theme-chalk': resolve(__dirname, './packages/theme-chalk'), - }, - }, })