From 2946832b3ab363da04bb5f005378512ba4a4ed26 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:47:29 +0800 Subject: [PATCH 1/2] feat(cli): add ACP tools, interactions and session-scoped MCP Complete PR5 tool projection, interaction handling, and isolated stdio MCP integration. Include the review fixes for cancellation fences, authoritative registration retirement, failed-turn tool terminal delivery, and the latest main compatibility epoch. Refs #3132 Refs #4862 Generated-by: Codex --- apps/desktop/e2e/side-chat-followups.spec.ts | 21 +- .../__tests__/acp-environment-mcp-fixture.ts | 28 + .../acp-session-event-mapper.test.ts | 399 ++++++++ .../acp-session-interactions.test.ts | 879 ++++++++++++++++++ .../cli/src/__tests__/acp-session-mcp.test.ts | 605 ++++++++++++ .../__tests__/acp-session-registry.test.ts | 526 ++++++++++- .../src/__tests__/acp-stdio-server.test.ts | 38 +- .../__tests__/acp-tools-child-process.test.ts | 687 ++++++++++++++ .../__tests__/bounded-chunk-buffer.test.ts | 19 + packages/cli/src/__tests__/cli.test.ts | 2 +- .../mcp-capability-publication.test.ts | 213 +++++ .../runtime-host-prompt-transcript.test.ts | 433 +++++++++ packages/cli/src/acp/README.md | 88 +- packages/cli/src/acp/VALIDATION.md | 106 +++ packages/cli/src/acp/active-promise.ts | 41 + packages/cli/src/acp/maka-acp-agent.ts | 31 +- packages/cli/src/acp/session-event-mapper.ts | 78 +- packages/cli/src/acp/session-interactions.ts | 621 +++++++++++++ packages/cli/src/acp/session-mcp.ts | 218 +++++ packages/cli/src/acp/session-registry.ts | 209 ++++- packages/cli/src/acp/stdio-server.ts | 5 + packages/cli/src/acp/tool-event-mapper.ts | 503 ++++++++++ packages/cli/src/bounded-chunk-buffer.ts | 21 +- packages/cli/src/cli-core.ts | 2 +- packages/cli/src/mcp-capability-provider.ts | 9 + .../cli/src/mcp-capability-publication.ts | 161 ++++ .../cli/src/runtime-host-session-channel.ts | 175 ++++ packages/cli/src/tui-mcp-control.ts | 127 +-- .../__tests__/client-capability-grant.test.ts | 51 + packages/core/src/client-capability-grant.ts | 6 +- .../client-capability-channel.test.ts | 127 +++ .../client-capability-coordinator.test.ts | 2 +- .../client-capability-session-scope.test.ts | 403 ++++++++ .../__tests__/client-capability-uds.test.ts | 59 ++ .../src/client/capability-provider-service.ts | 2 + .../src/client/client-capability-channel.ts | 84 +- .../src/client/client-capability.ts | 7 + .../runtime-host/src/client/connection.ts | 23 +- packages/runtime-host/src/client/index.ts | 5 +- .../src/client/reconnecting-connection.ts | 13 +- .../src/protocol/client-capability.ts | 25 +- packages/runtime-host/src/protocol/index.ts | 4 +- .../server/client-capability-coordinator.ts | 323 +++++-- .../client-capability-invocation-broker.ts | 26 +- .../src/test-only/client-capability-host.ts | 19 +- packages/ui/src/client-capability-prompt.tsx | 1 + 46 files changed, 7120 insertions(+), 305 deletions(-) create mode 100644 packages/cli/src/__tests__/acp-environment-mcp-fixture.ts create mode 100644 packages/cli/src/__tests__/acp-session-interactions.test.ts create mode 100644 packages/cli/src/__tests__/acp-session-mcp.test.ts create mode 100644 packages/cli/src/__tests__/acp-tools-child-process.test.ts create mode 100644 packages/cli/src/__tests__/mcp-capability-publication.test.ts create mode 100644 packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts create mode 100644 packages/cli/src/acp/VALIDATION.md create mode 100644 packages/cli/src/acp/active-promise.ts create mode 100644 packages/cli/src/acp/session-interactions.ts create mode 100644 packages/cli/src/acp/session-mcp.ts create mode 100644 packages/cli/src/acp/tool-event-mapper.ts create mode 100644 packages/cli/src/mcp-capability-publication.ts create mode 100644 packages/core/src/__tests__/client-capability-grant.test.ts create mode 100644 packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts diff --git a/apps/desktop/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts index 6f7c535b84..14af912e4e 100644 --- a/apps/desktop/e2e/side-chat-followups.spec.ts +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -95,6 +95,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await page.getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/ }).click(); const companion = page.locator('.maka-quote-companion'); const sideComposer = companion.locator(COMPOSER_INPUT); + const sideSubmit = companion.locator('.maka-composer button[type="submit"]'); await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT); await sideComposer.press('Enter'); await expect(companion).toContainText('Fake backend waiting'); @@ -104,10 +105,17 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', return created[0]!.id; }, originalSessionIds); const queued = companion.locator('.maka-composer-queue'); - for (const text of ['first follow-up', 'second follow-up', 'retract this follow-up']) { + const queueFollowUp = async (text: string): Promise => { await sideComposer.fill(text); + // Queue rows render optimistically before the previous Host admission + // releases the Composer's single-flight send slot. Wait for that actual + // interaction boundary so a fast next Enter is not intentionally ignored. + await expect(sideSubmit).toBeEnabled(); await sideComposer.press('Enter'); await expect(queued).toContainText(text); + }; + for (const text of ['first follow-up', 'second follow-up', 'retract this follow-up']) { + await queueFollowUp(text); } await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ 'first follow-up', 'second follow-up', 'retract this follow-up', @@ -118,6 +126,9 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await edit.press('Enter'); await expect(queued.locator('.maka-composer-queue-text').first()).toHaveText('edited first follow-up'); const grips = queued.locator('[draggable="true"]'); + // The Host projection can update before the edit request's pending UI + // state settles. A draggable grip is the user-visible readiness boundary. + await expect(grips).toHaveCount(3); await grips.nth(1).dragTo(grips.nth(0)); await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ 'second follow-up', 'edited first follow-up', 'retract this follow-up', @@ -150,9 +161,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await sideComposer.press('Enter'); await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); for (const text of ['successor one', 'successor two']) { - await sideComposer.fill(text); - await sideComposer.press('Enter'); - await expect(queued).toContainText(text); + await queueFollowUp(text); } await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true }); await sideComposer.fill('release the held response'); @@ -168,9 +177,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await sideComposer.press('Enter'); await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); for (const text of ['reconnected successor one', 'reconnected successor two']) { - await sideComposer.fill(text); - await sideComposer.press('Enter'); - await expect(queued).toContainText(text); + await queueFollowUp(text); } await armConnectionGap(app); // Capture the actual Desktop client before closing its transport. diff --git a/packages/cli/src/__tests__/acp-environment-mcp-fixture.ts b/packages/cli/src/__tests__/acp-environment-mcp-fixture.ts new file mode 100644 index 0000000000..43909e5171 --- /dev/null +++ b/packages/cli/src/__tests__/acp-environment-mcp-fixture.ts @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; + +// The manager redacts configured environment values in tool output. Observe +// each child process's environment through a derived value instead. +process.env.ACP_SESSION_FINGERPRINT = createHash('sha256') + .update(process.env.ACP_SESSION_SENTINEL ?? 'missing') + .digest('hex'); + +await import('@maka/mcp/test-only/stdio-server'); diff --git a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts index 45aec10a3d..0e8d6e5b3a 100644 --- a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { SessionEvent } from '@maka/core/events'; import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { InteractionPendingSnapshot } from '@maka/runtime-host/protocol'; import { AcpSessionEventMapper } from '../acp/session-event-mapper.js'; describe('ACP Session event mapper', () => { @@ -167,8 +168,406 @@ describe('ACP Session event mapper', () => { assert.equal(flushed, true); await accepting; }); + + test('prompt cancellation releases stalled notification delivery and flush', async () => { + const abort = new AbortController(); + let rejectDelivery!: (error: Error) => void; + const delivery = new Promise((_resolve, reject) => { + rejectDelivery = reject; + }); + const mapper = new AcpSessionEventMapper({ + sessionId: 'session-1', + signal: abort.signal, + notify: () => delivery, + }); + + const accepting = mapper.accept(toolOutput('tool', 1, 'pending')); + await new Promise((resolve) => setImmediate(resolve)); + abort.abort(); + await accepting; + await mapper.flush(); + rejectDelivery(new Error('Late transport failure')); + await new Promise((resolve) => setImmediate(resolve)); + }); + + test('replaces cumulative tool content, deduplicates output sequences and preserves stream/redaction', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(toolOutput('tool', 2, 'second', 'stderr', true)); + await mapper.accept(toolOutput('tool', 1, 'first')); + await mapper.accept(toolOutput('tool', 2, 'second', 'stderr', true)); + await mapper.accept( + event({ + type: 'tool_start', + toolUseId: 'tool', + toolName: 'Bash', + args: undefined, + argsPreview: { command: 'pwd' }, + activityKind: 'command', + }), + ); + const calls = notifications.filter(({ update }) => update.sessionUpdate === 'tool_call'); + assert.equal(calls.length, 1); + assert.equal(notifications.length, 3); + const update = toolUpdate(notifications.at(-1)!); + assert.equal(update.kind, 'execute'); + assert.equal('rawInput' in update, false); + assert.match(toolText(notifications.at(-1)!), /Input preview \(not full input\)/); + assert.ok( + toolText(notifications.at(-1)!).indexOf('first') < + toolText(notifications.at(-1)!).indexOf('second'), + ); + assert.match(toolText(notifications.at(-1)!), /\[stderr\] \[redacted\] second/); + }); + + test('an omitted result preserves live content until authoritative transcript replacement', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(toolOutput('tool', 1, 'transient output')); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + contentOmitted: true, + isError: false, + durationMs: 42, + content: { kind: 'text', text: '' }, + }), + ); + const omitted = toolUpdate(notifications.at(-1)!); + assert.equal(omitted.status, 'completed'); + assert.equal('content' in omitted, false); + assert.equal('rawOutput' in omitted, false); + await mapper.acceptTranscriptMessages('turn-1', [ + { + type: 'tool_result', + id: 'result', + turnId: 'turn-1', + ts: 2, + toolUseId: 'tool', + isError: false, + durationMs: 42, + content: { kind: 'text', text: 'authoritative result' }, + }, + ]); + assert.equal(toolText(notifications.at(-1)!), 'authoritative result'); + assert.deepEqual(toolUpdate(notifications.at(-1)!).rawOutput, { + kind: 'text', + text: 'authoritative result', + }); + const count = notifications.length; + await mapper.accept(toolOutput('tool', 3, 'late output')); + await mapper.accept( + event({ + type: 'tool_result_preview', + toolUseId: 'tool', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child', + agentName: 'Worker', + turnId: 'child-turn', + status: 'running', + permissionMode: 'ask', + }, + }), + ); + await mapper.finishTools('turn-1'); + assert.equal(notifications.length, count); + }); + + test('result before start creates one terminal card and late start only fills identity', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + isError: true, + content: { kind: 'text', text: 'failed' }, + durationMs: 13, + }), + ); + await mapper.accept( + event({ + type: 'tool_start', + toolUseId: 'tool', + toolName: 'Read', + args: { path: '/workspace/readme' }, + activityKind: 'read', + }), + ); + assert.equal( + notifications.filter(({ update }) => update.sessionUpdate === 'tool_call').length, + 1, + ); + const update = toolUpdate(notifications.at(-1)!); + assert.equal(update.title, 'Read'); + assert.equal(update.status, 'failed'); + assert.equal('content' in update, false); + assert.equal((update._meta?.maka as { durationMs: number } | undefined)?.durationMs, 13); + }); + + test('progress and preview replace a snapshot containing earlier output without ending a tool', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(toolOutput('tool', 1, 'working')); + await mapper.accept(event({ type: 'tool_progress', toolUseId: 'tool', chunk: 'steps:1/3' })); + await mapper.accept( + event({ + type: 'tool_result_preview', + toolUseId: 'tool', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child', + agentName: 'Worker', + turnId: 'child-turn', + status: 'running', + permissionMode: 'ask', + }, + }), + ); + assert.match(toolText(notifications.at(-1)!), /working/); + assert.match(toolText(notifications.at(-1)!), /Progress: steps:1\/3/); + assert.match(toolText(notifications.at(-1)!), /Preview:.*Worker/); + assert.equal(toolUpdate(notifications.at(-1)!).status, 'in_progress'); + await mapper.finishTools('turn-1', 'failed'); + assert.equal(toolUpdate(notifications.at(-1)!).status, 'failed'); + assert.match(toolText(notifications.at(-1)!), /without a result/); + }); + + test('does not label projected stored inputs as complete raw input', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + for (const toolName of ['WriteStdin', 'todo_write']) { + await mapper.acceptTranscriptMessages('turn-1', [ + { + type: 'tool_call', + id: toolName, + turnId: 'turn-1', + ts: 1, + toolName, + args: { inputPreview: { text: 'safe', bytes: 4, truncated: false } }, + }, + ]); + assert.equal('rawInput' in toolUpdate(notifications.at(-1)!), false); + } + }); + + test('bounds live output, terminal content and raw output, including multibyte truncation', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(toolOutput('tool', 1, '😀'.repeat(40_000))); + assert.match(toolText(notifications.at(-1)!), /truncated/); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + isError: false, + content: { kind: 'text', text: '😀'.repeat(40_000) }, + }), + ); + const update = toolUpdate(notifications.at(-1)!); + assert.ok(toolText(notifications.at(-1)!).length <= 64 * 1024); + assert.equal('rawOutput' in update, false); + assert.match(toolText(notifications.at(-1)!), /Result truncated/); + assert.equal( + Buffer.from(toolText(notifications.at(-1)!)).toString('utf8'), + toolText(notifications.at(-1)!), + ); + }); + + test('enforces the aggregate live-tool budget with a sticky projection failure', async () => { + const mapper = eventMapper([]); + for (let i = 0; i < 16; i += 1) + await mapper.accept(toolOutput(`tool-${i}`, 1, 'x'.repeat(64 * 1024))); + await assert.rejects(mapper.accept(toolOutput('tool-17', 1, 'x')), { + data: { source: 'adapter', code: 'tool_presentation_capacity' }, + }); + await assert.rejects(mapper.flush(), { + data: { source: 'adapter', code: 'tool_presentation_capacity' }, + }); + }); + + test('requires the authoritative result promised by an omitted terminal event', async () => { + const mapper = eventMapper([]); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + isError: false, + contentOmitted: true, + content: { kind: 'text', text: '' }, + }), + ); + await assert.rejects(mapper.finishTools('turn-1'), { + data: { source: 'adapter', code: 'tool_result_missing' }, + }); + }); + + test('a completed turn also rejects a started tool whose result event was entirely missing', async () => { + const mapper = eventMapper([]); + await mapper.accept( + event({ type: 'tool_start', toolUseId: 'tool', toolName: 'Read', args: undefined }), + ); + await assert.rejects(mapper.finishTools('turn-1', 'completed'), { + data: { source: 'adapter', code: 'tool_result_missing' }, + }); + }); + + test('notification failure remains visible through flush and suppresses later delivery', async () => { + let notifications = 0; + const failure = new Error('transport failed'); + const mapper = new AcpSessionEventMapper({ + sessionId: 'session-1', + notify: async () => { + notifications += 1; + throw failure; + }, + }); + await assert.rejects( + mapper.accept(toolOutput('tool', 1, 'first')), + (error) => error === failure, + ); + await assert.rejects(mapper.flush(), (error) => error === failure); + await assert.rejects( + mapper.accept(toolOutput('tool', 2, 'second')), + (error) => error === failure, + ); + assert.equal(notifications, 1); + }); + + test('keeps only bounded terminal identities and rejects the next distinct tool', async () => { + const mapper = new AcpSessionEventMapper({ sessionId: 'session-1', notify: async () => {} }); + for (let index = 0; index < 4096; index += 1) { + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: `tool-${index}`, + isError: false, + content: { kind: 'text', text: 'done' }, + }), + ); + } + await assert.rejects( + mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'overflow', + isError: false, + content: { kind: 'text', text: 'done' }, + }), + ), + { data: { source: 'adapter', code: 'tool_presentation_capacity' } }, + ); + }); + + test('repeated authoritative results and stale omitted events do not repeat terminal content', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + const result = event({ + type: 'tool_result', + toolUseId: 'tool', + isError: false, + content: { kind: 'text', text: 'done' }, + }); + await mapper.accept(result); + await mapper.accept(result); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + isError: false, + contentOmitted: true, + content: { kind: 'text', text: '' }, + }), + ); + assert.equal(notifications.length, 1); + }); + + test('interaction updates preserve Host closure reasons without reopening terminal tools', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'interaction', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run', + revision: 1, + status: 'pending', + outcome: null, + request: { kind: 'question', toolUseId: 'tool', questions: [] }, + }; + await mapper.pendingInteraction(pending); + assert.equal(toolUpdate(notifications.at(-1)!).status, 'pending'); + await mapper.resolvedInteraction( + { + ...pending, + revision: 2, + status: 'closed', + outcome: { kind: 'closure', reason: 'provider_disconnected', committedAt: 2 }, + }, + pending, + ); + assert.equal( + ( + toolUpdate(notifications.at(-1)!)._meta?.maka as + | { interaction: { reason: string } } + | undefined + )?.interaction.reason, + 'provider_disconnected', + ); + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + isError: true, + content: { kind: 'text', text: 'closed' }, + }), + ); + const count = notifications.length; + await mapper.pendingInteraction(pending); + assert.equal(notifications.length, count); + assert.equal(toolUpdate(notifications.at(-1)!).status, 'failed'); + }); }); +function toolOutput( + toolUseId: string, + seq: number, + chunk: string, + stream: 'stdout' | 'stderr' = 'stdout', + redacted = false, +): SessionEvent { + return event({ + type: 'tool_output_delta', + sessionId: 'session-1', + toolUseId, + toolCallId: toolUseId, + seq, + chunk, + stream, + redacted, + createdAt: 1, + }); +} + +function toolUpdate(notification: SessionNotification) { + const update = notification.update; + assert.ok(update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update'); + return update; +} + +function toolText(notification: SessionNotification): string { + return (toolUpdate(notification).content ?? []) + .map((entry) => + entry.type === 'content' && entry.content.type === 'text' ? entry.content.text : '', + ) + .join('\n'); +} + function eventMapper(notifications: SessionNotification[]): AcpSessionEventMapper { return new AcpSessionEventMapper({ sessionId: 'session-1', diff --git a/packages/cli/src/__tests__/acp-session-interactions.test.ts b/packages/cli/src/__tests__/acp-session-interactions.test.ts new file mode 100644 index 0000000000..ec3e5fefaa --- /dev/null +++ b/packages/cli/src/__tests__/acp-session-interactions.test.ts @@ -0,0 +1,879 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + agent, + client, + methods, + RequestError, + type ClientCapabilities, + type CreateElicitationRequest, + type CreateElicitationResponse, + type ElicitationFormMode, + type RequestPermissionRequest, + type RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; +import type { InteractionRequest } from '@maka/core/interaction'; +import { RuntimeHostOperationError, type RuntimeHostConnection } from '@maka/runtime-host/client'; +import type { + InteractionAnswerInput, + InteractionAnsweredSnapshot, + InteractionClosedSnapshot, + InteractionPendingSnapshot, + InteractionResolvedSnapshot, + InteractionSnapshot, +} from '@maka/runtime-host/protocol'; +import { + AcpSessionInteractions, + type AcpInteractionClient, + type AcpSessionInteractionsOptions, +} from '../acp/session-interactions.js'; + +describe('ACP Session interactions', () => { + test('official SDK routes carry typed forms and Session-scoped permission choices', async () => { + const inputs = [fullForm(), capability()]; + const fixtures: ReturnType[] = []; + const values = { + email: 'sdk@example.com', + count: 3, + ratio: 0.5, + enabled: true, + color: 'r', + tags: ['x'], + }; + let elicitationCalls = 0; + let permissionCalls = 0; + const sdkAgent = agent({ name: 'interaction-test-agent' }) + .onRequest(methods.agent.initialize, () => ({ protocolVersion: 1, agentCapabilities: {} })) + .onRequest(methods.agent.session.prompt, async ({ client: peer }) => { + const pending = inputs.shift(); + assert.ok(pending); + const fixture = interactionFixture(pending, { + client: { + capabilities: { elicitation: { form: {} } }, + createElicitation: (params, signal) => + peer.request(methods.client.elicitation.create, params, { + cancellationSignal: signal, + }), + requestPermission: (params, signal) => + peer.request(methods.client.session.requestPermission, params, { + cancellationSignal: signal, + }), + }, + }); + fixtures.push(fixture); + await fixture.bridge.pending(pending); + return { stopReason: 'end_turn' }; + }); + const sdkClient = client({ name: 'interaction-test-client' }) + .onRequest(methods.client.elicitation.create, ({ params }) => { + elicitationCalls += 1; + assert.equal(params.mode, 'form'); + return { action: 'accept', content: values }; + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissionCalls += 1; + assert.equal(params.options[0].kind, 'allow_always'); + return { outcome: { outcome: 'selected', optionId: params.options[0].optionId } }; + }); + await sdkClient.connectWith(sdkAgent, async (peer) => { + await peer.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} } }, + }); + for (let index = 0; index < 2; index += 1) { + assert.deepEqual( + await peer.request(methods.agent.session.prompt, { + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Go' }], + }), + { stopReason: 'end_turn' }, + ); + } + }); + assert.equal(elicitationCalls, 1); + assert.equal(permissionCalls, 1); + assert.deepEqual(fixtures[0].answers[0].answer, { kind: 'form', action: 'accept', values }); + assert.deepEqual(fixtures[1].answers[0].answer, { + kind: 'client_capability', + decision: 'allow', + }); + assert.deepEqual( + fixtures.flatMap((fixture) => fixture.failures), + [], + ); + }); + + test('questions preserve option hints, free text and individual unanswered questions', async () => { + const pending = snapshot({ + kind: 'question', + toolUseId: 'tool-1', + questions: [ + { + question: 'Approach?', + options: [{ label: 'Small', description: 'Minimal edit' }, { label: 'Large' }], + }, + { question: 'When?', options: [{ label: 'Now' }, { label: 'Later' }] }, + { question: 'Who?', options: [{ label: 'You' }, { label: 'Me' }] }, + ], + }); + const fixture = interactionFixture(pending); + const task = fixture.bridge.pending(pending); + const request = await fixture.elicited.promise; + assert.equal(request.mode, 'form'); + assert.ok('sessionId' in request); + assert.ok('toolCallId' in request); + assert.equal(request.sessionId, pending.sessionId); + assert.equal(request.toolCallId, 'tool-1'); + const schema = (request as ElicitationFormMode).requestedSchema; + assert.ok(schema.properties); + assert.deepEqual(schema.required, []); + assert.equal(schema.properties.q0.type, 'string'); + assert.equal(schema.properties.q0.title, 'Approach?'); + assert.match(String(schema.properties.q0.description), /Small: Minimal edit/); + assert.equal('enum' in schema.properties.q0, false); + fixture.elicitationResponse.resolve({ + action: 'accept', + content: { q0: ' A third option ', q1: ' ' }, + }); + await task; + assert.deepEqual( + fixture.answers.map(({ answer }) => answer), + [{ kind: 'question', answers: ['A third option', null, null] }], + ); + assert.equal(fixture.answered.length, 1); + assert.equal(fixture.resolutions.length, 1); + assert.deepEqual(fixture.failures, []); + }); + + test('form schemas preserve all field types and constrain accepted values without coercion', async () => { + const pending = fullForm(); + const fixture = interactionFixture(pending); + const task = fixture.bridge.pending(pending); + const request = (await fixture.elicited.promise) as ElicitationFormMode; + const schema = request.requestedSchema; + assert.ok(schema.properties); + assert.deepEqual(schema.required, ['email', 'count', 'ratio', 'enabled', 'color', 'tags']); + assert.deepEqual(schema.properties.email, { + type: 'string', + title: 'Email', + description: 'Contact address', + format: 'email', + minLength: 3, + maxLength: 100, + }); + assert.deepEqual(schema.properties.count, { + type: 'integer', + title: 'Count', + minimum: 2, + maximum: 4, + }); + assert.deepEqual(schema.properties.ratio, { + type: 'number', + title: 'Ratio', + minimum: 0.1, + maximum: 1, + }); + assert.deepEqual(schema.properties.enabled, { + type: 'boolean', + title: 'Enabled', + default: false, + }); + assert.deepEqual(schema.properties.color, { + type: 'string', + title: 'Color', + oneOf: [ + { const: 'r', title: 'Red' }, + { const: 'b', title: 'Blue' }, + ], + }); + assert.deepEqual(schema.properties.tags, { + type: 'array', + title: 'Tags', + items: { + anyOf: [ + { const: 'x', title: 'X' }, + { const: 'y', title: 'Y' }, + ], + }, + minItems: 1, + maxItems: 2, + }); + const values = { + email: 'user@example.com', + count: 2, + ratio: 0.25, + enabled: true, + color: 'b', + tags: ['x', 'y'], + }; + fixture.elicitationResponse.resolve({ action: 'accept', content: values }); + await task; + assert.deepEqual(fixture.answers[0]?.answer, { kind: 'form', action: 'accept', values }); + assert.deepEqual(fixture.failures, []); + }); + + test('form decline and cancel remain distinct and never submit content or defaults', async () => { + for (const action of ['decline', 'cancel'] as const) { + const fixture = interactionFixture(fullForm()); + fixture.elicitationResponse.resolve({ + action, + content: { enabled: true }, + } as CreateElicitationResponse); + await fixture.bridge.pending(fixture.initial); + assert.deepEqual(fixture.answers[0]?.answer, { kind: 'form', action }); + } + const fixture = interactionFixture( + snapshot({ + kind: 'form', + toolUseId: 'tool-1', + requester: { name: 'MCP' }, + message: 'Optional fields', + fields: [ + { name: 'optional', kind: 'boolean', label: 'Optional', required: false, default: true }, + ], + }), + ); + fixture.elicitationResponse.resolve({ action: 'accept', content: null }); + await fixture.bridge.pending(fixture.initial); + assert.deepEqual(fixture.answers[0]?.answer, { kind: 'form', action: 'accept', values: {} }); + }); + + test('question decline submits unanswered while cancel stops the Turn', async () => { + const declined = interactionFixture(question()); + declined.elicitationResponse.resolve({ action: 'decline' }); + await declined.bridge.pending(declined.initial); + assert.deepEqual(declined.answers[0]?.answer, { kind: 'question', answers: [null] }); + assert.deepEqual(declined.cancelled, []); + + const cancelled = interactionFixture(question()); + cancelled.elicitationResponse.resolve({ action: 'cancel' }); + await cancelled.bridge.pending(cancelled.initial); + assert.deepEqual(cancelled.answers, []); + assert.deepEqual(cancelled.cancelled, [cancelled.initial]); + }); + + test('rejects invalid typed forms, unknown fields and oversized Unicode answers before Host mutation', async () => { + const good = { + email: 'user@example.com', + count: 2, + ratio: 0.25, + enabled: false, + color: 'r', + tags: ['x'], + }; + for (const content of [ + { ...good, count: '2' }, + { ...good, count: 2.5 }, + { ...good, count: 1 }, + { ...good, enabled: 'false' }, + { ...good, color: 'green' }, + { ...good, tags: ['x', 'x'] }, + { ...good, email: 'invalid-email' }, + { ...good, unexpected: true }, + {}, + ]) { + const fixture = interactionFixture(fullForm()); + fixture.elicitationResponse.resolve({ action: 'accept', content }); + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.answers.length, 0); + assert.equal(fixture.failures.length, 1); + } + for (const content of [{ wrong: 'answer' }, { q0: false }, { q0: '界'.repeat(700) }]) { + const fixture = interactionFixture(question()); + fixture.elicitationResponse.resolve({ action: 'accept', content }); + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.answers.length, 0); + assert.equal(errorCode(fixture.failures[0]), 'invalid_interaction_answer'); + } + }); + + test('permission options bind opaque identifiers to the exact Session capability target', async () => { + const pending = capability(); + const fixture = interactionFixture(pending); + const task = fixture.bridge.pending(pending); + const request = await fixture.permissionRequested.promise; + assert.equal(request.sessionId, pending.sessionId); + assert.equal(request.toolCall.toolCallId, 'tool-1'); + assert.deepEqual( + request.options.map((option) => option.kind), + ['allow_always', 'reject_once'], + ); + assert.match(request.options[0].name, /this Session/); + assert.notEqual(request.options[0].optionId, 'allow'); + assert.notEqual(request.options[0].optionId, request.options[1].optionId); + const content = request.toolCall.content?.[0]; + assert.equal(content?.type, 'content'); + assert.ok(content?.type === 'content' && content.content.type === 'text'); + assert.match(content.content.text, /provider-1/); + assert.match(content.content.text, /contract-1/); + assert.match(content.content.text, /mcp_tool/); + fixture.permissionResponse.resolve({ + outcome: { outcome: 'selected', optionId: request.options[0].optionId }, + }); + await task; + assert.deepEqual(fixture.answers, [ + { + sessionId: 'session-1', + interactionId: 'interaction-1', + answer: { kind: 'client_capability', decision: 'allow' }, + }, + ]); + }); + + test('sandbox permissions display exact expansion and preserve Host conflict outcomes', async () => { + const pending = snapshot({ + kind: 'sandbox_boundary', + justification: 'Read shared fixtures', + expansion: { + filesystem: { + entries: [{ path: '/workspace/fixtures', access: 'read', scope: 'subtree' }], + }, + network: { enabled: true }, + }, + }); + const fixture = interactionFixture(pending); + fixture.hostAnswer = async (input) => ({ + ...pending, + revision: 2, + status: 'answered', + outcome: { + kind: 'sandbox_boundary_decision', + decision: 'allow', + status: 'conflict', + committedAt: 10, + }, + }); + const task = fixture.bridge.pending(pending); + const request = await fixture.permissionRequested.promise; + assert.equal(request.toolCall.toolCallId, pending.interactionId); + const serialized = JSON.stringify(request.toolCall.content); + assert.match(serialized, /Read shared fixtures/); + assert.match(serialized, /subtree/); + assert.match(serialized, /network/); + assert.equal(request.options[0].kind, 'allow_always'); + fixture.permissionResponse.resolve({ + outcome: { outcome: 'selected', optionId: request.options[0].optionId }, + }); + await task; + assert.equal(fixture.resolutions[0]?.outcome.kind, 'sandbox_boundary_decision'); + assert.equal( + fixture.resolutions[0]?.outcome.kind === 'sandbox_boundary_decision' && + fixture.resolutions[0].outcome.status, + 'conflict', + ); + }); + + test('permission cancellation stops the exact Turn and never becomes a denial', async () => { + const fixture = interactionFixture(capability()); + fixture.permissionResponse.resolve({ outcome: { outcome: 'cancelled' } }); + await fixture.bridge.pending(fixture.initial); + assert.deepEqual(fixture.cancelled, [fixture.initial]); + assert.deepEqual(fixture.answers, []); + assert.deepEqual(fixture.failures, []); + }); + + test('legacy permission choices preserve one-shot and Turn-scoped decisions', async () => { + const expectations = [ + { + kind: 'allow_once', + answer: { kind: 'permission', decision: 'allow', rememberForTurn: false }, + }, + { + kind: 'allow_always', + answer: { kind: 'permission', decision: 'allow', rememberForTurn: true }, + }, + { + kind: 'reject_once', + answer: { kind: 'permission', decision: 'deny', rememberForTurn: false }, + }, + ] as const; + for (const expected of expectations) { + const fixture = interactionFixture(legacyPermission()); + const task = fixture.bridge.pending(fixture.initial); + const request = await fixture.permissionRequested.promise; + assert.equal(request.toolCall.toolCallId, 'tool-1'); + assert.deepEqual( + request.options.map((option) => option.kind), + ['allow_once', 'allow_always', 'reject_once'], + ); + assert.match(JSON.stringify(request.toolCall.content), /workspace\/file/); + const selected = request.options.find((option) => option.kind === expected.kind); + assert.ok(selected); + fixture.permissionResponse.resolve({ + outcome: { outcome: 'selected', optionId: selected.optionId }, + }); + await task; + assert.deepEqual(fixture.answers[0]?.answer, expected.answer); + assert.deepEqual(fixture.failures, []); + } + + const oneShot = interactionFixture(legacyPermission(false)); + const task = oneShot.bridge.pending(oneShot.initial); + const request = await oneShot.permissionRequested.promise; + assert.deepEqual( + request.options.map((option) => option.kind), + ['allow_once', 'reject_once'], + ); + oneShot.permissionResponse.resolve({ + outcome: { outcome: 'selected', optionId: request.options[0].optionId }, + }); + await task; + assert.deepEqual(oneShot.answers[0]?.answer, { + kind: 'permission', + decision: 'allow', + rememberForTurn: false, + }); + }); + + test('unadvertised elicitation fails without synthetic answers', async () => { + for (const capabilities of [ + {}, + { elicitation: {} }, + { elicitation: { form: null } }, + { elicitation: { url: {} } }, + ] as ClientCapabilities[]) { + const fixture = interactionFixture(question(), { capabilities }); + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.elicitationCalls, 0); + assert.deepEqual(fixture.answers, []); + assert.equal(errorCode(fixture.failures[0]), 'unsupported_interaction'); + } + }); + + test('an old permission already closed by Host recovery is observed without a dialog', async () => { + const fixture = interactionFixture(legacyPermission()); + fixture.current = closed(fixture.initial, 'host_restarted'); + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.permissionCalls, 0); + assert.equal(fixture.resolutions[0]?.outcome.kind, 'closure'); + assert.equal( + fixture.resolutions[0]?.outcome.kind === 'closure' && fixture.resolutions[0].outcome.reason, + 'host_restarted', + ); + assert.deepEqual(fixture.answers, []); + assert.deepEqual(fixture.failures, []); + }); + + test('deduplicates pending replay and publishes one authoritative external answer', async () => { + const fixture = interactionFixture(question()); + const first = fixture.bridge.pending(fixture.initial); + assert.equal(fixture.bridge.pending(fixture.initial), first); + await fixture.elicited.promise; + fixture.current = answered(fixture.initial, { kind: 'question', answers: ['External answer'] }); + await fixture.bridge.resolved(fixture.initial); + await first; + assert.equal(fixture.clientSignal?.aborted, true); + fixture.elicitationResponse.resolve({ action: 'accept', content: { q0: 'Late answer' } }); + await fixture.bridge.pending(fixture.initial); + await fixture.bridge.resolved(fixture.initial); + assert.equal(fixture.elicitationCalls, 1); + assert.deepEqual(fixture.answers, []); + assert.equal(fixture.answered.length, 1); + assert.equal(fixture.resolutions.length, 1); + }); + + test('late conflicting Host answers re-query canonical closure without failing the Turn', async () => { + const fixture = interactionFixture(question()); + fixture.hostAnswer = async () => { + fixture.current = closed(fixture.initial, 'producer_cancelled'); + throw new RuntimeHostOperationError( + 'interaction.answer', + 'already_resolved', + 'Already closed', + ); + }; + fixture.elicitationResponse.resolve({ action: 'accept', content: { q0: 'Local reply' } }); + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.answers.length, 1); + assert.equal( + fixture.resolutions[0]?.outcome.kind === 'closure' && fixture.resolutions[0].outcome.reason, + 'producer_cancelled', + ); + assert.deepEqual(fixture.failures, []); + }); + + test('close and Turn cancellation release a client that ignores cancellation and late replies', async () => { + for (const stop of ['close', 'cancel'] as const) { + const fixture = interactionFixture(question()); + const task = fixture.bridge.pending(fixture.initial); + await fixture.elicited.promise; + if (stop === 'close') fixture.bridge.close(); + else fixture.bridge.cancelTurn(fixture.initial.turnId); + await task; + assert.equal(fixture.clientSignal?.aborted, true); + fixture.elicitationResponse.reject(new Error('Late client failure')); + await fixture.bridge.pending(fixture.initial); + assert.deepEqual(fixture.answers, []); + assert.deepEqual(fixture.failures, []); + assert.equal(fixture.elicitationCalls, 1); + } + }); + + test('closing while card delivery is blocked releases local work and sends no client request', async () => { + const entered = deferred(); + const delivery = deferred(); + const fixture = interactionFixture(question(), { + onPending: () => { + entered.resolve(); + return delivery.promise; + }, + }); + const task = fixture.bridge.pending(fixture.initial); + await entered.promise; + fixture.bridge.close(); + await task; + delivery.reject(new Error('Late transport failure')); + assert.equal(fixture.elicitationCalls, 0); + assert.deepEqual(fixture.failures, []); + }); + + test('Turn cancellation releases reconciliation and notification delivery that have stalled', async () => { + const querying = deferred(); + const query = deferred(); + const fixture = interactionFixture(question()); + fixture.hostAnswer = async () => { + fixture.hostQuery = () => { + querying.resolve(); + return query.promise; + }; + throw new RuntimeHostOperationError('interaction.answer', 'already_resolved', 'Resolved'); + }; + fixture.elicitationResponse.resolve({ action: 'accept', content: { q0: 'Reply' } }); + const task = fixture.bridge.pending(fixture.initial); + await querying.promise; + fixture.bridge.cancelTurn(fixture.initial.turnId); + await task; + query.reject(new Error('Late Host failure')); + assert.deepEqual(fixture.failures, []); + + const entered = deferred(); + const delivery = deferred(); + const notifying = interactionFixture(question(), { + onResolved: () => { + entered.resolve(); + return delivery.promise; + }, + }); + notifying.elicitationResponse.resolve({ action: 'accept', content: { q0: 'Reply' } }); + const notificationTask = notifying.bridge.pending(notifying.initial); + await entered.promise; + notifying.bridge.cancelTurn(notifying.initial.turnId); + await notificationTask; + delivery.reject(new Error('Late notification failure')); + assert.deepEqual(notifying.failures, []); + }); + + test('unknown permission choices and unrecognized elicitation actions fail instead of granting', async () => { + const permission = interactionFixture(capability()); + permission.permissionResponse.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }); + await permission.bridge.pending(permission.initial); + assert.deepEqual(permission.answers, []); + assert.equal(errorCode(permission.failures[0]), 'invalid_interaction_answer'); + const form = interactionFixture(question()); + form.elicitationResponse.resolve({ action: '_unknown' }); + await form.bridge.pending(form.initial); + assert.deepEqual(form.answers, []); + assert.equal(errorCode(form.failures[0]), 'invalid_interaction_answer'); + }); + + test('Host identities and failures cannot silently rebind a pending request', async () => { + const fixture = interactionFixture(question()); + fixture.current = { ...fixture.initial, runId: 'another-run' }; + await fixture.bridge.pending(fixture.initial); + assert.equal(fixture.elicitationCalls, 0); + assert.deepEqual(fixture.answers, []); + assert.equal(errorCode(fixture.failures[0]), 'invalid_interaction'); + const failing = interactionFixture(question()); + failing.hostAnswer = async () => { + throw new RuntimeHostOperationError('interaction.answer', 'operation_conflict', 'Conflict'); + }; + failing.elicitationResponse.resolve({ action: 'accept', content: { q0: 'Reply' } }); + await failing.bridge.pending(failing.initial); + assert.deepEqual(failing.failures[0]?.data, { + source: 'runtime_host', + operation: 'interaction.answer', + code: 'operation_conflict', + }); + }); +}); + +function interactionFixture( + initial: InteractionPendingSnapshot, + options: { + capabilities?: ClientCapabilities; + client?: AcpInteractionClient; + onPending?: AcpSessionInteractionsOptions['onPending']; + onResolved?: AcpSessionInteractionsOptions['onResolved']; + } = {}, +) { + const fixture = { + initial, + current: initial as InteractionSnapshot, + answers: [] as InteractionAnswerInput[], + answered: [] as InteractionAnsweredSnapshot[], + resolutions: [] as InteractionResolvedSnapshot[], + failures: [] as RequestError[], + cancelled: [] as InteractionPendingSnapshot[], + elicited: deferred(), + permissionRequested: deferred(), + elicitationResponse: deferred(), + permissionResponse: deferred(), + clientSignal: undefined as AbortSignal | undefined, + elicitationCalls: 0, + permissionCalls: 0, + hostQuery: undefined as (() => Promise) | undefined, + hostAnswer: undefined as + | ((input: InteractionAnswerInput) => Promise) + | undefined, + bridge: undefined! as AcpSessionInteractions, + }; + const connection = { + request: async (operation: string, input: InteractionAnswerInput) => { + if (operation === 'interaction.query') return fixture.hostQuery?.() ?? fixture.current; + assert.equal(operation, 'interaction.answer'); + fixture.answers.push(input); + const result = fixture.hostAnswer + ? await fixture.hostAnswer(input) + : answered(initial, input.answer); + fixture.current = result; + return result; + }, + } as Pick; + fixture.bridge = new AcpSessionInteractions({ + sessionId: initial.sessionId, + connection, + client: options.client ?? { + capabilities: options.capabilities ?? { elicitation: { form: {} } }, + createElicitation: (request, signal) => { + fixture.elicitationCalls += 1; + fixture.clientSignal = signal; + fixture.elicited.resolve(request); + return fixture.elicitationResponse.promise; + }, + requestPermission: (request, signal) => { + fixture.permissionCalls += 1; + fixture.clientSignal = signal; + fixture.permissionRequested.resolve(request); + return fixture.permissionResponse.promise; + }, + }, + onPending: options.onPending ?? (async () => undefined), + onAnswered: (result) => fixture.answered.push(result), + onResolved: (result) => { + fixture.resolutions.push(result); + return options.onResolved?.(result, initial); + }, + onFailure: (_, error) => fixture.failures.push(error), + onCancelled: (pending) => fixture.cancelled.push(pending), + }); + return fixture; +} + +function snapshot(request: InteractionRequest): InteractionPendingSnapshot { + return { + schemaVersion: 1, + interactionId: 'interaction-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1, + status: 'pending', + outcome: null, + request, + }; +} + +function question(): InteractionPendingSnapshot { + return snapshot({ + kind: 'question', + toolUseId: 'tool-1', + questions: [{ question: 'Choose?', options: [{ label: 'A' }, { label: 'B' }] }], + }); +} + +function fullForm(): InteractionPendingSnapshot { + return snapshot({ + kind: 'form', + toolUseId: 'tool-1', + requester: { name: 'Example', source: 'MCP server' }, + message: 'Fill the form', + fields: [ + { + kind: 'string', + name: 'email', + label: 'Email', + description: 'Contact address', + required: true, + minLength: 3, + maxLength: 100, + format: 'email', + }, + { + kind: 'integer', + name: 'count', + label: 'Count', + required: true, + minimum: 1.2, + maximum: 4.9, + }, + { kind: 'number', name: 'ratio', label: 'Ratio', required: true, minimum: 0.1, maximum: 1 }, + { kind: 'boolean', name: 'enabled', label: 'Enabled', required: true, default: false }, + { + kind: 'single_select', + name: 'color', + label: 'Color', + required: true, + options: [ + { value: 'r', label: 'Red' }, + { value: 'b', label: 'Blue' }, + ], + }, + { + kind: 'multi_select', + name: 'tags', + label: 'Tags', + required: true, + options: [ + { value: 'x', label: 'X' }, + { value: 'y', label: 'Y' }, + ], + minItems: 1, + maxItems: 2, + }, + ], + }); +} + +function capability(): InteractionPendingSnapshot { + return snapshot({ + kind: 'client_capability', + toolUseId: 'tool-1', + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'server-1', + toolName: 'read', + capability: 'mcp', + scope: { kind: 'mcp_tool', serverId: 'server-1', toolName: 'read' }, + }, + }); +} + +function legacyPermission(rememberForTurnAllowed = true): InteractionPendingSnapshot { + return snapshot({ + kind: 'permission', + toolUseId: 'tool-1', + prompt: { + kind: 'tool_permission', + toolName: 'Read', + category: 'read', + reason: 'custom', + review: { kind: 'path', operation: 'read', path: '/workspace/file' }, + rememberForTurnAllowed, + }, + }); +} + +function answered( + pending: InteractionPendingSnapshot, + answer: InteractionAnswerInput['answer'], +): InteractionAnsweredSnapshot { + const base = { ...pending, revision: 2 as const, status: 'answered' as const }; + const committedAt = 10; + switch (answer.kind) { + case 'question': + return { + ...base, + outcome: { kind: 'question_answer', answers: answer.answers, committedAt }, + }; + case 'form': + return { + ...base, + outcome: + answer.action === 'accept' + ? { kind: 'form_answer', action: 'accept', values: answer.values, committedAt } + : { kind: 'form_answer', action: answer.action, committedAt }, + }; + case 'client_capability': + return { + ...base, + outcome: { kind: 'client_capability_decision', decision: answer.decision, committedAt }, + }; + case 'sandbox_boundary': + return { + ...base, + outcome: { + kind: 'sandbox_boundary_decision', + decision: answer.decision, + status: answer.decision === 'allow' ? 'approved' : 'denied', + committedAt, + }, + }; + case 'permission': + return { + ...base, + outcome: + answer.decision === 'deny' + ? { + kind: 'permission_answer', + reviewer: 'user', + decision: 'deny', + rememberForTurn: false, + committedAt, + } + : { + kind: 'permission_answer', + reviewer: 'user', + decision: 'allow', + rememberForTurn: answer.rememberForTurn, + committedAt, + }, + }; + } +} + +function closed( + pending: InteractionPendingSnapshot, + reason: InteractionClosedSnapshot['outcome']['reason'], +): InteractionClosedSnapshot { + return { + ...pending, + revision: 2, + status: 'closed', + outcome: { kind: 'closure', reason, committedAt: 10 }, + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function errorCode(error: RequestError | undefined): unknown { + const data = error?.data; + return typeof data === 'object' && data !== null && 'code' in data ? data.code : undefined; +} diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts new file mode 100644 index 0000000000..0c193e5fff --- /dev/null +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -0,0 +1,605 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { RequestError, type NewSessionRequest } from '@agentclientprotocol/sdk'; +import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; +import { deferred, waitFor, withTimeout } from '@maka/core/test-only/async-primitives'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + type ClientCapabilityProvider, + type ClientCapabilityRegistrationOptions, + type RuntimeHostConnectionAvailability, +} from '@maka/runtime-host/client'; +import { AcpSessionMcp, createAcpMcpConfig, type AcpMcpConnection } from '../acp/session-mcp.js'; +import { AcpSessionRegistry, type AcpSessionRegistryConnection } from '../acp/session-registry.js'; + +const fixturePath = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); +const sessionId = 'session-mcp-fixture'; + +test('ACP stdio configuration copies cwd, arguments and explicit environment into the existing MCP model', () => { + const server = stdioServer('/workspace', 'fixture'); + server.env.push({ name: 'EXPLICIT_SETTING', value: 'enabled' }); + const config = createAcpMcpConfig({ + cwd: '/workspace', + mcpServers: [server], + _meta: { ignored: true }, + }); + assert.deepEqual(config, { + version: MCP_CONFIG_VERSION, + mcpServers: { + fixture: { + enabled: true, + protocol: 'auto', + command: process.execPath, + args: [fixturePath], + cwd: '/workspace', + env: { MAKA_MCP_STDIO_EVENT_LOG: '/workspace/fixture.jsonl', EXPLICIT_SETTING: 'enabled' }, + }, + }, + }); + server.args.push('--crash'); + server.env[0]!.value = 'changed'; + const normalized = config.mcpServers.fixture; + assert.ok(normalized && 'command' in normalized); + assert.deepEqual(normalized.args, [fixturePath]); + assert.equal(normalized.env?.MAKA_MCP_STDIO_EVENT_LOG, '/workspace/fixture.jsonl'); +}); + +test('invalid ACP stdio configurations fail as invalid params before resource preparation', () => { + const valid = stdioServer('/workspace', 'fixture'); + const badServers: unknown[] = [ + null, + 'not-an-array', + ['not-a-server'], + [null], + [valid, { ...valid }], + [{ ...valid, name: '' }], + [{ ...valid, name: '__proto__' }], + [{ ...valid, name: 'constructor' }], + [{ ...valid, command: 'node' }], + [{ ...valid, command: '/node\0bad' }], + [{ ...valid, type: 'stdio' }], + [{ name: 'remote', type: 'http', url: 'https://example.com/mcp', headers: [] }], + [{ name: 'remote', type: 'sse', url: 'https://example.com/mcp', headers: [] }], + [{ ...valid, args: undefined }], + [{ ...valid, args: 'one argument' }], + [{ ...valid, args: [42] }], + [{ ...valid, args: ['bad\0argument'] }], + [{ ...valid, env: undefined }], + [{ ...valid, env: {} }], + [{ ...valid, env: [null] }], + [ + { + ...valid, + env: [ + { name: 'KEY', value: 'one' }, + { name: 'KEY', value: 'two' }, + ], + }, + ], + [{ ...valid, env: [{ name: '', value: 'value' }] }], + [{ ...valid, env: [{ name: 'BAD=KEY', value: 'value' }] }], + [{ ...valid, env: [{ name: 'BAD\0KEY', value: 'value' }] }], + [{ ...valid, env: [{ name: 'KEY', value: 42 }] }], + [{ ...valid, env: [{ name: 'KEY', value: 'bad\0value' }] }], + ]; + for (const mcpServers of badServers) { + assert.throws( + () => createAcpMcpConfig({ cwd: '/workspace', mcpServers } as NewSessionRequest), + (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.equal(error.code, -32602); + assert.equal(errorData(error).field, 'mcpServers'); + return true; + }, + JSON.stringify(mcpServers), + ); + } +}); + +test('Session preparation waits for scoped publication and reconnect reuses its MCP processes', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const accepted = deferred(); + host.replace = () => accepted.promise; + const server = stdioServer(root, 'fixture', '--environment'); + server.env.push( + { name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'session-setting' }, + { name: 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL', value: 'must-not-reach-mcp' }, + ); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [server] }), + host.connection, + ); + t.after(async () => { + accepted.resolve(); + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + let prepared = false; + const preparing = mcp.prepare().then(() => { + prepared = true; + }); + await waitFor(() => host.replacements.length === 1, { timeoutMs: 5_000, pollMs: 10 }); + assert.equal(prepared, false); + assert.deepEqual(host.replacements[0]?.options, { sessionId }); + const provider = host.replacements[0]!.provider; + assert.ok(provider.offers().length > 0); + for (const offer of provider.offers()) { + assert.equal(offer.admission, 'mcp'); + assert.equal(offer.affinity, 'session'); + assert.equal(offer.hostPathAccess, 'none'); + } + accepted.resolve(); + await preparing; + await mcp.ready(); + assert.equal(host.replacements.length, 1); + assert.deepEqual(await invokeEnvironment(provider), { + MAKA_MCP_STDIO_FIXTURE_VALUE: '[redacted]', + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); + const starts = (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start'); + assert.ok(starts.length > 0); + assert.ok(starts.every((event) => event.cwd === root && event.fixtureEnv === 'session-setting')); + host.emit({ kind: 'unavailable' }); + await assert.rejects(mcp.ready(), isMcpError('mcp_publication_failed')); + host.emit({ kind: 'connected', hostEpoch: 'host-1', connectionId: 'connection-2' }); + await mcp.ready(); + assert.equal(host.replacements.length, 2); + assert.deepEqual( + host.replacements.map((replacement) => replacement.options), + [{ sessionId }, { sessionId }], + ); + assert.deepEqual( + (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start'), + starts, + ); + await mcp.close(); + await mcp.close(); + assert.deepEqual(host.unregisters, [{ sessionId }]); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'fixture'); +}); + +test('authoritative retirement of the current Session registration closes its MCP process', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + host.connection, + ); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const provider = host.replacements[0]!.provider; + assert.ok(provider.currentRegistrationRetired); + await provider.currentRegistrationRetired(); + + await assertFixtureExited(root, 'fixture'); + await assert.rejects(mcp.ready(), isMcpError('mcp_not_ready')); + await mcp.close(); + assert.deepEqual(host.unregisters, []); + assert.equal(host.listenerCount(), 0); +}); + +test('one failed MCP discovery closes every prepared server without publishing a partial group', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const config = createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'healthy'), stdioServer(root, 'broken', '--crash')], + }); + const mcp = new AcpSessionMcp(sessionId, config, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await assert.rejects(mcp.prepare(), isMcpError('mcp_not_ready')); + assert.deepEqual(host.replacements, []); + assert.deepEqual(host.unregisters, []); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'healthy'); + await assertFixtureExited(root, 'broken'); +}); + +test('abort during MCP startup closes the child before preparation completes', { + timeout: 15_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const controller = new AbortController(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'slow', '--slow-start')] }), + host.connection, + ); + t.after(async () => { + controller.abort(); + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + const rejected = assert.rejects( + mcp.prepare(controller.signal), + isMcpError('mcp_preparation_failed'), + ); + await waitForFixtureStart(root, 'slow'); + controller.abort(); + await withTimeout(rejected, 5_000, 'aborted preparation did not release startup'); + assert.equal(host.listenerCount(), 0); + assert.deepEqual(host.replacements, []); + assert.deepEqual(host.unregisters, []); + await assertFixtureExited(root, 'slow'); +}); + +test('failed Host publication fails preparation and releases discovered MCP processes', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + host.replace = async () => { + throw new Error('registration rejected'); + }; + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + host.connection, + ); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await assert.rejects(mcp.prepare(), isMcpError('mcp_publication_failed')); + assert.equal(host.replacements.length, 1); + assert.deepEqual(host.unregisters, []); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'fixture'); +}); + +test('ready aborts locally while a reconnect publication is pending and close awaits withdrawal', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const accepted = deferred(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + host.connection, + ); + t.after(async () => { + accepted.resolve(); + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + host.replace = () => accepted.promise; + host.emit({ kind: 'connected', hostEpoch: 'host-2', connectionId: 'connection-2' }); + const controller = new AbortController(); + const rejection = assert.rejects(mcp.ready(controller.signal), { name: 'AbortError' }); + controller.abort(); + await withTimeout(rejection, 1_000, 'ready waited for Host delivery after abort'); + let closed = false; + const closing = mcp.close().then(() => { + closed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closed, false); + accepted.resolve(); + await closing; + assert.deepEqual(host.unregisters, [{ sessionId }]); + await assertFixtureExited(root, 'fixture'); +}); + +test('a dispatched create with unknown outcome retains its published MCP scope until the returned Session is closed', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const operations: string[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + registryConnection(host, async (operation) => { + operations.push(operation); + throw new RuntimeHostRequestInterruptedError( + 'session.create', + 'command', + 'dispatched', + 'connection_lost', + ); + }), + }); + t.after(async () => { + await registry.dispose(); + await rm(root, { recursive: true, force: true }); + }); + await assert.rejects( + registry.create({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.equal(errorData(error).sessionId, sessionId); + assert.equal(errorData(error).dispatch, 'dispatched'); + return true; + }, + ); + assert.deepEqual(operations, ['session.create']); + assert.equal(host.replacements.length, 1); + assert.deepEqual(host.unregisters, []); + assert.equal(host.listenerCount(), 1); + assert.ok( + (await fixtureEvents(root, 'fixture')).some( + (event) => event.event === 'start' && processExists(event.pid), + ), + ); + await registry.close({ sessionId }); + assert.deepEqual(host.unregisters, [{ sessionId }]); + assert.deepEqual(operations, ['session.create']); + await assertFixtureExited(root, 'fixture'); +}); + +test('a known Host create failure withdraws the already prepared scope and leaves no Session ownership', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + registryConnection(host, async () => { + throw new RuntimeHostOperationError('session.create', 'operation_conflict', 'rejected'); + }), + }); + t.after(async () => { + await registry.dispose(); + await rm(root, { recursive: true, force: true }); + }); + await assert.rejects( + registry.create({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + RequestError, + ); + assert.equal(host.replacements.length, 1); + assert.deepEqual(host.unregisters, [{ sessionId }]); + await assert.rejects( + registry.close({ sessionId }), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'fixture'); +}); + +test('ACP EOF during MCP startup aborts preparation before any Session create dispatch', { + timeout: 15_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const operations: string[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + registryConnection(host, async (operation) => { + operations.push(operation); + return {}; + }), + }); + t.after(async () => { + await registry.dispose(); + await rm(root, { recursive: true, force: true }); + }); + const rejected = assert.rejects( + registry.create({ cwd: root, mcpServers: [stdioServer(root, 'slow', '--slow-start')] }), + RequestError, + ); + await waitForFixtureStart(root, 'slow'); + await withTimeout( + Promise.all([registry.dispose(), rejected]), + 5_000, + 'EOF did not cancel MCP preparation', + ); + assert.deepEqual(operations, []); + assert.deepEqual(host.replacements, []); + assert.deepEqual(host.unregisters, []); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'slow'); +}); + +function stdioServer(root: string, name: string, ...flags: string[]) { + return { + name, + command: process.execPath, + args: [fixturePath, ...flags], + env: [{ name: 'MAKA_MCP_STDIO_EVENT_LOG', value: join(root, `${name}.jsonl`) }], + }; +} + +function fakeHost() { + const listeners = new Set<(availability: RuntimeHostConnectionAvailability) => void>(); + const host = { + replacements: [] as { + provider: ClientCapabilityProvider; + options: number | ClientCapabilityRegistrationOptions | undefined; + }[], + unregisters: [] as (number | ClientCapabilityRegistrationOptions | undefined)[], + replace: async (): Promise => undefined, + listenerCount: () => listeners.size, + emit: (availability: RuntimeHostConnectionAvailability) => { + for (const listener of listeners) listener(availability); + }, + connection: undefined as unknown as AcpMcpConnection, + }; + host.connection = { + replaceClientCapabilities: async (provider, options) => { + host.replacements.push({ provider, options }); + await host.replace(); + return { registrationId: 'registration', revision: host.replacements.length }; + }, + unregisterClientCapabilities: async (options) => { + host.unregisters.push(options); + return { registrationId: 'registration', revision: host.replacements.length + 1 }; + }, + subscribeConnectionAvailability: (listener) => { + listeners.add(listener); + listener({ kind: 'connected', hostEpoch: 'host-1', connectionId: 'connection-1' }); + return () => { + listeners.delete(listener); + }; + }, + }; + return host; +} + +function registryConnection( + host: ReturnType, + request: (operation: string) => Promise, +): AcpSessionRegistryConnection { + return { + ...host.connection, + reconnecting: true, + request: request as AcpSessionRegistryConnection['request'], + close: async () => undefined, + openSessionSubscription: async () => { + throw new Error('unexpected subscription'); + }, + openSessionSubscriptionOnce: async () => { + throw new Error('unexpected subscription'); + }, + }; +} + +async function invokeEnvironment(provider: ClientCapabilityProvider): Promise { + const offer = provider + .offers() + .find((candidate) => candidate.tools.some((tool) => tool.name === 'environment')); + const tool = offer?.tools.find((candidate) => candidate.name === 'environment'); + assert.ok(offer && tool && provider.call); + const result = await provider.call( + { + kind: 'client.capability.call', + invocationId: 'invocation', + registrationId: 'registration', + offerId: offer.offerId, + serverId: tool.serverId, + toolName: tool.name, + arguments: { names: ['MAKA_MCP_STDIO_FIXTURE_VALUE', 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'] }, + sessionId, + turnId: 'turn', + toolCallId: 'tool-call', + }, + { + signal: new AbortController().signal, + accept: async (evidence) => { + assert.deepEqual(evidence, { kind: 'none' }); + }, + requestInteraction: async () => { + throw new Error('unexpected interaction'); + }, + }, + ); + const content = result.content[0]; + assert.ok(content?.type === 'text'); + return JSON.parse(content.text); +} + +function isMcpError(code: string): (error: unknown) => boolean { + return (error) => { + assert.ok(error instanceof RequestError); + assert.equal(errorData(error).code, code); + assert.equal(errorData(error).sessionId, sessionId); + return true; + }; +} + +function errorData(error: RequestError): Record { + assert.ok(error.data && typeof error.data === 'object'); + return error.data as Record; +} + +async function temporaryRoot(): Promise { + return realpath(await mkdtemp(join(tmpdir(), 'maka-acp-mcp-'))); +} + +interface FixtureEvent { + event: string; + pid: number; + cwd?: string; + fixtureEnv?: string; +} + +async function fixtureEvents(root: string, name: string): Promise { + try { + const content = await readFile(join(root, `${name}.jsonl`), 'utf8'); + return content + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as FixtureEvent); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +async function waitForFixtureStart(root: string, name: string): Promise { + await waitFor( + async () => (await fixtureEvents(root, name)).some((event) => event.event === 'start'), + { + timeoutMs: 5_000, + pollMs: 10, + message: `${name} fixture did not start`, + }, + ); +} + +async function assertFixtureExited(root: string, name: string): Promise { + await waitFor( + async () => { + const events = await fixtureEvents(root, name); + const starts = events.filter((event) => event.event === 'start'); + // The transport is allowed to escalate from SIGTERM to SIGKILL on a + // loaded runner. A SIGKILLed fixture cannot append its own `exit` event, + // so process liveness—not cooperative fixture logging—is the leak check. + return starts.length > 0 && starts.every((start) => !processExists(start.pid)); + }, + { timeoutMs: 5_000, pollMs: 10, message: `${name} MCP fixture leaked a child process` }, + ); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 3d1c5e8ea6..54b7ecc9e0 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -37,13 +37,18 @@ import { RuntimeHostRequestInterruptedError, RuntimeHostSubscriptionError, type RuntimeHostSessionSubscription, + type DecodedSessionTranscriptPage, } from '@maka/runtime-host/client'; import { SESSION_CATALOG_CWD_MAX_BYTES, SESSION_CONTINUITY_SCHEMA_VERSION, + type InteractionPendingSnapshot, + type InteractionSnapshot, type SessionCatalogProjection, type SessionContinuitySnapshot, type SubscriptionFrame, + type SessionTranscriptPage, + type SessionTranscriptPageInput, } from '@maka/runtime-host/protocol'; import { AcpSessionRegistry, type AcpSessionRegistryConnection } from '../acp/session-registry.js'; @@ -452,6 +457,191 @@ describe('ACP Session registry', () => { assert.equal(subscription.closeCalls, 1); }); + for (const scenario of [ + 'complete', + 'notification-failure', + 'cancel', + 'cancel-stalled-notification', + 'host-failure', + 'host-abort', + ] as const) { + test(`tool reconciliation through the real Session channel handles ${scenario}`, async () => { + const sessionId = `session-tool-${scenario}`; + const turn = runningTurn(sessionId, 'turn-tool'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const pageGate = deferred(); + const deliveryGate = deferred(); + subscription.transcriptPageGate = pageGate.promise; + let subscriptionOpens = 0; + let stopped = 0; + let terminalDeliveryStarted = false; + let failedToolDelivered = false; + let settled = false; + const completesNormally = + scenario === 'complete' || + scenario === 'notification-failure' || + scenario === 'cancel-stalled-notification'; + const notifications: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + subscription.setRoot(turn); + if (completesNormally) + subscription.appendToolResult(turn.turnId, turn.runId, 'tool'); + else subscription.appendToolStart(turn.turnId, turn.runId, 'tool'); + subscription.publishTranscript([ + { + type: 'tool_result', + id: 'stored-result', + turnId: turn.turnId, + ts: 2, + toolUseId: 'tool', + isError: false, + content: { kind: 'text', text: 'authoritative result' }, + }, + ...(!completesNormally + ? [] + : [ + { + type: 'turn_state' as const, + id: 'stored-terminal', + turnId: turn.turnId, + ts: 3, + status: 'completed' as const, + }, + ]), + ]); + if (completesNormally) subscription.setRoot(completedTurn(sessionId, turn.turnId)); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stopped += 1; + subscription.setRoot({ + ...turn, + status: 'cancelled', + terminalEventId: 'cancelled', + abortSource: 'user', + }); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + subscriptionOpens += 1; + return subscription; + }, + openSessionSubscription: async () => { + throw new Error('Reconciliation must not open another subscription'); + }, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'use the tool' }] }, + { + signal: new AbortController().signal, + notify: async (notification) => { + if ( + (notification.update.sessionUpdate === 'tool_call' || + notification.update.sessionUpdate === 'tool_call_update') && + notification.update.status === 'failed' + ) { + failedToolDelivered = true; + } + if ( + (notification.update.sessionUpdate === 'tool_call' || + notification.update.sessionUpdate === 'tool_call_update') && + notification.update.rawOutput !== undefined + ) { + terminalDeliveryStarted = true; + if (scenario === 'notification-failure') + throw new Error('terminal notification rejected'); + await deliveryGate.promise; + } + notifications.push(notification); + }, + }, + ); + void prompt.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await waitFor(() => subscription.transcriptPageReads > 0); + assert.equal(settled, false); + if (scenario === 'cancel') { + await registry.cancel({ sessionId }); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(stopped, 1); + pageGate.resolve(); + deliveryGate.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(terminalDeliveryStarted, false); + } else if (scenario === 'cancel-stalled-notification') { + pageGate.resolve(); + await waitFor(() => terminalDeliveryStarted); + await registry.cancel({ sessionId }); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(stopped, 0); + deliveryGate.reject(new Error('Late transport failure')); + await new Promise((resolve) => setImmediate(resolve)); + } else if (scenario === 'host-failure' || scenario === 'host-abort') { + subscription.setRoot( + scenario === 'host-failure' + ? { + ...turn, + status: 'failed', + terminalEventId: 'failed', + failureClass: 'provider_failure', + } + : { + ...turn, + status: 'cancelled', + terminalEventId: 'aborted', + abortSource: 'host', + }, + ); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + assert.equal(stopped, 0); + pageGate.resolve(); + deliveryGate.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(terminalDeliveryStarted, false); + assert.equal(failedToolDelivered, true); + } else { + pageGate.resolve(); + await waitFor(() => terminalDeliveryStarted); + if (scenario === 'complete') { + assert.equal(settled, false); + deliveryGate.resolve(); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + assert.ok( + notifications.some( + ({ update }) => + (update.sessionUpdate === 'tool_call_update' || + update.sessionUpdate === 'tool_call') && + update.rawOutput !== undefined, + ), + ); + } else await assert.rejects(prompt); + } + assert.equal(subscriptionOpens, 1); + await registry.dispose(); + }); + } + test('latches cancellation while the real Session subscription is opening', async () => { const sessionId = 'session-cancel-before-attach'; const subscription = new FakeSubscription(continuitySnapshot(sessionId)); @@ -595,6 +785,254 @@ describe('ACP Session registry', () => { } }); + test('a second prompt settling cannot reopen a cancelled Turn interaction replay', async () => { + const sessionId = 'session-overlapping-interaction-cancel'; + const turnIds = ['turn-a', 'turn-b']; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const stopEntered = deferred(); + const stopRelease = deferred(); + const abortA = new AbortController(); + const starts: string[] = []; + let queries = 0; + let dialogs = 0; + let answers = 0; + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question-a', + sessionId, + turnId: 'turn-a', + runId: 'run-turn-a', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool-a', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const turnA = runningTurn(sessionId, 'turn-a'); + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + const turnId = (input as { turnId: string }).turnId; + starts.push(turnId); + if (turnId === 'turn-b') { + return { + kind: 'blocked', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + subscription.setRoot(turnA); + return { + kind: 'started', + turn: turnA, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stopEntered.resolve(); + await stopRelease.promise; + subscription.setRoot({ + ...turnA, + status: 'cancelled', + terminalEventId: 'cancelled-a', + abortSource: 'user', + }); + return {}; + } + if (operation === 'interaction.query') { + queries += 1; + return pending; + } + if (operation === 'interaction.answer') { + answers += 1; + const answer = (input as { answer: { answers: readonly string[] } }).answer; + return { + ...pending, + revision: 2, + status: 'answered', + outcome: { + kind: 'question_answer', + answers: answer.answers, + committedAt: 1, + }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => turnIds.shift()!, + }); + const interactions = { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs += 1; + return { action: 'accept' as const, content: { q0: 'Yes' } }; + }, + requestPermission: async () => assert.fail('Unexpected permission request'), + }; + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const cancelled = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'cancel A' }] }, + { ...promptContext([]), signal: abortA.signal, interactions }, + ); + try { + await waitFor(() => starts.includes('turn-a')); + abortA.abort(); + await stopEntered.promise; + await assert.rejects( + registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'block B' }] }, + { ...promptContext([]), interactions }, + ), + ); + + subscription.project({ interactions: { pending: [pending] } }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(queries, 0); + assert.equal(dialogs, 0); + assert.equal(answers, 0); + } finally { + stopRelease.resolve(); + assert.deepEqual(await cancelled, { stopReason: 'cancelled' }); + await registry.dispose(); + } + }); + + test('an externally resolved interaction cannot clear a cancelled Turn fence before settlement', async () => { + const sessionId = 'session-same-turn-interaction-cancel'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const stopEntered = deferred(); + const stopRelease = deferred(); + const firstDialog = deferred(); + const abort = new AbortController(); + const first: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question-first', + sessionId, + turnId: 'turn-cancelled', + runId: 'run-cancelled', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool-first', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const second: InteractionPendingSnapshot = { + ...first, + interactionId: 'permission-second', + request: { + kind: 'permission', + toolUseId: 'tool-second', + prompt: { + kind: 'tool_permission', + toolName: 'fixture', + category: 'read', + reason: 'custom', + review: { kind: 'path', operation: 'read', path: '/workspace/file' }, + rememberForTurnAllowed: false, + }, + }, + }; + let current: InteractionSnapshot = first; + let dialogs = 0; + let permissionDialogs = 0; + let answers = 0; + const turn = runningTurn(sessionId, first.turnId, first.runId); + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + subscription.setRoot(turn); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stopEntered.resolve(); + await stopRelease.promise; + subscription.setRoot({ + ...turn, + status: 'cancelled', + terminalEventId: 'cancelled', + abortSource: 'user', + }); + return {}; + } + if (operation === 'interaction.query') return current; + if (operation === 'interaction.answer') { + answers += 1; + return current; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + const interactions = { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs += 1; + return firstDialog.promise; + }, + requestPermission: async (request: { options: readonly { optionId: string }[] }) => { + permissionDialogs += 1; + return { + outcome: { outcome: 'selected' as const, optionId: request.options[0]!.optionId }, + }; + }, + }; + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'cancel during interaction' }] }, + { ...promptContext([]), signal: abort.signal, interactions }, + ); + try { + await waitFor(() => subscription.snapshot.rootTurn?.turnId === turn.turnId); + subscription.project({ interactions: { pending: [first] } }); + await waitFor(() => dialogs === 1); + abort.abort(); + await stopEntered.promise; + + current = { + ...first, + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['External'], committedAt: 1 }, + }; + subscription.project({ interactions: { pending: [] } }); + await new Promise((resolve) => setImmediate(resolve)); + current = second; + subscription.project({ interactions: { pending: [second] } }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(dialogs, 1); + assert.equal(permissionDialogs, 0); + assert.equal(answers, 0); + } finally { + stopRelease.resolve(); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + await registry.dispose(); + } + }); + for (const action of ['close', 'dispose'] as const) { test(`${action} during real Session channel open prevents Turn admission`, async () => { const sessionId = `session-open-${action}`; @@ -2791,6 +3229,10 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< #failure: Error | undefined; closeCalls = 0; nextCalls = 0; + transcriptPageReads = 0; + transcriptPageGate?: Promise; + #liveTranscript: StoredMessage[] = []; + readonly #decodedPages = new WeakMap(); constructor( public snapshot: SessionContinuitySnapshot, @@ -2861,6 +3303,56 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< }); } + appendToolResult(turnId: string, runId: string, toolUseId: string): void { + this.push({ + kind: 'subscription.session_event', + hostEpoch: this.hostEpoch, + subscriptionId: this.subscriptionId, + sequence: ++this.#sequence, + sessionId: this.snapshot.session.sessionId, + runId, + event: { + type: 'tool_result', + id: `tool-result-${toolUseId}`, + turnId, + ts: 2, + toolUseId, + status: 'completed', + }, + }); + } + + appendToolStart(turnId: string, runId: string, toolUseId: string): void { + this.push({ + kind: 'subscription.session_event', + hostEpoch: this.hostEpoch, + subscriptionId: this.subscriptionId, + sequence: ++this.#sequence, + sessionId: this.snapshot.session.sessionId, + runId, + event: { + type: 'tool_start', + id: `tool-start-${toolUseId}`, + turnId, + ts: 1, + toolUseId, + toolName: 'fixture', + }, + }); + } + + publishTranscript(messages: StoredMessage[]): void { + this.#liveTranscript = messages; + this.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: this.hostEpoch, + subscriptionId: this.subscriptionId, + sequence: ++this.#sequence, + sessionId: this.snapshot.session.sessionId, + throughSequence: messages.length * 8 + 7, + }); + } + project(overrides: Partial): void { this.snapshot = { ...this.snapshot, @@ -2889,12 +3381,38 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return []; } - async decodeTranscriptPage(): Promise { - throw new Error('Fake subscription does not expose transcript pages'); + async decodeTranscriptPage( + page: SessionTranscriptPage, + decodeMessage: (value: unknown) => T, + ): Promise> { + return { + messages: (this.#decodedPages.get(page) ?? []).map((message, index) => ({ + identity: index * 8, + message: decodeMessage(message), + })), + nextCursor: page.nextCursor, + }; } - async loadTranscriptPage(): Promise { - throw new Error('Fake subscription does not expose transcript pages'); + async loadTranscriptPage( + input: Omit, + ): Promise { + this.transcriptPageReads += 1; + await this.transcriptPageGate; + const page: SessionTranscriptPage = { + kind: 'page', + sessionId: this.snapshot.session.sessionId, + source: input.source, + direction: input.direction, + throughSequence: input.throughSequence, + rawBytes: 0, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: null, + }; + this.#decodedPages.set(page, this.#liveTranscript); + return page; } async close(): Promise { diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index 8129e0d8eb..5d8ccd4f83 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -24,6 +24,7 @@ import type { InteractionRequest } from '@maka/core/interaction'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, + type InteractionPendingSnapshot, type SessionCatalogProjection, type SessionContinuitySnapshot, type SubscriptionFrame, @@ -54,6 +55,7 @@ describe('Maka ACP stdio server', () => { let root: NonNullable | undefined; let first: FakeSubscription | undefined; let opens = 0; + let pending: InteractionPendingSnapshot | undefined; const stops: unknown[] = []; const snapshot = (projectionRevision = 1): SessionContinuitySnapshot => continuitySnapshot({ @@ -68,6 +70,7 @@ describe('Maka ACP stdio server', () => { return (created = sessionProjection({ id: input.sessionId })); if (operation === 'connection.catalog.query') return connectionCatalogPage(); if (operation === 'session.catalog.query') return { kind: 'session', session: created }; + if (operation === 'interaction.query') return pending; if (operation === 'turn.start') { root = { sessionId: input.sessionId, @@ -152,6 +155,15 @@ describe('Maka ACP stdio server', () => { assert.equal(opens, 2); assert.deepEqual(stops, []); } else { + pending = { + schemaVersion: 1, + interactionId: 'question-1', + ...root!, + revision: 1, + status: 'pending', + outcome: null, + request: unsupportedRequests[scenario], + }; first!.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', @@ -160,20 +172,24 @@ describe('Maka ACP stdio server', () => { snapshot: { ...snapshot(3), interactions: { - pending: [ - { - schemaVersion: 1, - interactionId: 'question-1', - ...root!, - revision: 1, - status: 'pending', - outcome: null, - request: unsupportedRequests[scenario], - }, - ], + pending: [pending], }, }, }); + if ( + scenario === 'permission' || + scenario === 'sandbox_boundary' || + scenario === 'client_capability' + ) { + const request = () => + (harness.stdoutMessages() as Array<{ id: string; method?: string }>).find( + (message) => message.method === 'session/request_permission', + ); + await waitFor(() => Boolean(request())); + stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id: request()!.id, error: { code: -32601, message: 'Unsupported client method' } })}\n`, + ); + } await waitFor(() => Boolean(response(2))); assert.equal(response(2)?.error?.data?.code, 'unsupported_interaction'); assert.equal(response(2)?.error?.data?.kind, scenario); diff --git a/packages/cli/src/__tests__/acp-tools-child-process.test.ts b/packages/cli/src/__tests__/acp-tools-child-process.test.ts new file mode 100644 index 0000000000..a295088653 --- /dev/null +++ b/packages/cli/src/__tests__/acp-tools-child-process.test.ts @@ -0,0 +1,687 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { + methods, + RequestError, + type CreateElicitationRequest, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk'; +import { mcpProxyToolName } from '@maka/runtime/mcp-tools'; +import { withAcpChildProcessHarness } from './acp-child-process-harness.js'; + +const MODEL_ID = 'acp-tools-fixture'; +const CAPACITY_FILLER = 'ACP_CAPACITY_FILLER'; +const legacyFixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); +const environmentFixture = fileURLToPath( + new URL('./acp-environment-mcp-fixture.js', import.meta.url), +); +const formFixture = fileURLToPath( + new URL('./form-stdio-server.js', import.meta.resolve('@maka/mcp/test-only/form-server')), +); + +describe('ACP tools through the official SDK, child process and Runtime Host', () => { + test('ask authorizes the exact MCP Session scope and settles the authoritative result before end_turn', { + timeout: 60_000, + }, async () => { + const marker = 'ACP_ECHO_TOOL'; + const sentinel = 'acp-echo-authoritative-result'; + const model = await startToolModel([{ marker, tool: 'echo', args: { value: sentinel } }]); + try { + await withAcpChildProcessHarness( + async (harness) => { + const permissions: RequestPermissionRequest[] = []; + const updates: SessionNotification[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [legacyFixture], env: [] }, + ], + }); + assert.equal( + created.configOptions?.find((option) => option.id === 'permission_mode') + ?.currentValue, + 'ask', + ); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: marker }], + }), + { stopReason: 'end_turn' }, + ); + assert.equal(permissions.length, 1, harness.stdout); + assert.equal(permissions[0].sessionId, created.sessionId); + assert.equal(permissions[0].options[0].kind, 'allow_always'); + assert.match(permissions[0].options[0].name, /this Session/); + assert.match(JSON.stringify(permissions[0].toolCall.content), /mcp_tool/); + assertToolSettled(updates, created.sessionId, sentinel); + assert.ok( + model.results(marker).some((result) => result.includes(sentinel)), + model.diagnostics(), + ); + assert.deepEqual( + await context.request(methods.agent.session.close, { + sessionId: created.sessionId, + }), + {}, + ); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissions.push(params); + return { outcome: { outcome: 'selected', optionId: params.options[0].optionId } }; + }), + ); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('tool results settle at the full 16-subscription capacity without opening another subscription', { + timeout: 90_000, + }, async () => { + const marker = 'ACP_CAPACITY_TOOL'; + const sentinel = 'acp-capacity-authoritative-result'; + const model = await startToolModel([{ marker, tool: 'echo', args: { value: sentinel } }]); + try { + await withAcpChildProcessHarness( + async (harness) => { + const permissions: RequestPermissionRequest[] = []; + const updates: SessionNotification[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const sessionIds: string[] = []; + for (let index = 0; index < 15; index += 1) { + const filler = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + sessionIds.push(filler.sessionId); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: filler.sessionId, + prompt: [{ type: 'text', text: `${CAPACITY_FILLER} ${index}` }], + }), + { stopReason: 'end_turn' }, + ); + } + // Completed prompts retain their attachment until session/close. + // Only the final Session starts an MCP process. + const target = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [legacyFixture], env: [] }, + ], + }); + sessionIds.push(target.sessionId); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: target.sessionId, + prompt: [{ type: 'text', text: marker }], + }), + { stopReason: 'end_turn' }, + ); + assert.equal(permissions.length, 1, harness.stdout); + assert.equal(permissions[0].sessionId, target.sessionId); + assertToolSettled(updates, target.sessionId, sentinel); + assert.ok( + model.results(marker).some((result) => result.includes(sentinel)), + model.diagnostics(), + ); + + // Verify the limit is actually occupied, so closing a filler + // attachment implicitly cannot turn this into a false positive. + const overflow = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + sessionIds.push(overflow.sessionId); + await assert.rejects( + context.request(methods.agent.session.prompt, { + sessionId: overflow.sessionId, + prompt: [{ type: 'text', text: CAPACITY_FILLER }], + }), + (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.deepEqual(error.data, { + source: 'runtime_host', + operation: 'subscription.open', + code: 'operation_conflict', + }); + return true; + }, + ); + await Promise.all( + sessionIds.map((sessionId) => + context.request(methods.agent.session.close, { sessionId }), + ), + ); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissions.push(params); + return { outcome: { outcome: 'selected', optionId: params.options[0].optionId } }; + }), + ); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + }, + { + startRuntimeHost: true, + timeoutMs: 75_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('modern stdio inputRequired becomes a typed ACP form and resumes the same MCP call', { + timeout: 60_000, + }, async () => { + const marker = 'ACP_FORM_TOOL'; + const model = await startToolModel([{ marker, tool: 'ask_user', args: {} }]); + const values = { name: 'Ada', email: 'ada@example.com', confirm: true }; + try { + await withAcpChildProcessHarness( + async (harness) => { + const forms: CreateElicitationRequest[] = []; + const permissions: RequestPermissionRequest[] = []; + const updates: SessionNotification[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} } }, + }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [formFixture], env: [] }, + ], + }); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: marker }], + }), + { stopReason: 'end_turn' }, + ); + assert.equal(permissions.length, 1, harness.stdout); + assert.equal(forms.length, 1, harness.stdout); + const form = forms[0]; + assert.equal(form.mode, 'form'); + assert.ok('sessionId' in form); + assert.equal(form.sessionId, created.sessionId); + const schema = 'requestedSchema' in form ? form.requestedSchema : undefined; + assert.ok(schema && typeof schema === 'object' && 'properties' in schema); + assert.deepEqual(Object.keys(schema.properties as object), [ + 'name', + 'email', + 'confirm', + ]); + assertToolSettled(updates, created.sessionId, 'Form completed'); + const results = model.results(marker).join('\n'); + assert.match(results, /Ada/); + assert.match(results, /ada@example.com/); + assert.match(results, /true/); + assert.doesNotMatch( + harness.stdout + harness.stderr + results, + /stdio-private-continuation-state/, + ); + assert.deepEqual( + await context.request(methods.agent.session.close, { + sessionId: created.sessionId, + }), + {}, + ); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissions.push(params); + return { outcome: { outcome: 'selected', optionId: params.options[0].optionId } }; + }) + .onRequest(methods.client.elicitation.create, ({ params }) => { + forms.push(params); + return { action: 'accept', content: values }; + }), + ); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('same-named servers keep different Session environments and grants after one Session closes', { + timeout: 60_000, + }, async () => { + const alphaFingerprint = '8ed3f6ad685b959ead7022518e1af76cd816f8e8ec7ccdda1ed4018e8f2223f8'; + const betaFingerprint = 'f44e64e75f3948e9f73f8dfa94721c4ce8cbb4f265c4790c702b2d41cfbf2753'; + const model = await startToolModel([ + { + marker: 'ACP_ENV_ALPHA', + tool: 'environment', + args: { names: ['ACP_SESSION_FINGERPRINT'] }, + }, + { marker: 'ACP_ENV_BETA', tool: 'environment', args: { names: ['ACP_SESSION_FINGERPRINT'] } }, + { + marker: 'ACP_ENV_BETA_AGAIN', + tool: 'environment', + args: { names: ['ACP_SESSION_FINGERPRINT'] }, + }, + ]); + try { + await withAcpChildProcessHarness( + async (harness) => { + const updates: SessionNotification[] = []; + const permissions: RequestPermissionRequest[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const sessions = await Promise.all( + ['alpha', 'beta'].map((value) => + context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'fixture', + command: process.execPath, + args: [environmentFixture, '--environment'], + env: [{ name: 'ACP_SESSION_SENTINEL', value }], + }, + ], + }), + ), + ); + const [alpha, beta] = sessions; + const completed = await Promise.all( + sessions.map((session, index) => + context.request(methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [ + { type: 'text', text: index === 0 ? 'ACP_ENV_ALPHA' : 'ACP_ENV_BETA' }, + ], + }), + ), + ); + assert.deepEqual(completed, [{ stopReason: 'end_turn' }, { stopReason: 'end_turn' }]); + assertToolSettled(updates, alpha.sessionId, alphaFingerprint); + assertToolSettled(updates, beta.sessionId, betaFingerprint); + assert.ok(!toolOutput(updates, alpha.sessionId).includes(betaFingerprint)); + assert.ok(!toolOutput(updates, beta.sessionId).includes(alphaFingerprint)); + assert.deepEqual( + new Set(permissions.map((permission) => permission.sessionId)), + new Set(sessions.map((session) => session.sessionId)), + ); + assert.equal(permissions.length, 2); + await context.request(methods.agent.session.close, { sessionId: alpha.sessionId }); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: beta.sessionId, + prompt: [{ type: 'text', text: 'ACP_ENV_BETA_AGAIN' }], + }), + { stopReason: 'end_turn' }, + ); + assert.equal( + permissions.length, + 2, + 'the surviving Session retains its exact tool grant', + ); + assert.ok( + model + .results('ACP_ENV_BETA_AGAIN') + .some((result) => result.includes(betaFingerprint)), + model.diagnostics(), + ); + await context.request(methods.agent.session.close, { sessionId: beta.sessionId }); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissions.push(params); + return { outcome: { outcome: 'selected', optionId: params.options[0].optionId } }; + }), + ); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('cancels and closes a Turn while the SDK client never answers its permission request', { + timeout: 60_000, + }, async () => { + const marker = 'ACP_CANCEL_PERMISSION'; + const model = await startToolModel([ + { marker, tool: 'echo', args: { value: 'must-not-execute' } }, + ]); + let permissionEntered!: () => void; + const permissionPending = new Promise((resolve) => { + permissionEntered = resolve; + }); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [legacyFixture], env: [] }, + ], + }); + const prompt = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: marker }], + }); + await permissionPending; + await context.notify(methods.agent.session.cancel, { sessionId: created.sessionId }); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.deepEqual( + await context.request(methods.agent.session.close, { + sessionId: created.sessionId, + }), + {}, + ); + assert.deepEqual(model.results(marker), []); + }, + (app) => + app.onRequest(methods.client.session.requestPermission, () => { + permissionEntered(); + return new Promise(() => undefined); + }), + ); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); +}); + +function assertToolSettled( + updates: readonly SessionNotification[], + sessionId: string, + sentinel: string, +): void { + const results = updates.filter( + ({ sessionId: id, update }) => + id === sessionId && + update.sessionUpdate === 'tool_call_update' && + update.status === 'completed', + ); + assert.ok( + results.some((result) => JSON.stringify(result.update).includes(sentinel)), + JSON.stringify(updates), + ); + const ids = updates.flatMap(({ sessionId: id, update }) => + id === sessionId && update.sessionUpdate === 'tool_call' ? [update.toolCallId] : [], + ); + assert.equal(new Set(ids).size, ids.length, 'each tool has exactly one card'); +} + +function toolOutput(updates: readonly SessionNotification[], sessionId: string): string { + return JSON.stringify( + updates + .filter( + ({ sessionId: id, update }) => + id === sessionId && + update.sessionUpdate === 'tool_call_update' && + update.status === 'completed', + ) + .map(({ update }) => update), + ); +} + +interface ToolModelRoute { + readonly marker: string; + readonly tool: string; + readonly args: Record; +} + +/** The same real OpenAI-compatible SSE flow used by the remote TUI MCP integration. */ +async function startToolModel(routes: readonly ToolModelRoute[]) { + const steps = new Map(); + const results = new Map(); + const requests: unknown[] = []; + const errors: unknown[] = []; + const server = createServer((request, response) => { + void readBody(request) + .then((body) => { + const input = JSON.parse(body) as Record; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'summary', + object: 'chat.completion', + created: 1, + model: MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ACP MCP test' }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + const messages = Array.isArray(input.messages) + ? (input.messages as Array>) + : []; + const latestUserMessage = [...messages] + .reverse() + .find((message) => message.role === 'user'); + if ( + latestUserMessage && + JSON.stringify(latestUserMessage.content).includes(CAPACITY_FILLER) + ) { + respondEvents(response, [ + modelChunk( + CAPACITY_FILLER, + { role: 'assistant', content: 'Subscription retained.' }, + null, + ), + modelChunk(CAPACITY_FILLER, {}, 'stop'), + ]); + return; + } + let route: ToolModelRoute | undefined; + for (const message of [...messages].reverse()) { + if (message.role !== 'user') continue; + const text = JSON.stringify(message.content); + route = [...routes] + .sort((a, b) => b.marker.length - a.marker.length) + .find((candidate) => text.includes(candidate.marker)); + if (route) break; + } + assert.ok(route, `No fixture route in ${body}`); + const step = (steps.get(route.marker) ?? 0) + 1; + steps.set(route.marker, step); + const names = toolNames(input); + requests.push({ marker: route.marker, step, tools: names }); + if (step === 1) { + assert.ok(names.includes('tool_search')); + respondTool(response, route.marker, step, 'tool_search', { + query: mcpProxyToolName('fixture', route.tool), + }); + } else if (step === 2) { + const tool = mcpProxyToolName('fixture', route.tool); + assert.ok(names.includes(tool), `Missing ${tool}: ${names.join(', ')}`); + respondTool(response, route.marker, step, tool, route.args); + } else { + results.set( + route.marker, + messages + .filter((message) => message.role === 'tool') + .map((message) => JSON.stringify(message.content)), + ); + respondEvents(response, [ + modelChunk( + route.marker, + { role: 'assistant', content: 'ACP MCP execution completed.' }, + null, + ), + modelChunk(route.marker, {}, 'stop'), + ]); + } + }) + .catch((error: unknown) => { + errors.push(error); + response.destroy(error as Error); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + results: (marker: string) => results.get(marker) ?? [], + diagnostics: () => JSON.stringify({ requests, errors: errors.map(String) }), + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + }; +} + +function toolNames(input: Record): string[] { + return (Array.isArray(input.tools) ? input.tools : []).flatMap( + (tool: { function?: { name?: unknown } }) => + typeof tool.function?.name === 'string' ? [tool.function.name] : [], + ); +} + +function respondTool( + response: ServerResponse, + marker: string, + step: number, + name: string, + args: Record, +): void { + respondEvents(response, [ + modelChunk( + marker, + { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `${marker}-call-${step}`, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }, + null, + ), + modelChunk(marker, {}, 'tool_calls'), + ]); +} + +function modelChunk( + id: string, + delta: Record, + finishReason: 'tool_calls' | 'stop' | null, +) { + return { + id, + object: 'chat.completion.chunk', + created: 1, + model: MODEL_ID, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(finishReason + ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } + : {}), + }; +} + +function respondEvents(response: ServerResponse, events: readonly unknown[]): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end('data: [DONE]\n\n'); +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/packages/cli/src/__tests__/bounded-chunk-buffer.test.ts b/packages/cli/src/__tests__/bounded-chunk-buffer.test.ts index d1e5342014..817ecd99c0 100644 --- a/packages/cli/src/__tests__/bounded-chunk-buffer.test.ts +++ b/packages/cli/src/__tests__/bounded-chunk-buffer.test.ts @@ -73,4 +73,23 @@ describe('BoundedChunkBuffer', () => { assert.deepEqual(buffer.values(), ['a']); assert.equal(buffer.droppedChars, 2); }); + + test('shares a smaller presentation budget without forgetting discarded sequence identities', () => { + const buffer = new BoundedChunkBuffer({ + maxChars: 20, + maxChunks: 10, + textOf: (chunk) => chunk.text, + withText: (chunk, text) => ({ ...chunk, text }), + sequence: (chunk) => chunk.seq, + }); + buffer.append({ seq: 1, text: 'old' }); + buffer.append({ seq: 2, text: '😀tail' }); + const prior = buffer.values(); + buffer.trimTo(5, 1); + assert.equal(buffer.charLength, 4); + assert.deepEqual(buffer.values(), [{ seq: 2, text: 'tail' }]); + assert.notEqual(buffer.values(), prior); + assert.equal(buffer.append({ seq: 1, text: 'old' }), false); + assert.equal(buffer.droppedChars, 5); + }); }); diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index a6572f2934..f32ca1a4cc 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -54,7 +54,7 @@ describe('Maka CLI args', () => { assert.match(help.text, /^ {2}maka update /m); assert.match( help.text, - /^ {2}maka --acp {2,}Serve ACP v1 over stdio \(sessions, prompts, streaming, cancellation\)$/m, + /^ {2}maka --acp {2,}Serve ACP v1 over stdio \(sessions, tools, permissions, forms, stdio MCP\)$/m, ); // Runtime Host owns its own help; the root lists it once and points there. assert.match(help.text, /^ {2}maka runtime-host \.\.\. {2,}Serve and manage a Runtime Host$/m); diff --git a/packages/cli/src/__tests__/mcp-capability-publication.test.ts b/packages/cli/src/__tests__/mcp-capability-publication.test.ts new file mode 100644 index 0000000000..0794500a0c --- /dev/null +++ b/packages/cli/src/__tests__/mcp-capability-publication.test.ts @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; +import { + McpCapabilityPublication, + type McpCapabilityPublicationState, +} from '../mcp-capability-publication.js'; + +test('publication deduplicates a snapshot within one Host connection and republishes on reconnect', async () => { + const harness = publicationHarness(); + assert.equal(await harness.publication.settle(), 'published'); + assert.equal(await harness.publication.settle(), 'published'); + assert.deepEqual(harness.replacements, [{ identity: 'host:one', revision: 1 }]); + harness.revision = 2; + assert.equal(await harness.publication.settle(), 'published'); + harness.identity = 'host:two'; + assert.equal(await harness.publication.settle(), 'published'); + assert.deepEqual(harness.replacements, [ + { identity: 'host:one', revision: 1 }, + { identity: 'host:one', revision: 2 }, + { identity: 'host:two', revision: 2 }, + ]); + harness.publication.invalidate(); + await harness.publication.settle(); + assert.equal(harness.replacements.length, 4); + await harness.publication.close(); + assert.deepEqual(harness.unregisters, ['host:two']); +}); + +test('publication coalesces revisions while a manifest is in flight and awaits the latest delivery', async () => { + const first = deferred(); + const second = deferred(); + const harness = publicationHarness(); + harness.replace = async () => { + await (harness.replacements.length === 1 ? first.promise : second.promise); + }; + const pending = harness.publication.settle(); + harness.revision = 2; + harness.publication.request(); + harness.revision = 3; + harness.publication.request(); + first.resolve(); + await nextTurn(); + assert.deepEqual( + harness.replacements.map((item) => item.revision), + [1, 3], + ); + assert.equal(harness.states.includes('published'), false); + let settled = false; + void pending.then(() => { + settled = true; + }); + await nextTurn(); + assert.equal(settled, false); + second.resolve(); + assert.equal(await pending, 'published'); + assert.equal(harness.states.filter((state) => state === 'published').length, 1); + await harness.publication.close(); +}); + +test('an obsolete successful replacement is withdrawn when the latest snapshot becomes empty', async () => { + const accepted = deferred(); + const harness = publicationHarness(); + harness.replace = () => accepted.promise; + const pending = harness.publication.settle(); + harness.revision = 2; + harness.hasTools = false; + harness.publication.request(); + accepted.resolve(); + assert.equal(await pending, 'not_published'); + assert.deepEqual(harness.unregisters, ['host:one']); + await harness.publication.close(); + assert.deepEqual(harness.unregisters, ['host:one']); +}); + +test('an old connection completion cannot unregister or advertise tools on its replacement', async () => { + const accepted = deferred(); + const harness = publicationHarness(); + harness.replace = () => accepted.promise; + const pending = harness.publication.settle(); + harness.identity = 'host:two'; + harness.hasTools = false; + harness.publication.invalidate(); + harness.publication.request(); + accepted.resolve(); + assert.equal(await pending, 'not_published'); + assert.deepEqual(harness.unregisters, []); + assert.equal(harness.states.includes('published'), false); + await harness.publication.close(); + assert.deepEqual(harness.unregisters, []); +}); + +test('failed publication releases its provider and readiness can retry the same revision', async () => { + const harness = publicationHarness(); + harness.replace = async () => { + throw new Error('Host rejected publication'); + }; + assert.equal(await harness.publication.settle(), 'error'); + assert.equal(harness.providerClosures, 1); + assert.deepEqual(harness.unregisters, []); + harness.replace = async () => undefined; + assert.equal(await harness.publication.settle(), 'published'); + assert.equal(harness.replacements.length, 2); + await harness.publication.close(); + assert.deepEqual(harness.unregisters, ['host:one']); +}); + +test('close waits for an admitted publication then withdraws it without notifying after close', async () => { + const accepted = deferred(); + const harness = publicationHarness(); + harness.replace = () => accepted.promise; + harness.publication.request(); + const states = [...harness.states]; + let closed = false; + const closing = harness.publication.close().then(() => { + closed = true; + }); + await nextTurn(); + assert.equal(closed, false); + accepted.resolve(); + await closing; + assert.deepEqual(harness.unregisters, ['host:one']); + assert.deepEqual(harness.states, states); + harness.publication.request(); + assert.equal(await harness.publication.settle(), 'unavailable'); + await harness.publication.close(); + assert.equal(harness.replacements.length, 1); + assert.equal(harness.unregisters.length, 1); +}); + +test('authoritative retirement closes publication state without unregistering again', async () => { + const harness = publicationHarness(); + assert.equal(await harness.publication.settle(), 'published'); + await harness.publication.retire(); + assert.deepEqual(harness.unregisters, []); + assert.equal(await harness.publication.settle(), 'unavailable'); + await harness.publication.close(); + assert.deepEqual(harness.unregisters, []); +}); + +test('empty snapshots and unavailable connections never create or withdraw a registration', async () => { + const harness = publicationHarness(); + harness.hasTools = false; + assert.equal(await harness.publication.settle(), 'not_published'); + harness.identity = undefined; + assert.equal(await harness.publication.settle(), 'unavailable'); + await harness.publication.close(); + assert.deepEqual(harness.replacements, []); + assert.deepEqual(harness.unregisters, []); +}); + +function publicationHarness() { + const harness = { + identity: 'host:one' as string | undefined, + revision: 1, + hasTools: true, + providerClosures: 0, + replacements: [] as { identity: string | undefined; revision: number }[], + unregisters: [] as (string | undefined)[], + states: [] as McpCapabilityPublicationState[], + replace: async (): Promise => undefined, + publication: undefined as unknown as McpCapabilityPublication, + }; + harness.publication = new McpCapabilityPublication({ + connectionIdentity: () => harness.identity, + revision: () => harness.revision, + createProvider: () => + harness.hasTools + ? provider(() => { + harness.providerClosures += 1; + }) + : undefined, + replace: async () => { + harness.replacements.push({ identity: harness.identity, revision: harness.revision }); + await harness.replace(); + }, + unregister: async () => { + harness.unregisters.push(harness.identity); + }, + onState: (state) => { + harness.states.push(state); + }, + }); + return harness; +} + +function provider(close: () => void): ClientCapabilityProvider { + return { offers: () => [], close }; +} + +function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts b/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts new file mode 100644 index 0000000000..fc3fb13014 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts @@ -0,0 +1,433 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; +import { + RuntimeHostSubscriptionError, + type DecodedSessionTranscriptPage, + type RuntimeHostSessionSubscription, +} from '@maka/runtime-host/client'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + type SessionContinuitySnapshot, + type SessionTranscriptBootstrap, + type SessionTranscriptPage, + type SessionTranscriptPageInput, + type SubscriptionFrame, +} from '@maka/runtime-host/protocol'; +import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; + +test('prompt transcript pages on its existing subscription, preserves sparse cuts and waits for its consumer', async () => { + const subscription = new TranscriptSubscription('first', 7); + let opens = 0; + const channel = await openChannel(async () => { + opens += 1; + return subscription; + }); + const transcript = channel.trackPromptTranscript('turn'); + subscription.advance(63); + await setImmediate(); + subscription.readPage = async (input) => + input.cursor === null + ? subscription.page(input, [{ identity: 16, message: result('tool-1', 'first') }], 'next') + : subscription.page(input, [ + { identity: 40, message: result('tool-2', 'second') }, + { identity: 56, message: terminal() }, + ]); + const pending = deferred(); + const batches: readonly StoredMessage[][] = []; + let delivered = false; + const reading = transcript + .reconcile(async (messages) => { + (batches as StoredMessage[][]).push([...messages]); + await pending.promise; + }) + .then(() => { + delivered = true; + }); + await setImmediate(); + assert.equal(delivered, false); + assert.equal(subscription.pages.length, 1); + pending.resolve(); + await reading; + assert.equal(opens, 1); + assert.equal(subscription.bootstrapReads, 1); + assert.deepEqual( + subscription.pages.map(({ cursor, anchorSequence, throughSequence }) => ({ + cursor, + anchorSequence, + throughSequence, + })), + [ + { cursor: null, anchorSequence: 7, throughSequence: 63 }, + { cursor: 'next', anchorSequence: null, throughSequence: 63 }, + ], + ); + assert.equal(batches.flat().length, 3); + transcript.dispose(); + await channel.close(); +}); + +test('every reconciliation rereads its immutable pre-turn cut and ignores unrelated turns', async () => { + const subscription = new TranscriptSubscription('first', 7); + const channel = await openChannel(async () => subscription); + const transcript = channel.trackPromptTranscript('turn'); + subscription.advance(63); + await setImmediate(); + let text = 'original'; + subscription.readPage = async (input) => + subscription.page( + input, + [ + { identity: 16, message: result('tool', text) }, + { + identity: 24, + message: { ...result('unrelated', 'not for this prompt'), turnId: 'nested-turn' }, + }, + { identity: 56, message: terminal() }, + ], + 'unrelated-history-after-terminal', + ); + const observed: StoredMessage[] = []; + await transcript.reconcile(async (messages) => { + observed.push(...messages); + }); + text = 'authoritative archived projection'; + await transcript.reconcile(async (messages) => { + observed.push(...messages); + }); + assert.equal(subscription.pages.length, 2); + assert.deepEqual( + subscription.pages.map(({ anchorSequence }) => anchorSequence), + [7, 7], + ); + assert.ok(observed.every((message) => message.turnId === 'turn')); + assert.equal( + observed.filter((message) => message.type === 'tool_result').at(-1)?.content.kind, + 'text', + ); + const last = observed.filter((message) => message.type === 'tool_result').at(-1)!; + assert.equal(last.content.kind === 'text' && last.content.text, text); + transcript.dispose(); + await channel.close(); +}); + +test('cancel and channel close release a stalled read without waiting for missing results', async () => { + for (const action of ['cancel', 'dispose', 'close'] as const) { + const subscription = new TranscriptSubscription('first', 7); + const channel = await openChannel(async () => subscription); + const transcript = channel.trackPromptTranscript('turn'); + subscription.advance(31); + await setImmediate(); + const stalled = deferred(); + subscription.readPage = () => stalled.promise; + const abort = new AbortController(); + const observed: StoredMessage[] = []; + const reading = transcript.reconcile(async (messages) => { + observed.push(...messages); + }, abort.signal); + await setImmediate(); + if (action === 'cancel') abort.abort(new Error('cancelled')); + else if (action === 'dispose') transcript.dispose(); + else await channel.close(); + await assert.rejects(reading); + stalled.resolve( + subscription.page(subscription.pages[0]!, [ + { identity: 16, message: result('late', 'must not publish') }, + ]), + ); + await setImmediate(); + assert.equal(observed.length, 0); + transcript.dispose(); + await channel.close(); + } +}); + +test('recovery across root turns rereads the old prompt below the new bootstrap cut and drops stale pages', async () => { + const first = new TranscriptSubscription('first', 7); + const second = new TranscriptSubscription('second', 95); + second.snapshot.rootTurn = runningTurn('next-turn'); + second.initialTranscript = [terminal()]; + let opens = 0; + const channel = await openChannel(async () => (++opens === 1 ? first : second)); + const transcript = channel.trackPromptTranscript('turn'); + first.setRoot(runningTurn('turn')); + first.advance(31); + await setImmediate(); + const stale = deferred(); + first.readPage = () => stale.promise; + second.readPage = async (input) => + second.page(input, [ + { identity: 16, message: result('tool', 'recovered') }, + { identity: 56, message: terminal() }, + ]); + const observed: StoredMessage[] = []; + const reading = transcript.reconcile(async (messages) => { + observed.push(...messages); + }); + await setImmediate(); + first.fail(new RuntimeHostSubscriptionError('connection_closed', 'recover')); + await reading; + stale.resolve(first.page(first.pages[0]!, [{ identity: 16, message: result('stale', 'old') }])); + await setImmediate(); + assert.equal(opens, 2); + assert.equal(second.pages[0]?.anchorSequence, 7); + assert.equal(second.pages[0]?.throughSequence, 95); + assert.equal(channel.snapshot.rootTurn?.turnId, 'next-turn'); + assert.equal(observed.filter((message) => message.type === 'tool_result').length, 1); + assert.equal(observed[0]?.type === 'tool_result' && observed[0].toolUseId, 'tool'); + transcript.dispose(); + await channel.close(); +}); + +test('prompt transcript rejects nonadvancing cursors and propagates consumer failures', async () => { + for (const failure of ['cursor', 'consumer'] as const) { + const subscription = new TranscriptSubscription('first', 7); + const channel = await openChannel(async () => subscription); + const transcript = channel.trackPromptTranscript('turn'); + subscription.advance(63); + await setImmediate(); + subscription.readPage = async (input) => + subscription.page( + input, + [{ identity: 16, message: result('tool', 'result') }], + failure === 'cursor' ? 'same' : null, + ); + await assert.rejects( + transcript.reconcile(async () => { + if (failure === 'consumer') throw new Error('notification rejected'); + }), + failure === 'cursor' ? /cursor did not advance/ : /notification rejected/, + ); + transcript.dispose(); + await channel.close(); + } +}); + +async function openChannel( + openSessionSubscription: () => Promise, +): Promise { + const connection = { reconnecting: true as const, openSessionSubscription }; + const { channel } = await RuntimeHostSessionChannel.open({ + connection, + sessionId: 'session', + now: () => 1, + onTurnStarted: () => {}, + onRuntimeResourceChanged: () => {}, + onInteractionPending: () => {}, + onInteractionResolved: () => {}, + onTranscriptSettlement: () => {}, + onTranscriptReplaced: () => {}, + onGoalChanged: () => {}, + onRecovered: () => {}, + }); + channel.activate(); + return channel; +} + +class TranscriptSubscription + implements RuntimeHostSessionSubscription, AsyncIterator +{ + readonly hostEpoch = 'host'; + readonly activeAssistantStreams = []; + snapshot: SessionContinuitySnapshot = { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'session', + metadataRevision: 1, + status: 'active', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { hostEpoch: 'host', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }; + readonly transcriptBootstrap: SessionTranscriptBootstrap; + readonly pages: Omit[] = []; + readonly #decoded = new WeakMap< + SessionTranscriptPage, + readonly { identity: number; message: StoredMessage }[] + >(); + readonly #frames: SubscriptionFrame[] = []; + #waiting?: { + resolve: (result: IteratorResult) => void; + reject: (error: Error) => void; + }; + #closed = false; + #sequence = 0; + bootstrapReads = 0; + initialTranscript: StoredMessage[] = []; + readPage: ( + input: Omit, + ) => Promise = async (input) => this.page(input, []); + + constructor( + readonly subscriptionId: string, + throughSequence: number, + ) { + const durable = this.page( + { + source: 'durable', + direction: 'older', + throughSequence, + cursor: null, + anchorSequence: null, + maxBytes: 16384, + }, + [], + ); + this.transcriptBootstrap = { + throughSequence, + durable, + overlay: { ...durable, source: 'overlay' }, + overlayMessageCount: 0, + }; + } + subscribePtyData(): () => void { + return () => {}; + } + subscribeSessionDomainChanges(): () => void { + return () => {}; + } + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + next(): Promise> { + const frame = this.#frames.shift(); + if (frame) return Promise.resolve({ value: frame, done: false }); + if (this.#closed) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve, reject) => { + this.#waiting = { resolve, reject }; + }); + } + advance(throughSequence: number): void { + const frame: SubscriptionFrame = { + kind: 'subscription.transcript_advanced', + sessionId: 'session', + subscriptionId: this.subscriptionId, + hostEpoch: this.hostEpoch, + sequence: ++this.#sequence, + throughSequence, + }; + this.push(frame); + } + setRoot(rootTurn: SessionContinuitySnapshot['rootTurn']): void { + this.snapshot = { + ...this.snapshot, + projectionRevision: this.snapshot.projectionRevision + 1, + rootTurn, + }; + this.push({ + kind: 'subscription.session_projection', + subscriptionId: this.subscriptionId, + hostEpoch: this.hostEpoch, + sequence: ++this.#sequence, + snapshot: structuredClone(this.snapshot), + }); + } + push(frame: SubscriptionFrame): void { + if (this.#waiting) { + this.#waiting.resolve({ done: false, value: frame }); + this.#waiting = undefined; + } else this.#frames.push(frame); + } + fail(error: Error): void { + this.#waiting?.reject(error); + this.#waiting = undefined; + } + async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + this.bootstrapReads += 1; + return this.initialTranscript.map(decodeMessage); + } + async loadTranscriptOverlay(): Promise { + return []; + } + async loadTranscriptPage( + input: Omit, + ): Promise { + this.pages.push(input); + return this.readPage(input); + } + async decodeTranscriptPage( + page: SessionTranscriptPage, + decodeMessage: (value: unknown) => T, + maxMessageBytes?: number, + ): Promise> { + assert.equal(maxMessageBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES); + return { + messages: (this.#decoded.get(page) ?? []).map(({ identity, message }) => ({ + identity, + message: decodeMessage(message), + })), + nextCursor: page.nextCursor, + }; + } + page( + input: Omit, + messages: readonly { identity: number; message: StoredMessage }[], + nextCursor: string | null = null, + ): SessionTranscriptPage { + const page: SessionTranscriptPage = { + kind: 'page', + sessionId: 'session', + source: input.source, + direction: input.direction, + throughSequence: input.throughSequence, + rawBytes: 0, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor, + }; + this.#decoded.set(page, messages); + return page; + } + async close(): Promise { + this.#closed = true; + this.#waiting?.resolve({ value: undefined, done: true }); + this.#waiting = undefined; + } +} + +function result(toolUseId: string, text: string): StoredMessage { + return { + type: 'tool_result', + id: `result-${toolUseId}`, + turnId: 'turn', + ts: 2, + toolUseId, + isError: false, + content: { kind: 'text', text }, + }; +} +function terminal(): StoredMessage { + return { type: 'turn_state', id: 'terminal', turnId: 'turn', ts: 3, status: 'completed' }; +} + +function runningTurn(turnId: string) { + return { sessionId: 'session', turnId, runId: `run-${turnId}`, status: 'running' as const }; +} diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 210e02314a..693def33a8 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -47,13 +47,81 @@ explicit cancellation still returns `cancelled`, with the failed Stop diagnostic retained. Shutdown can cancel an initial attachment waiting for transcript hydration or reconnection without waiting for the Host to become available. -Interaction mapping remains deferred to the next ACP capability increment. If a -pending permission, question, form, sandbox-boundary, or client-capability request -belongs to an active ACP prompt, the adapter rejects it with JSON-RPC `-32603` and -`error.data.code: unsupported_interaction` (`error.data.kind` identifies the request). -It retires the attachment and uses the existing failure path to request Stop for -that prompt's exact Host Turn. It does not answer or approve the interaction; -Host remains responsible for settlement. A failed Stop retains the Host diagnostic. -The durable Session remains owned and can be prompted again or closed. -Interactions belonging to another client's Turn keep the idle attachment available -so ACP cancellation and close can still stop the observed root. +## Capabilities + +| Feature | ACP v1 behavior | +| --- | --- | +| Session create, list, configure, prompt, cancel, close | Supported through the shared Runtime Host connection. | +| Tools | `tool_call` and cumulative `tool_call_update` snapshots. Host `toolUseId` is the stable `toolCallId`. | +| Questions | Requires the client to advertise `elicitation.form`; each question is an optional string field with option hints and free answers. Missing, blank, or declined answers remain unanswered; cancellation cancels the Turn. | +| Forms | Standard `elicitation/create`, preserving string, number, integer, boolean, enum and multi-enum types and constraints. Defaults are hints, never automatically submitted. Decline and cancel remain distinct answers. | +| Sandbox boundary and client capability approval | Standard `session/request_permission`. The `allow_always` choice explicitly grants only the displayed scope for this Session; `reject_once` denies it. Permission cancellation cancels the Turn. | +| MCP | Session-owned stdio servers supplied in `session/new.mcpServers`; discovered tools and MCP form continuation use the existing MCP manager and Host capability path. | +| Tool `permission` | Standard `session/request_permission`. One-shot allow/deny choices are preserved; eligible tool permissions also expose an explicit allow-for-this-Turn choice. Permission cancellation cancels the Turn. | +| Load/resume, replacing all MCP configuration, HTTP/SSE/OAuth | Deferred. | + +The adapter saves the capabilities supplied during `initialize`. Missing form +capability, unsupported client methods, or invalid answers explicitly fail the +affected prompt and stop its exact Host Turn. Host owns interaction closure and +the canonical answer, including externally answered or replayed requests. Client +requests are fenced by Session, interaction, Turn/run, and attachment lifetime; +cancel and EOF release local waits even when the client never responds. + +## Tool output and completion + +Output, progress and previews update one card, including when start arrives late. +Input previews are labelled as previews. A complete authoritative result replaces +the live output. `contentOmitted` preserves the existing display and requires +transcript reconciliation. Raw input/output is omitted when completeness cannot +be established or its presentation would exceed the limit. Late progress cannot +reopen a terminal tool; authoritative results may still correct its content. + +Each tool retains at most 64 Ki characters and 512 chunks of presentation state. +Truncation is visible. A prompt may retain at most 1 Mi characters and 4096 tool +identities; exceeding either limit explicitly fails projection. Terminal delivery +releases large payloads and retains bounded identity/digest information. + +Before `turn.start`, the Session channel captures a transcript watermark. On +settlement and before successful prompt completion it rereads the target Turn to +the announced upper watermark, using the same subscription's paged transcript and +fragment decoder (the existing 16 MiB range assembly budget applies). Recovery +invalidates reads from the old subscription and starts again at the original cut. +This does not consume another subscription slot. Missing required results, failed +reads and failed notifications prevent `end_turn`; cancellation and failed Turns +do not wait for missing results. Only the channel decides Turn terminal state; +the registry waits for final projection delivery before returning `end_turn`. + +## Session MCP ownership + +The executable must be an absolute path. Duplicate server/env names, malformed +args/env and unsupported transports are rejected before starting processes. +Processes use the Session's working directory and the manager's existing +environment, credential exclusion, log redaction, discovery and cleanup behavior. +The configuration stays in memory and never edits user MCP settings. + +Creation generates an ID, prepares and validates every requested server, publishes +the Session-scoped capabilities, then dispatches Host `session.create`. One failed +server releases the entire prepared group. A confirmed creation always returns its +ID even if optional configuration presentation fails. If the dispatched creation +response is lost, the error includes `sessionId` and `dispatch: "dispatched"`; the +adapter retains the connection-local reservation and MCP resources. The client can +continue with that ID or close it; creation is never silently retried. + +Different Sessions can use the same server/tool names with different processes. +Registration replacement, unregister, disconnection and invocation routing respect +the target Session and owning connection. A default registration and its target +Session registration may not expose the same tool identity. Another Session cannot +borrow the registration through provider fallback. Reconnection republishes the +current tool snapshot without replaying calls, and prompt admission waits for the +current connection and tool revision to be published. + +Generic MCP `ask` approval uses `admission: "mcp"` and the existing atomic Session +grant mechanism with `mcp_tool` scope. It does not elevate provider trust or grant +Host path access. Desktop MCP continues to use its existing capability. These wire +changes move the Host compatibility epoch to 153; grant storage needs no migration. +Close/EOF stops execution, releases subscriptions, unregisters the corresponding +capabilities and closes MCP processes before closing the shared Host connection. + +For a Zed custom agent, configure an absolute Maka executable with `args: ["--acp"]` +under `agent_servers`, following [Zed's external agent documentation](https://zed.dev/docs/ai/external-agents#custom-agents). +The standard tool and permission flow does not require a private ACP route. diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md new file mode 100644 index 0000000000..1bc8adc149 --- /dev/null +++ b/packages/cli/src/acp/VALIDATION.md @@ -0,0 +1,106 @@ + + +# PR5 validation record + +Validated on macOS with Node 24.19.0 and ACP SDK 1.4.0. +Branch: `feat/acp-tools-interactions-mcp`. +After PR #4862 merged, the branch was rebuilt as one PR5 commit and was most recently +refreshed onto Apache main commit `5f4614bfdba710fad44699bbd879e78806ab54da`. +Scope follows the [PR5 checklist](https://github.com/apache/maka/issues/3132#issuecomment-5386735709) +and the approved implementation plan. + +The September 15 refresh also closes the remaining review races around cancelled +Turn interaction fences, authoritative Session registration retirement and failed +Turn tool-terminal delivery. Main had advanced the compatibility epoch to 154, so +the combined Session-scoped capability contract advances it once more to 155. + +## Automated results + +| Validation | Result | +| --- | --- | +| `npm run build` | Passed, including Desktop renderer and its notice attestation. | +| `npm run typecheck` | Passed across all workspaces after rebuilding workspace declarations. | +| `npm run check:cli-third-party-notices` | Passed. | +| `node scripts/protocol-epoch-check.mjs --base review/latest-main-5222` | Passed: changed protocol, epoch 154 → 155. | +| `node --test scripts/protocol-epoch-check.test.mjs` | 17 passed. | +| `npm run lint` / `npm run format:check` | Passed (3605 linted files, 2135 formatted files). | +| `git diff --check review/latest-main-5222...HEAD` | Passed. | +| MCP workspace tests | 250 passed. | +| Runtime Host workspace tests | 1946 passed, 12 skipped, including UDS scope isolation and existing Desktop/default registrations. | +| Core grant decoder test | Passed, including `mcp` and retained `desktop_mcp`. | +| CLI workspace tests | 1118 passed, 3 skipped, including the real ACP process boundary and all PR5 unit/integration suites. | +| Desktop and UI `knip` checks | Passed. | +| Desktop E2E | Current budget check passed with 38 tests in 22 files. The original Side Chat follow-up acceptance passed 10/10 under an isolated stress loop and in its then-current full suite; the detailed historical evidence remains below. | + +The first CLI run overlapped the full MCP E2E suite and one child-cleanup assertion +hit its five-second test deadline. The failed case passed alone in 91 ms; the full +CLI suite then passed without concurrent load, including the same case in 187 ms. + +The first GitHub Desktop E2E run exposed a test-side interaction race: an +optimistic queue row could appear before the Composer released its single-flight +send slot, so the test's immediate next Enter was correctly ignored. The same +missing readiness boundary also reproduced locally after a queue edit and before +dragging (1 failure in 10 runs). The E2E now waits for the actual enabled Send or +draggable control before acting; the same isolated loop then passed 10/10. The +full local suite passed 33 tests including this case; two unrelated macOS-native +focus/screenshot cases timed out once and both passed immediately when rerun. + +## Real ACP process boundary + +`acp-tools-child-process.test.ts` uses the official SDK, a real ACP child process, +a real execution Runtime Host, local model HTTP fixtures and actual stdio MCP +processes. Its five passing cases establish: + +1. `create → prompt → tool_search → MCP ask permission → Session grant → tool result → end_turn → close`. +2. Modern MCP `inputRequired → elicitation/form → typed answer → same-call continuation`, + with private continuation state excluded from the ACP transcript. +3. Parallel Sessions with the same server/tool names return distinct public + fingerprints of their isolated environments and retain separate grants; + closing one Session leaves the other's tools callable. +4. A client permission handler that never responds does not prevent + `session/cancel`, `session/close`, or stdin EOF cleanup. +5. Fifteen retained ordinary Session attachments plus a sixteenth MCP Session + complete MCP authorization and authoritative result reconciliation. A + seventeenth attachment is then rejected by Host `operation_conflict`, proving + reconciliation did not require another subscription slot. + +The existing `acp-child-process.test.ts` real-process suite also passed its Session, +configuration, capacity, recovery, streaming, cancellation and EOF checks. + +## Zed status + +Zed 1.19.2 opened a disposable project containing the custom `Maka PR5 Validation` +agent and forwarded the existing `fixture` stdio MCP server. Under Zed's `Ask` +permission mode, the prompt `Run the configured MCP echo tool and return its result.` +completed the standard UI flow: + +1. Zed displayed the `tool_search` card and then the `echo` card. +2. Zed displayed `Authorize a Session capability` with `capability: "mcp"`, + `scope.kind: "mcp_tool"`, `serverId: "fixture"` and `toolName: "echo"`. +3. Selecting `Allow this scope for this Session` resumed the same Turn. +4. The `echo` card completed and Zed displayed the final assistant text + `Zed PR5 MCP tool and permission flow completed.` + +The captured ACP stream independently records the permission request, the answered +`allow` decision, and the authoritative terminal tool update with +`resultPending: false`. Its content and `rawOutput` both contain +`Zed PR5 MCP result verified`, followed by the prompt response +`{"stopReason":"end_turn"}`. This completes the remaining standard Zed +tool/permission acceptance without a private ACP route. diff --git a/packages/cli/src/acp/active-promise.ts b/packages/cli/src/acp/active-promise.ts new file mode 100644 index 0000000000..e8963aab49 --- /dev/null +++ b/packages/cli/src/acp/active-promise.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Races cooperative work against cancellation while observing the losing task. */ +export function whileActive( + task: Promise, + signal: AbortSignal, +): Promise<{ active: true; value: T } | { active: false }> { + return new Promise((resolve, reject) => { + const cancelled = () => resolve({ active: false }); + if (signal.aborted) cancelled(); + else signal.addEventListener('abort', cancelled, { once: true }); + task.then( + (value) => { + signal.removeEventListener('abort', cancelled); + resolve(signal.aborted ? { active: false } : { active: true, value }); + }, + (error: unknown) => { + signal.removeEventListener('abort', cancelled); + if (signal.aborted) resolve({ active: false }); + else reject(error); + }, + ); + }); +} diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index ac91c535f7..e1523bef9a 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -17,7 +17,7 @@ * under the License. */ -import { agent, methods, type AgentApp } from '@agentclientprotocol/sdk'; +import { agent, methods, type AgentApp, type ClientCapabilities } from '@agentclientprotocol/sdk'; import type { AcpSessionRegistry } from './session-registry.js'; export interface MakaAcpAgentOptions { @@ -29,14 +29,20 @@ export interface MakaAcpAgentOptions { } export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { + let clientCapabilities: ClientCapabilities = {}; return agent({ name: 'maka' }) - .onRequest(methods.agent.initialize, () => ({ - protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, - authMethods: [], - agentInfo: { name: 'maka', title: 'Maka', version: options.version }, - })) - .onRequest(methods.agent.session.new, ({ params }) => options.sessionRegistry.create(params)) + .onRequest(methods.agent.initialize, ({ params }) => { + clientCapabilities = structuredClone(params.clientCapabilities ?? {}); + return { + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, + authMethods: [], + agentInfo: { name: 'maka', title: 'Maka', version: options.version }, + }; + }) + .onRequest(methods.agent.session.new, ({ params, signal }) => + options.sessionRegistry.create(params, signal), + ) .onRequest(methods.agent.session.list, ({ params }) => options.sessionRegistry.list(params)) .onRequest(methods.agent.session.setConfigOption, ({ params }) => options.sessionRegistry.setConfigOption(params), @@ -45,6 +51,15 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { options.sessionRegistry.prompt(params, { signal, notify: (notification) => client.notify(methods.client.session.update, notification), + interactions: { + capabilities: clientCapabilities, + requestPermission: (params, cancellationSignal) => + client.request(methods.client.session.requestPermission, params, { + cancellationSignal, + }), + createElicitation: (params, cancellationSignal) => + client.request(methods.client.elicitation.create, params, { cancellationSignal }), + }, }), ) .onNotification(methods.agent.session.cancel, ({ params }) => diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index a454235b4c..1ebc785d17 100644 --- a/packages/cli/src/acp/session-event-mapper.ts +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -25,30 +25,41 @@ import { } from '@agentclientprotocol/sdk'; import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; +import type { InteractionPendingSnapshot, InteractionSnapshot } from '@maka/runtime-host/protocol'; +import { whileActive } from './active-promise.js'; +import { AcpToolEventMapper } from './tool-event-mapper.js'; type StreamKind = 'text' | 'thinking'; export interface AcpSessionEventMapperOptions { readonly sessionId: string; readonly notify: (notification: SessionNotification) => Promise; + /** Ends projection delivery without waiting for a stalled client transport. */ + readonly signal?: AbortSignal; } /** Serializes one ACP prompt's live projection delivery. */ export class AcpSessionEventMapper { readonly #sessionId: string; readonly #notify: (notification: SessionNotification) => Promise; + readonly #signal: AbortSignal | undefined; readonly #streams = new Map(); + readonly #tools: AcpToolEventMapper; #tail: Promise = Promise.resolve(); - #failure: RequestError | undefined; + #failure: unknown; + #failed = false; constructor(options: AcpSessionEventMapperOptions) { this.#sessionId = options.sessionId; this.#notify = options.notify; + this.#signal = options.signal; + this.#tools = new AcpToolEventMapper((update) => + this.#deliver({ sessionId: this.#sessionId, update }), + ); } accept(event: SessionEvent): Promise { return this.#enqueue(async () => { - if (this.#failure) throw this.#failure; switch (event.type) { case 'text_delta': await this.#acceptText( @@ -70,6 +81,13 @@ export class AcpSessionEventMapper { case 'thinking_complete': await this.#acceptText('thinking', event.messageId, event.text); break; + case 'tool_start': + case 'tool_output_delta': + case 'tool_progress': + case 'tool_result_preview': + case 'tool_result': + await this.#tools.accept(event); + break; default: break; } @@ -77,19 +95,45 @@ export class AcpSessionEventMapper { } replaceTranscript(turnId: string, messages: readonly StoredMessage[]): Promise { + return this.acceptTranscriptMessages(turnId, messages); + } + + /** Apply a bounded authoritative batch; absence from one batch never removes a tool. */ + acceptTranscriptMessages(turnId: string, messages: readonly StoredMessage[]): Promise { return this.#enqueue(async () => { - if (this.#failure) throw this.#failure; for (const message of messages) { - if (message.turnId !== turnId || message.type !== 'assistant') continue; - await this.#acceptText('thinking', message.id, message.thinking?.text ?? ''); - await this.#acceptText('text', message.id, message.text); + if (message.turnId !== turnId) continue; + if (message.type === 'assistant') { + await this.#acceptText('thinking', message.id, message.thinking?.text ?? ''); + await this.#acceptText('text', message.id, message.text); + } else await this.#tools.acceptMessage(message); } }); } + finishTools( + turnId: string, + terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed', + ): Promise { + return this.#enqueue(() => this.#tools.finishTools(turnId, terminalStatus)); + } + + pendingInteraction(pending: InteractionPendingSnapshot): Promise { + return this.#enqueue(() => this.#tools.pendingInteraction(pending)); + } + + resolvedInteraction( + resolved: InteractionSnapshot, + pending: InteractionPendingSnapshot, + ): Promise { + return this.#enqueue(() => this.#tools.resolvedInteraction(resolved, pending)); + } + /** Waits until every notification already accepted by this mapper has settled. */ flush(): Promise { - return this.#tail.then(() => undefined); + return this.#tail.then(() => { + if (this.#failed) throw this.#failure; + }); } async #acceptText(kind: StreamKind, hostMessageId: string, nextText: string): Promise { @@ -111,11 +155,27 @@ export class AcpSessionEventMapper { content: { type: 'text', text: chunk }, messageId: hostMessageId, }; - await this.#notify({ sessionId: this.#sessionId, update }); + await this.#deliver({ sessionId: this.#sessionId, update }); + } + + async #deliver(notification: SessionNotification): Promise { + if (this.#signal?.aborted) return; + const delivery = this.#notify(notification); + if (!this.#signal) return delivery; + await whileActive(delivery, this.#signal); } #enqueue(operation: () => Promise): Promise { - const result = this.#tail.then(operation, operation); + const result = this.#tail.then(async () => { + if (this.#failed) throw this.#failure; + try { + return await operation(); + } catch (error) { + this.#failure = error; + this.#failed = true; + throw error; + } + }); this.#tail = result.then( () => undefined, () => undefined, diff --git a/packages/cli/src/acp/session-interactions.ts b/packages/cli/src/acp/session-interactions.ts new file mode 100644 index 0000000000..b17065644c --- /dev/null +++ b/packages/cli/src/acp/session-interactions.ts @@ -0,0 +1,621 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { + RequestError, + type ClientCapabilities, + type CreateElicitationRequest, + type CreateElicitationResponse, + type ElicitationPropertySchema, + type RequestPermissionRequest, + type RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; +import { + decodeInteractionAnswer, + isInteractionAnswerValidForRequest, + INTERACTION_ANSWER_MAX_BYTES, + type InteractionAnswer, + type InteractionFormField, +} from '@maka/core/interaction'; +import { RuntimeHostOperationError, type RuntimeHostConnection } from '@maka/runtime-host/client'; +import { + INTERACTION_MAX_PENDING_PER_SESSION, + type InteractionAnsweredSnapshot, + type InteractionPendingSnapshot, + type InteractionResolvedSnapshot, + type InteractionSnapshot, +} from '@maka/runtime-host/protocol'; +import { whileActive } from './active-promise.js'; + +export interface AcpInteractionClient { + readonly capabilities: ClientCapabilities; + requestPermission( + params: RequestPermissionRequest, + signal: AbortSignal, + ): Promise; + createElicitation( + params: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; +} + +export interface AcpSessionInteractionsOptions { + readonly sessionId: string; + readonly connection: Pick; + readonly client: AcpInteractionClient; + /** Ensures the associated tool card is visible before requesting input. */ + readonly onPending: (pending: InteractionPendingSnapshot) => Promise; + readonly onAnswered: ( + answered: InteractionAnsweredSnapshot, + pending: InteractionPendingSnapshot, + ) => void; + readonly onResolved: ( + resolved: InteractionResolvedSnapshot, + pending: InteractionPendingSnapshot, + ) => Promise | void; + readonly onFailure: (pending: InteractionPendingSnapshot, error: RequestError) => void; + readonly onCancelled: (pending: InteractionPendingSnapshot) => void; +} + +interface PendingInteraction { + readonly snapshot: InteractionPendingSnapshot; + readonly cancellation: AbortController; + task: Promise; +} + +interface PermissionPresentation { + readonly request: RequestPermissionRequest; + readonly answers: ReadonlyMap; +} + +/** Connection-local presentation of Host interactions; the Host owns every answer and grant. */ +export class AcpSessionInteractions { + readonly #options: AcpSessionInteractionsOptions; + readonly #lifetime = new AbortController(); + readonly #pending = new Map(); + readonly #resolving = new Map>(); + readonly #published = new Set(); + readonly #cancelledTurns = new Set(); + readonly #cancelledInteractions = new Set(); + + constructor(options: AcpSessionInteractionsOptions) { + this.#options = options; + } + + pending(snapshot: InteractionPendingSnapshot): Promise { + if (this.#lifetime.signal.aborted) { + return Promise.resolve(); + } + if ( + this.#cancelledTurns.has(snapshot.turnId) || + this.#cancelledInteractions.has(snapshot.interactionId) + ) { + this.#cancelledInteractions.add(snapshot.interactionId); + return Promise.resolve(); + } + const existing = this.#pending.get(snapshot.interactionId); + if (existing) return existing.task; + if (snapshot.sessionId !== this.#options.sessionId) { + this.#fail(snapshot, interactionError(snapshot, 'invalid_interaction', 'Wrong Session')); + return Promise.resolve(); + } + if (this.#pending.size >= INTERACTION_MAX_PENDING_PER_SESSION) { + this.#fail(snapshot, interactionError(snapshot, 'interaction_capacity_exceeded')); + return Promise.resolve(); + } + const entry: PendingInteraction = { + snapshot, + cancellation: new AbortController(), + task: Promise.resolve(), + }; + this.#pending.set(snapshot.interactionId, entry); + entry.task = this.#present(entry) + .catch((error: unknown) => { + if (!entry.cancellation.signal.aborted) this.#fail(snapshot, error); + }) + .finally(() => { + if (this.#pending.get(snapshot.interactionId) === entry) { + this.#pending.delete(snapshot.interactionId); + } + }); + return entry.task; + } + + resolved(pending: InteractionPendingSnapshot): Promise { + if (pending.sessionId !== this.#options.sessionId) { + this.#fail(pending, interactionError(pending, 'invalid_interaction', 'Wrong Session')); + return Promise.resolve(); + } + this.#retire(pending.interactionId); + if (this.#lifetime.signal.aborted || this.#published.has(pending.interactionId)) { + this.#retireCancelledInteraction(pending); + return Promise.resolve(); + } + const existing = this.#resolving.get(pending.interactionId); + if (existing) return existing; + if (this.#resolving.size >= INTERACTION_MAX_PENDING_PER_SESSION) { + this.#fail(pending, interactionError(pending, 'interaction_capacity_exceeded')); + this.#retireCancelledInteraction(pending); + return Promise.resolve(); + } + const task = this.#readResolved(pending) + .catch((error: unknown) => this.#fail(pending, error)) + .finally(() => { + if (this.#resolving.get(pending.interactionId) === task) { + this.#resolving.delete(pending.interactionId); + } + this.#retireCancelledInteraction(pending); + }); + this.#resolving.set(pending.interactionId, task); + return task; + } + + cancelTurn(turnId: string): void { + this.#cancelledTurns.add(turnId); + for (const entry of this.#pending.values()) { + if (entry.snapshot.turnId !== turnId) continue; + this.#cancelledInteractions.add(entry.snapshot.interactionId); + this.#retire(entry.snapshot.interactionId); + } + } + + /** Retires local presentation work after a Turn settles without cancelling another Turn. */ + settleTurn(turnId: string): void { + for (const entry of this.#pending.values()) { + if (entry.snapshot.turnId === turnId) this.#retire(entry.snapshot.interactionId); + } + this.#cancelledTurns.delete(turnId); + } + + close(): void { + if (this.#lifetime.signal.aborted) return; + this.#lifetime.abort(); + for (const id of this.#pending.keys()) this.#retire(id); + this.#resolving.clear(); + this.#published.clear(); + this.#cancelledTurns.clear(); + this.#cancelledInteractions.clear(); + } + + async #present(entry: PendingInteraction): Promise { + const pending = entry.snapshot; + const signal = entry.cancellation.signal; + // A replayed pending snapshot can outlive an answer. Re-query the existing + // authority before opening another dialog rather than retaining an unbounded history. + const queried = await whileActive(this.#query(pending), signal); + if (!queried.active) return; + assertSameInteraction(pending, queried.value); + if (queried.value.status !== 'pending') { + await this.#publishResolved(queried.value, pending, signal); + return; + } + const request = pending.request; + if ( + (request.kind === 'question' || request.kind === 'form') && + this.#options.client.capabilities.elicitation?.form == null + ) { + throw interactionError( + pending, + 'unsupported_interaction', + 'Client requires form elicitation', + ); + } + const presented = await whileActive( + Promise.resolve().then(() => { + if (!signal.aborted) return this.#options.onPending(pending); + }), + signal, + ); + if (!presented.active) return; + + let answer: InteractionAnswer; + if (request.kind === 'question' || request.kind === 'form') { + const response = await whileActive( + this.#options.client.createElicitation(elicitationRequest(pending), signal), + signal, + ); + if (!response.active) return; + if (request.kind === 'question' && response.value.action === 'cancel') { + this.#cancelPendingTurn(pending); + return; + } + try { + answer = elicitationAnswer(pending, response.value); + } catch { + throw interactionError(pending, 'invalid_interaction_answer'); + } + } else { + const presentation = permissionPresentation(pending); + const response = await whileActive( + this.#options.client.requestPermission(presentation.request, signal), + signal, + ); + if (!response.active) return; + if (response.value.outcome.outcome === 'cancelled') { + this.#cancelPendingTurn(pending); + return; + } + const optionId = response.value.outcome.optionId; + const selected = presentation.answers.get(optionId); + if (!selected) { + throw interactionError(pending, 'invalid_interaction_answer', 'Unknown permission option'); + } + answer = selected; + } + try { + answer = decodeInteractionAnswer(answer); + if (!isInteractionAnswerValidForRequest(pending.request, answer)) throw new Error(); + } catch { + throw interactionError(pending, 'invalid_interaction_answer'); + } + if (signal.aborted) return; + try { + const answered = await whileActive( + this.#options.connection.request('interaction.answer', { + sessionId: pending.sessionId, + interactionId: pending.interactionId, + answer, + }), + signal, + ); + if (answered.active) await this.#publishResolved(answered.value, pending, signal); + } catch (error) { + if (!(error instanceof RuntimeHostOperationError) || error.code !== 'already_resolved') { + throw error; + } + await this.#readResolved(pending, signal); + } + } + + #query(pending: InteractionPendingSnapshot): Promise { + return this.#options.connection.request('interaction.query', { + sessionId: pending.sessionId, + interactionId: pending.interactionId, + }); + } + + async #readResolved( + pending: InteractionPendingSnapshot, + signal = this.#lifetime.signal, + ): Promise { + const active = AbortSignal.any([signal, this.#lifetime.signal]); + const queried = await whileActive(this.#query(pending), active); + if (!queried.active) return; + assertSameInteraction(pending, queried.value); + if (queried.value.status !== 'pending') { + await this.#publishResolved(queried.value, pending, active); + } + } + + async #publishResolved( + resolved: InteractionResolvedSnapshot, + pending: InteractionPendingSnapshot, + signal = this.#lifetime.signal, + ): Promise { + const active = AbortSignal.any([signal, this.#lifetime.signal]); + if (active.aborted || this.#published.has(pending.interactionId)) return; + assertSameInteraction(pending, resolved); + this.#published.add(pending.interactionId); + if (this.#published.size > INTERACTION_MAX_PENDING_PER_SESSION) { + this.#published.delete(this.#published.values().next().value!); + } + if (resolved.status === 'answered') this.#options.onAnswered(resolved, pending); + await whileActive( + Promise.resolve().then(() => { + if (!active.aborted) return this.#options.onResolved(resolved, pending); + }), + active, + ); + } + + #retire(interactionId: string): void { + const entry = this.#pending.get(interactionId); + this.#pending.delete(interactionId); + entry?.cancellation.abort(); + } + + #retireCancelledInteraction(pending: InteractionPendingSnapshot): void { + this.#cancelledInteractions.delete(pending.interactionId); + } + + #cancelPendingTurn(pending: InteractionPendingSnapshot): void { + this.cancelTurn(pending.turnId); + this.#options.onCancelled(pending); + } + + #fail(pending: InteractionPendingSnapshot, error: unknown): void { + if (this.#lifetime.signal.aborted) return; + let failure: RequestError; + if (error instanceof RuntimeHostOperationError) { + failure = RequestError.internalError( + { source: 'runtime_host', operation: error.operation, code: error.code }, + 'Runtime Host interaction failed', + ); + } else if (error instanceof RequestError && error.code !== -32601) { + failure = error; + } else { + failure = interactionError( + pending, + error instanceof RequestError ? 'unsupported_interaction' : 'interaction_failed', + ); + } + this.#options.onFailure(pending, failure); + } +} + +function elicitationRequest(pending: InteractionPendingSnapshot): CreateElicitationRequest { + const request = pending.request; + if (request.kind !== 'question' && request.kind !== 'form') throw new Error('Not a form'); + const properties: Record = Object.create(null); + const required: string[] = []; + if (request.kind === 'question') { + request.questions.forEach((question, index) => { + properties[`q${index}`] = { + type: 'string', + title: question.question, + description: [ + ...question.options.map((option) => + option.description ? `${option.label}: ${option.description}` : option.label, + ), + 'Enter an option or your own answer. Leave empty to skip this question.', + ].join('\n'), + maxLength: INTERACTION_ANSWER_MAX_BYTES, + }; + }); + } else { + for (const field of request.fields) { + properties[field.name] = formProperty(field); + if (field.required) required.push(field.name); + } + } + return { + sessionId: pending.sessionId, + toolCallId: request.toolUseId, + mode: 'form', + message: + request.kind === 'question' + ? 'Please answer the following questions.' + : `${request.requester.name}${request.requester.source ? ` (${request.requester.source})` : ''}: ${request.message}`, + requestedSchema: { type: 'object', properties, required }, + }; +} + +function formProperty(field: InteractionFormField): ElicitationPropertySchema { + const base = { + title: field.label, + ...(field.description === undefined ? {} : { description: field.description }), + ...(field.default === undefined ? {} : { default: structuredClone(field.default) }), + }; + switch (field.kind) { + case 'string': + return { + ...base, + type: 'string', + ...(field.minLength === undefined ? {} : { minLength: field.minLength }), + ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }), + ...(field.format === undefined ? {} : { format: field.format }), + }; + case 'number': + return { + ...base, + type: 'number', + ...(field.minimum === undefined ? {} : { minimum: field.minimum }), + ...(field.maximum === undefined ? {} : { maximum: field.maximum }), + }; + case 'integer': + return { + ...base, + type: 'integer', + minimum: Math.max( + Number.MIN_SAFE_INTEGER, + Math.ceil(field.minimum ?? Number.MIN_SAFE_INTEGER), + ), + maximum: Math.min( + Number.MAX_SAFE_INTEGER, + Math.floor(field.maximum ?? Number.MAX_SAFE_INTEGER), + ), + }; + case 'boolean': + return { ...base, type: 'boolean' }; + case 'single_select': + return { + ...base, + type: 'string', + oneOf: field.options.map((option) => ({ const: option.value, title: option.label })), + }; + case 'multi_select': + return { + ...base, + type: 'array', + items: { + anyOf: field.options.map((option) => ({ const: option.value, title: option.label })), + }, + ...(field.minItems === undefined ? {} : { minItems: field.minItems }), + ...(field.maxItems === undefined ? {} : { maxItems: field.maxItems }), + }; + } +} + +function elicitationAnswer( + pending: InteractionPendingSnapshot, + response: CreateElicitationResponse, +): InteractionAnswer { + const request = pending.request; + const action = response.action; + if (action !== 'accept' && action !== 'decline' && action !== 'cancel') { + throw interactionError(pending, 'invalid_interaction_answer', 'Unknown elicitation action'); + } + if (request.kind === 'form') { + return decodeInteractionAnswer( + action === 'accept' + ? { kind: 'form', action, values: response.content ?? {} } + : { kind: 'form', action }, + ); + } + if (request.kind !== 'question') throw new Error('Not a question'); + if (response.action === 'cancel') { + throw interactionError(pending, 'invalid_interaction_answer', 'Question cancellation escaped'); + } + if (response.action === 'decline') { + return { kind: 'question', answers: request.questions.map(() => null) }; + } + if (response.action !== 'accept') { + throw interactionError(pending, 'invalid_interaction_answer', 'Unknown elicitation action'); + } + const content = response.content ?? {}; + if ( + typeof content !== 'object' || + Array.isArray(content) || + Object.keys(content).some((key) => !request.questions.some((_, index) => key === `q${index}`)) + ) { + throw interactionError(pending, 'invalid_interaction_answer', 'Invalid question fields'); + } + return { + kind: 'question', + answers: request.questions.map((_, index) => { + const value = (content as Record)[`q${index}`]; + if (value === undefined) return null; + if (typeof value !== 'string') { + throw interactionError( + pending, + 'invalid_interaction_answer', + 'Question answer is not text', + ); + } + return value.trim() || null; + }), + }; +} + +function permissionPresentation(pending: InteractionPendingSnapshot): PermissionPresentation { + const request = pending.request; + if ( + request.kind !== 'permission' && + request.kind !== 'sandbox_boundary' && + request.kind !== 'client_capability' + ) { + throw new Error('Not a permission request'); + } + const allow = randomUUID(); + const deny = randomUUID(); + if (request.kind === 'permission') { + const canRemember = + request.prompt.kind === 'tool_permission' && request.prompt.rememberForTurnAllowed; + const options: RequestPermissionRequest['options'] = [ + { optionId: allow, name: 'Allow once', kind: 'allow_once' }, + ]; + const answers = new Map([ + [allow, { kind: 'permission', decision: 'allow', rememberForTurn: false }], + ]); + if (canRemember) { + const remember = randomUUID(); + options.push({ optionId: remember, name: 'Allow for this Turn', kind: 'allow_always' }); + answers.set(remember, { kind: 'permission', decision: 'allow', rememberForTurn: true }); + } + options.push({ optionId: deny, name: 'Reject', kind: 'reject_once' }); + answers.set(deny, { kind: 'permission', decision: 'deny', rememberForTurn: false }); + return { + request: { + sessionId: pending.sessionId, + toolCall: { + toolCallId: request.toolUseId, + title: `Authorize ${request.prompt.toolName}`, + status: 'pending', + content: [ + { + type: 'content', + content: { + type: 'text', + text: `Review this exact permission request:\n${JSON.stringify(request.prompt, null, 2)}`, + }, + }, + ], + }, + options, + }, + answers, + }; + } + const boundary = request.kind === 'sandbox_boundary'; + return { + request: { + sessionId: pending.sessionId, + toolCall: { + toolCallId: boundary ? pending.interactionId : request.toolUseId, + title: boundary + ? 'Expand this Session’s sandbox boundary' + : 'Authorize a Session capability', + status: 'pending', + content: [ + { + type: 'content', + content: { + type: 'text', + text: boundary + ? `${request.justification}\n\nApply only this requested expansion to this Session’s boundary:\n${JSON.stringify(request.expansion, null, 2)}` + : `Grant only the following exact capability target for this Session:\n${JSON.stringify(request.target, null, 2)}`, + }, + }, + ], + }, + options: [ + { + optionId: allow, + name: boundary + ? 'Apply this expansion to this Session' + : 'Allow this scope for this Session', + kind: 'allow_always', + }, + { optionId: deny, name: 'Reject', kind: 'reject_once' }, + ], + }, + answers: new Map([ + [allow, { kind: request.kind, decision: 'allow' }], + [deny, { kind: request.kind, decision: 'deny' }], + ]), + }; +} + +function assertSameInteraction( + pending: InteractionPendingSnapshot, + actual: InteractionSnapshot, +): void { + if ( + pending.interactionId !== actual.interactionId || + pending.sessionId !== actual.sessionId || + pending.turnId !== actual.turnId || + pending.runId !== actual.runId || + !isDeepStrictEqual(pending.request, actual.request) + ) { + throw interactionError(pending, 'invalid_interaction', 'Host interaction identity changed'); + } +} + +function interactionError( + pending: InteractionPendingSnapshot, + code: string, + detail?: string, +): RequestError { + return RequestError.internalError( + { source: 'adapter', code, kind: pending.request.kind, interactionId: pending.interactionId }, + detail ? `ACP interaction failed: ${detail}` : 'ACP interaction failed', + ); +} diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts new file mode 100644 index 0000000000..bd484846cd --- /dev/null +++ b/packages/cli/src/acp/session-mcp.ts @@ -0,0 +1,218 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isAbsolute } from 'node:path'; +import { RequestError, type NewSessionRequest } from '@agentclientprotocol/sdk'; +import { MCP_CONFIG_VERSION, type McpConfigFile } from '@maka/core/mcp'; +import { McpClientManager } from '@maka/mcp'; +import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; +import type { + RuntimeHostConnectionAvailability, + RuntimeHostReconnectingConnection, +} from '@maka/runtime-host/client'; +import { abortable } from '@maka/runtime-host/client'; +import { createMcpCapabilityProvider } from '../mcp-capability-provider.js'; +import { McpCapabilityPublication } from '../mcp-capability-publication.js'; + +export type AcpMcpConnection = Pick< + RuntimeHostReconnectingConnection, + 'replaceClientCapabilities' | 'unregisterClientCapabilities' | 'subscribeConnectionAvailability' +>; + +/** Validates before any process or Host work; never writes a user MCP configuration. */ +export function createAcpMcpConfig(params: NewSessionRequest): McpConfigFile { + const servers: Record = Object.create(null); + if (!Array.isArray(params.mcpServers)) throw invalidMcpInput('invalid_servers'); + for (const server of params.mcpServers) { + if (!server || typeof server !== 'object' || 'type' in server || !('command' in server)) { + throw invalidMcpInput('unsupported_transport'); + } + if (typeof server.name !== 'string' || Object.hasOwn(servers, server.name)) { + throw invalidMcpInput('duplicate_or_invalid_name'); + } + if ( + typeof server.command !== 'string' || + !isAbsolute(server.command) || + server.command.includes('\0') + ) { + throw invalidMcpInput('command_must_be_absolute'); + } + if ( + !Array.isArray(server.args) || + server.args.some((arg) => typeof arg !== 'string' || arg.includes('\0')) + ) { + throw invalidMcpInput('invalid_arguments'); + } + if (!Array.isArray(server.env)) throw invalidMcpInput('invalid_environment'); + const env: Record = Object.create(null); + for (const variable of server.env) { + if ( + !variable || + typeof variable.name !== 'string' || + variable.name.length === 0 || + variable.name.includes('\0') || + variable.name.includes('=') || + Object.hasOwn(env, variable.name) || + typeof variable.value !== 'string' || + variable.value.includes('\0') + ) { + throw invalidMcpInput('duplicate_or_invalid_environment'); + } + env[variable.name] = variable.value; + } + servers[server.name] = { + command: server.command, + args: server.args, + env, + cwd: params.cwd, + protocol: 'auto', + }; + } + try { + return normalizeMcpConfig({ version: MCP_CONFIG_VERSION, mcpServers: servers }); + } catch { + throw invalidMcpInput('invalid_configuration'); + } +} + +/** Owns one Session's in-memory MCP processes and publication on the shared Host connection. */ +export class AcpSessionMcp { + readonly #sessionId: string; + readonly #config: McpConfigFile; + readonly #manager = new McpClientManager({ + clientName: 'maka-acp', + excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'], + }); + readonly #publication: McpCapabilityPublication; + readonly #unsubscribeManager: () => void; + readonly #unsubscribeConnection: () => void; + #availability: RuntimeHostConnectionAvailability | undefined; + #prepared = false; + #closed = false; + #closeTask: Promise | undefined; + + constructor(sessionId: string, config: McpConfigFile, connection: AcpMcpConnection) { + this.#sessionId = sessionId; + this.#config = config; + this.#publication = new McpCapabilityPublication({ + connectionIdentity: () => + this.#availability?.kind === 'connected' + ? this.#availability.hostEpoch + '\0' + this.#availability.connectionId + : undefined, + revision: () => this.#manager.toolSnapshot().revision, + createProvider: () => + createMcpCapabilityProvider(this.#manager, { + admission: 'mcp', + onCurrentRegistrationRetired: () => this.#retire(), + }), + replace: (provider) => connection.replaceClientCapabilities(provider, { sessionId }), + unregister: () => connection.unregisterClientCapabilities({ sessionId }), + onState: () => undefined, + }); + this.#unsubscribeManager = this.#manager.onChange(() => { + if (this.#prepared && !this.#closed) this.#publication.request(); + }); + this.#unsubscribeConnection = connection.subscribeConnectionAvailability((availability) => { + this.#availability = availability; + if (availability.kind !== 'connected') this.#publication.invalidate(); + if (this.#prepared && !this.#closed) this.#publication.request(); + }); + } + + async prepare(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const cancel = () => { + void this.close().catch(() => undefined); + }; + signal?.addEventListener('abort', cancel, { once: true }); + try { + await this.#manager.sync(this.#config); + signal?.throwIfAborted(); + this.#assertConnected(); + this.#prepared = true; + await this.ready(signal); + } catch (error) { + await this.close().catch(() => undefined); + if (error instanceof RequestError) throw error; + throw mcpUnavailable(this.#sessionId, 'mcp_preparation_failed'); + } finally { + signal?.removeEventListener('abort', cancel); + } + } + + async ready(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + this.#assertConnected(); + const state = await abortable(() => this.#publication.settle(), signal); + signal?.throwIfAborted(); + this.#assertConnected(); + if (state !== 'published' && state !== 'not_published') { + throw mcpUnavailable(this.#sessionId, 'mcp_publication_failed'); + } + } + + close(): Promise { + return this.#close(true); + } + + #retire(): Promise { + return this.#close(false); + } + + #close(unregister: boolean): Promise { + if (this.#closeTask) return this.#closeTask; + this.#closed = true; + this.#unsubscribeManager(); + this.#unsubscribeConnection(); + const managerClose = this.#manager.close(); + this.#closeTask = (async () => { + try { + await (unregister ? this.#publication.close() : this.#publication.retire()); + } finally { + await managerClose; + } + })(); + return this.#closeTask; + } + + #assertConnected(): void { + if ( + this.#closed || + Object.keys(this.#config.mcpServers).some( + (serverId) => this.#manager.status(serverId)?.state !== 'connected', + ) + ) { + throw mcpUnavailable(this.#sessionId, 'mcp_not_ready'); + } + } +} + +function invalidMcpInput(reason: string): RequestError { + return RequestError.invalidParams( + { field: 'mcpServers', reason }, + 'Invalid ACP stdio MCP configuration', + ); +} + +function mcpUnavailable(sessionId: string, code: string): RequestError { + return RequestError.internalError( + { source: 'adapter', operation: 'mcp.prepare', sessionId, code }, + 'Session MCP tools are unavailable', + ); +} diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index eb9f2518d4..d8a702a422 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -38,6 +38,7 @@ import { type StopReason, } from '@agentclientprotocol/sdk'; import type { SessionEvent } from '@maka/core/events'; +import type { McpConfigFile } from '@maka/core/mcp'; import { isRuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { readRuntimeHostConnectionCatalog, @@ -72,6 +73,8 @@ import { } from './session-configuration.js'; import { AcpSessionEventMapper } from './session-event-mapper.js'; import { mapAcpPromptContent, publishAcpPromptAttachments } from './prompt-content.js'; +import { AcpSessionMcp, createAcpMcpConfig, type AcpMcpConnection } from './session-mcp.js'; +import { AcpSessionInteractions, type AcpInteractionClient } from './session-interactions.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; const ADMISSION_QUERY_MAX_ATTEMPTS = 5; @@ -94,13 +97,19 @@ type AcpSessionRegistryLifecycleOperation = export interface AcpSessionRegistryConnection extends Pick< - RuntimeHostReconnectingConnection, - 'reconnecting' | 'request' | 'openSessionSubscription' | 'openSessionSubscriptionOnce' | 'close' - > {} + RuntimeHostReconnectingConnection, + | 'reconnecting' + | 'request' + | 'openSessionSubscription' + | 'openSessionSubscriptionOnce' + | 'close' + >, + AcpMcpConnection {} export interface AcpPromptContext { readonly signal: AbortSignal; readonly notify: (notification: SessionNotification) => Promise; + readonly interactions?: AcpInteractionClient; } export interface AcpSessionRegistryOptions { @@ -123,6 +132,10 @@ interface ActiveAcpPrompt { readonly mapper: AcpSessionEventMapper; readonly waiters: Set<() => void>; attachment?: RuntimeHostSessionChannel; + transcript?: ReturnType; + readonly projectionAbort: AbortController; + readonly reconciliationAbort: AbortController; + projectionFailure?: unknown; dispatchStarted: boolean; startRequestSettled: boolean; admissionSettled: boolean; @@ -141,6 +154,9 @@ export class AcpSessionRegistry { readonly #newTurnId: () => string; readonly #inFlightOperations = new Set>(); readonly #ownedSessionIds = new Set(); + readonly #mcps = new Map(); + readonly #creationAbort = new AbortController(); + readonly #attachmentInteractions = new Map(); readonly #attachments = new Map>(); readonly #attachmentOpenControllers = new Map(); readonly #attachmentConfigurations = new Map(); @@ -160,10 +176,11 @@ export class AcpSessionRegistry { this.#newTurnId = options.newTurnId ?? randomUUID; } - async create(params: NewSessionRequest): Promise { + async create(params: NewSessionRequest, signal?: AbortSignal): Promise { this.#assertOpen('session.create'); validateNewSessionParams(params); - return this.#track(this.#create(params)); + const mcpConfig = createAcpMcpConfig(params); + return this.#track(this.#create(params, mcpConfig, signal)); } async list(params: ListSessionsRequest): Promise { @@ -242,12 +259,15 @@ export class AcpSessionRegistry { dispose(): Promise { this.#closing = true; this.#connectAbortController?.abort(); + this.#creationAbort.abort(); this.#disposeTask ??= this.#dispose(); return this.#disposeTask; } async #prompt(params: PromptRequest, context: AcpPromptContext): Promise { const turnId = this.#newTurnId(); + const projectionAbort = new AbortController(); + const reconciliationAbort = new AbortController(); const active: ActiveAcpPrompt = { sessionId: params.sessionId, turnId, @@ -258,8 +278,11 @@ export class AcpSessionRegistry { await context.notify(notification); } }, + signal: projectionAbort.signal, }), waiters: new Set(), + projectionAbort, + reconciliationAbort, dispatchStarted: false, startRequestSettled: false, admissionSettled: false, @@ -292,7 +315,7 @@ export class AcpSessionRegistry { const connection = await this.#getConnection('subscription.open'); let attachment: RuntimeHostSessionChannel; try { - attachment = await this.#ensureAttachment(params.sessionId, connection, context.notify); + attachment = await this.#ensureAttachment(params.sessionId, connection, context); } catch (error) { if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; throw error; @@ -320,6 +343,9 @@ export class AcpSessionRegistry { } if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + await this.#mcps.get(params.sessionId)?.ready(active.projectionAbort.signal); + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + active.transcript = attachment.trackPromptTranscript(turnId); const observation = this.#consumePromptEvents(active, attachment.eventsForTurn(turnId)); // Mark the observer as handled immediately: turn.start may still be in flight // when the live subscription reports a failure. @@ -371,6 +397,10 @@ export class AcpSessionRegistry { // A terminal subscription event can precede the Stop response. Retain this // prompt so close/dispose cannot release its connection while Stop is in flight. await active.stopTask?.catch(() => undefined); + active.projectionAbort.abort(); + active.reconciliationAbort.abort(); + active.transcript?.dispose(); + this.#attachmentInteractions.get(active.sessionId)?.settleTurn(active.turnId); active.finished = true; this.#wake(active); this.#removeActivePrompt(active); @@ -381,10 +411,20 @@ export class AcpSessionRegistry { active: ActiveAcpPrompt, events: AsyncIterable, ): Promise { + let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; try { for await (const event of events) { + if (event.type === 'abort') terminalStatus = 'cancelled'; + else if (event.type === 'error' && !event.recoverable) terminalStatus = 'failed'; + if (terminalStatus !== 'completed') active.reconciliationAbort.abort(); if (!active.cancelled) await active.mapper.accept(event); } + if (active.cancelled) return this.#cancelledStopReason(active); + if (terminalStatus === 'completed') await this.#reconcilePrompt(active); + else active.reconciliationAbort.abort(); + if (active.projectionFailure) throw active.projectionFailure; + await active.mapper.finishTools(active.turnId, terminalStatus); + await active.mapper.flush(); return active.cancelled ? this.#cancelledStopReason(active) : 'end_turn'; } catch (error) { if (active.cancelled) return this.#cancelledStopReason(active); @@ -392,6 +432,27 @@ export class AcpSessionRegistry { } } + async #reconcilePrompt(active: ActiveAcpPrompt): Promise { + if (active.cancelled || active.finished || !active.transcript) return; + try { + await active.transcript.reconcile( + (messages) => active.mapper.acceptTranscriptMessages(active.turnId, messages), + AbortSignal.any([active.projectionAbort.signal, active.reconciliationAbort.signal]), + ); + } catch (error) { + if ( + active.cancelled || + active.finished || + active.projectionAbort.signal.aborted || + active.reconciliationAbort.signal.aborted + ) + return; + active.projectionFailure ??= error; + active.attachment?.failTurn(active.turnId, error); + throw error; + } + } + async #cancelledStopReason(active: ActiveAcpPrompt): Promise<'cancelled'> { await active.mapper.flush(); return 'cancelled'; @@ -438,6 +499,9 @@ export class AcpSessionRegistry { ) { this.#attachmentOpenControllers.get(active.sessionId)?.abort(); } + active.projectionAbort.abort(); + active.reconciliationAbort.abort(); + this.#attachmentInteractions.get(active.sessionId)?.cancelTurn(active.turnId); active.stopTask ??= this.#stopPromptWhenObservable(active); await Promise.all([ active.mapper.flush(), @@ -549,14 +613,14 @@ export class AcpSessionRegistry { async #ensureAttachment( sessionId: string, connection: AcpSessionRegistryConnection, - notify: AcpPromptContext['notify'], + context: AcpPromptContext, ): Promise { const existing = this.#attachments.get(sessionId); if (existing) return existing; const openingController = new AbortController(); this.#attachmentOpenControllers.set(sessionId, openingController); const configuration: AcpAttachmentConfiguration = { - notify, + notify: context.notify, // Setters can outlive an absent or failed attachment. Their responses // must precede refreshes delivered by the new attachment's queue. tail: Promise.allSettled([...(this.#pendingConfigSets.get(sessionId) ?? [])]), @@ -572,6 +636,50 @@ export class AcpSessionRegistry { } this.#retireFailedAttachment(sessionId, task, attachment, error); }; + const interactions = new AcpSessionInteractions({ + sessionId, + connection, + client: context.interactions ?? { + capabilities: {}, + requestPermission: async () => { + throw RequestError.methodNotFound('session/request_permission'); + }, + createElicitation: async () => { + throw RequestError.methodNotFound('elicitation/create'); + }, + }, + onPending: async (pending) => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.turnId === pending.turnId && !active.cancelled) { + await active.mapper.pendingInteraction(pending); + } + } + }, + onAnswered: (answered, pending) => attachment?.publishInteractionAnswer(answered, pending), + onResolved: async (resolved, pending) => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.turnId === pending.turnId && !active.cancelled && !active.finished) { + await active.mapper.resolvedInteraction(resolved, pending); + } + } + }, + onFailure: (pending, error) => { + const active = [...(this.#activePrompts.get(sessionId) ?? [])].find( + (prompt) => prompt.turnId === pending.turnId && !prompt.finished, + ); + if (active?.attachment) { + active.projectionFailure ??= error; + active.attachment.failTurn(active.turnId, error); + } else if (!attachment) failAttachment(error); + }, + onCancelled: (pending) => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.turnId === pending.turnId) + void this.#cancelPrompt(active).catch(() => undefined); + } + }, + }); + this.#attachmentInteractions.set(sessionId, interactions); task = RuntimeHostSessionChannel.open({ connection, signal: openingController.signal, @@ -612,26 +720,16 @@ export class AcpSessionRegistry { } }, onInteractionPending: (pending) => { - if ( - ![...(this.#activePrompts.get(sessionId) ?? [])].some( - (active) => active.turnId === pending.turnId && active.dispatchStarted, - ) - ) { - // An idle attachment may observe another client's Turn. Retain its - // identity so a later ACP cancel/close can still stop that root. - return; + void interactions.pending(pending); + }, + onInteractionResolved: (pending) => { + void interactions.resolved(pending); + }, + onTranscriptSettlement: (turnId) => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.turnId === turnId) void this.#reconcilePrompt(active).catch(() => undefined); } - // Full interaction mapping belongs to the next ACP capability increment. - // Retire observation so the prompt's existing failure path stops its exact Turn. - failAttachment( - RequestError.internalError( - { source: 'adapter', code: 'unsupported_interaction', kind: pending.request.kind }, - 'This ACP adapter does not support interactions yet; the prompt failed', - ), - ); }, - onInteractionResolved: () => undefined, - onTranscriptSettlement: () => undefined, onGoalChanged: () => undefined, onFailed: failAttachment, onRecovered: () => { @@ -668,6 +766,10 @@ export class AcpSessionRegistry { return channel; }) .catch((error: unknown) => { + interactions.close(); + if (this.#attachmentInteractions.get(sessionId) === interactions) { + this.#attachmentInteractions.delete(sessionId); + } if (this.#attachments.get(sessionId) === task) { this.#attachments.delete(sessionId); this.#attachmentConfigurations.delete(sessionId); @@ -693,6 +795,8 @@ export class AcpSessionRegistry { if (this.#attachments.get(sessionId) === task) { this.#attachments.delete(sessionId); this.#attachmentConfigurations.delete(sessionId); + this.#attachmentInteractions.get(sessionId)?.close(); + this.#attachmentInteractions.delete(sessionId); } for (const active of this.#activePrompts.get(sessionId) ?? []) { if (active.attachment !== attachment) continue; @@ -706,6 +810,8 @@ export class AcpSessionRegistry { async #closeSession(sessionId: string, delivery?: Promise): Promise { const cancellation = await this.#cancelSession(sessionId); + this.#attachmentInteractions.get(sessionId)?.close(); + this.#attachmentInteractions.delete(sessionId); const attachmentTask = this.#attachments.get(sessionId); this.#attachments.delete(sessionId); let closeError: unknown; @@ -718,6 +824,13 @@ export class AcpSessionRegistry { closeError = error; } } + const mcp = this.#mcps.get(sessionId); + this.#mcps.delete(sessionId); + try { + await mcp?.close(); + } catch (error) { + closeError ??= error; + } await delivery; const failedCancellation = cancellation.find( (result): result is PromiseRejectedResult => result.status === 'rejected', @@ -765,17 +878,48 @@ export class AcpSessionRegistry { if (!this.#ownedSessionIds.has(sessionId)) throw unknownSessionError(); } - async #create(params: NewSessionRequest): Promise { + async #create( + params: NewSessionRequest, + mcpConfig: McpConfigFile, + signal?: AbortSignal, + ): Promise { + const lifetime = signal + ? AbortSignal.any([signal, this.#creationAbort.signal]) + : this.#creationAbort.signal; + lifetime.throwIfAborted(); const connection = await this.#getConnection('session.create'); const sessionId = this.#newSessionId(); + let mcp: AcpSessionMcp | undefined; + if (params.mcpServers.length > 0) { + mcp = new AcpSessionMcp(sessionId, mcpConfig, connection); + this.#mcps.set(sessionId, mcp); + } let result; + let dispatched = false; try { + await mcp?.prepare(lifetime); + lifetime.throwIfAborted(); + this.#assertOpen('session.create'); + dispatched = true; result = await connection.request('session.create', { sessionId, workspace: { kind: 'host_path', path: params.cwd }, modelTarget: { kind: 'default' }, }); } catch (error) { + const outcomeUnknown = + dispatched && + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched'; + if (outcomeUnknown && !this.#closing) { + // The error returns this ID. Keep its connection-local reservation usable + // without guessing whether Host committed or resending Session creation. + this.#ownedSessionIds.add(sessionId); + } else { + this.#mcps.delete(sessionId); + await mcp?.close().catch(() => undefined); + } + if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'session.create', { sessionId }); } // Session creation has committed. Optional presentation failures must not @@ -933,6 +1077,8 @@ export class AcpSessionRegistry { ...(this.#activePrompts.get(sessionId) ?? []), ]); const cancellations = [...sessionIds].map((sessionId) => this.#cancelSession(sessionId)); + for (const interactions of this.#attachmentInteractions.values()) interactions.close(); + this.#attachmentInteractions.clear(); const attachments = [...this.#attachments.values()]; this.#attachments.clear(); const configurations = [...this.#attachmentConfigurations.values()]; @@ -962,6 +1108,9 @@ export class AcpSessionRegistry { } } await Promise.allSettled(cancellations); + const mcps = [...this.#mcps.values()]; + this.#mcps.clear(); + await Promise.allSettled(mcps.map((mcp) => mcp.close())); await Promise.allSettled([this.#closeOwnedConnection()]); await Promise.allSettled([ ...this.#inFlightOperations, @@ -1059,12 +1208,6 @@ function registryClosedError(operation: AcpSessionRegistryLifecycleOperation): R function validateNewSessionParams(params: NewSessionRequest): void { assertBoundedAbsoluteCwd(params.cwd); - if (params.mcpServers.length > 0) { - throw RequestError.invalidParams( - { field: 'mcpServers', reason: 'unsupported' }, - 'MCP servers are not supported by this ACP adapter yet', - ); - } if ((params.additionalDirectories?.length ?? 0) > 0) { throw RequestError.invalidParams( { field: 'additionalDirectories', reason: 'unsupported' }, diff --git a/packages/cli/src/acp/stdio-server.ts b/packages/cli/src/acp/stdio-server.ts index 2e09cee081..959ee7ffec 100644 --- a/packages/cli/src/acp/stdio-server.ts +++ b/packages/cli/src/acp/stdio-server.ts @@ -62,6 +62,11 @@ export async function runMakaAcpStdioServer( request: connection.request.bind(connection) as RuntimeHostConnection['request'], openSessionSubscription: connection.openSessionSubscription.bind(connection), openSessionSubscriptionOnce: connection.openSessionSubscriptionOnce.bind(connection), + replaceClientCapabilities: (provider, options) => + connection.replaceClientCapabilities(provider, options), + unregisterClientCapabilities: (options) => connection.unregisterClientCapabilities(options), + subscribeConnectionAvailability: (listener) => + connection.subscribeConnectionAvailability(listener), close: () => context.close(), }; }, diff --git a/packages/cli/src/acp/tool-event-mapper.ts b/packages/cli/src/acp/tool-event-mapper.ts new file mode 100644 index 0000000000..4fb342b7fc --- /dev/null +++ b/packages/cli/src/acp/tool-event-mapper.ts @@ -0,0 +1,503 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { + RequestError, + type SessionUpdate, + type ToolCallContent, + type ToolKind, +} from '@agentclientprotocol/sdk'; +import { + decodeToolStepProgress, + type SessionEvent, + type ToolActivityKind, + type ToolResultContent, +} from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import { projectToolArgsPreview } from '@maka/core/tool-quiet-preview'; +import { toolResultActivityStatus } from '@maka/core/tool-result-status'; +import type { InteractionPendingSnapshot, InteractionSnapshot } from '@maka/runtime-host/protocol'; +import { BoundedChunkBuffer } from '../bounded-chunk-buffer.js'; +import { formatToolResultContent } from '../pi-transcript-format.js'; + +// Presentation limits, not Host execution/admission limits. The per-tool values +// match the existing TUI buffers; the aggregate also bounds concurrent tools. +const TOOL_CHARS = 64 * 1024; +const TOOL_CHUNKS = 512; +const PROMPT_CHARS = 1024 * 1024; +const PROMPT_TOOL_IDENTITIES = 4096; +const AUXILIARY_CHARS = 4096; + +type ToolEvent = Extract< + SessionEvent, + { + type: + | 'tool_start' + | 'tool_output_delta' + | 'tool_progress' + | 'tool_result_preview' + | 'tool_result'; + } +>; +type Output = { seq: number; stream: string; chunk: string; redacted: boolean }; +type ToolStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; +interface ToolState { + id: string; + turnId: string; + title: string; + name?: string; + kind: ToolKind; + status: ToolStatus; + terminal: boolean; + authoritative: boolean; + created: boolean; + output?: BoundedChunkBuffer; + progress?: BoundedChunkBuffer; + inputPreview: string; + preview: string; + meta: Record; + lastDigest?: string; + resultDigest?: string; + callDigest?: string; + retainedChars: number; + synthetic?: boolean; +} + +interface ToolCallProjection { + readonly toolName: string; + readonly displayName?: string; + readonly activityKind?: ToolActivityKind; + readonly args?: unknown; + readonly argsPreview?: unknown; + readonly stepId?: string; + readonly operationId?: string; +} + +/** Tool presentation only. Turn completion remains the Session channel's decision. */ +export class AcpToolEventMapper { + readonly #tools = new Map(); + #retainedChars = 0; + constructor(readonly notify: (update: SessionUpdate) => Promise) {} + + async accept(event: ToolEvent): Promise { + const tool = this.#ensure(event.turnId, event.toolUseId); + switch (event.type) { + case 'tool_start': + await this.#call(tool, event); + return; + case 'tool_output_delta': + if (tool.terminal) return; + tool.output ??= outputBuffer(); + if (!tool.output.append(event)) return; + tool.meta.redacted = tool.meta.redacted === true || event.redacted; + if (event.seq >= ((tool.meta.output as { sequence: number } | undefined)?.sequence ?? -1)) { + tool.meta.output = { + sequence: event.seq, + stream: event.stream, + redacted: event.redacted, + }; + } + break; + case 'tool_progress': + if (tool.terminal) return; + tool.progress ??= progressBuffer(); + tool.progress.append( + typeof event.chunk === 'string' + ? event.chunk + : `[${event.chunk.kind}] ${event.chunk.text}`, + ); + { + const progress = decodeToolStepProgress(event.chunk); + if (progress) tool.meta.progress = progress; + } + break; + case 'tool_result_preview': + if (tool.terminal) return; + tool.preview = bounded(`Preview: ${JSON.stringify(event.content)}`, AUXILIARY_CHARS).text; + break; + case 'tool_result': + if (tool.authoritative && event.contentOmitted) return; + if (event.operationId) tool.meta.operationId = event.operationId; + await this.#result( + tool, + event.isError, + event.content, + event.durationMs, + event.contentOmitted === true, + ); + return; + } + await this.#publish(tool); + } + + async acceptMessage(message: StoredMessage): Promise { + if (message.type === 'tool_call') { + const tool = this.#ensure(message.turnId, message.id); + await this.#call(tool, message); + } else if (message.type === 'tool_result') { + await this.#result( + this.#ensure(message.turnId, message.toolUseId), + message.isError, + message.content, + message.durationMs, + false, + ); + } + } + + async pendingInteraction(pending: InteractionPendingSnapshot): Promise { + const tool = this.#ensure(pending.turnId, interactionToolId(pending)); + if (pending.request.kind === 'sandbox_boundary') tool.synthetic = true; + if (tool.terminal) return; + tool.status = 'pending'; + tool.meta.interaction = { + id: pending.interactionId, + kind: pending.request.kind, + status: 'pending', + }; + if (!tool.name) tool.title = `Awaiting ${pending.request.kind.replaceAll('_', ' ')}`; + await this.#publish(tool); + } + + async resolvedInteraction( + resolved: InteractionSnapshot, + pending: InteractionPendingSnapshot, + ): Promise { + if (resolved.status === 'pending') return; + const tool = this.#ensure(pending.turnId, interactionToolId(pending)); + if (pending.request.kind === 'sandbox_boundary') tool.synthetic = true; + const outcome = resolved.outcome; + tool.meta.interaction = { + id: pending.interactionId, + kind: pending.request.kind, + status: resolved.status, + ...(outcome.kind === 'closure' ? { reason: outcome.reason } : {}), + ...('decision' in outcome ? { decision: outcome.decision } : {}), + ...('action' in outcome ? { action: outcome.action } : {}), + }; + if (!tool.terminal) { + if (pending.request.kind === 'sandbox_boundary') { + tool.terminal = true; + tool.status = + outcome.kind === 'sandbox_boundary_decision' && outcome.decision === 'allow' + ? 'completed' + : 'failed'; + } else tool.status = 'in_progress'; + } + const closure = + outcome.kind === 'closure' ? `Interaction closed: ${outcome.reason}` : undefined; + if (closure && !tool.terminal) { + tool.progress ??= progressBuffer(); + tool.progress.append(closure); + } + await this.#publish( + tool, + closure && pending.request.kind === 'sandbox_boundary' + ? { content: textContent(closure) } + : {}, + ); + if (tool.terminal) this.#release(tool); + } + + /** Called only after authoritative turn settlement and transcript reconciliation. */ + async finishTools( + turnId: string, + terminalStatus: 'completed' | 'failed' | 'cancelled', + ): Promise { + let missingResult = false; + for (const tool of this.#tools.values()) { + if (tool.turnId !== turnId || tool.authoritative) continue; + if (!tool.terminal) { + tool.terminal = true; + tool.status = 'failed'; + tool.meta.hostStatus = 'interrupted'; + await this.#publish(tool, { + content: textContent('Tool interrupted: the turn ended without a result.'), + }); + } + if (terminalStatus === 'completed' && !tool.synthetic) missingResult = true; + this.#release(tool); + } + if (missingResult) + throw projectionError( + 'tool_result_missing', + 'Runtime Host completed the turn without an authoritative tool result', + ); + } + + #ensure(turnId: string, id: string): ToolState { + let tool = this.#tools.get(id); + if (tool && tool.turnId !== turnId) + throw projectionError('tool_identity_changed', 'A tool identity changed its turn'); + if (!tool) { + if (this.#tools.size >= PROMPT_TOOL_IDENTITIES) + throw projectionError( + 'tool_presentation_capacity', + 'ACP tool identity presentation limit exceeded', + ); + tool = { + id, + turnId, + title: id, + kind: 'other', + status: 'in_progress', + terminal: false, + authoritative: false, + created: false, + inputPreview: '', + preview: '', + meta: {}, + retainedChars: 0, + }; + this.#tools.set(id, tool); + } + return tool; + } + + async #result( + tool: ToolState, + isError: boolean, + result: ToolResultContent, + durationMs: number | undefined, + omitted: boolean, + ): Promise { + const resultDigest = digestValue({ + isError, + result: omitted ? null : result, + durationMs, + omitted, + }); + if (tool.resultDigest === resultDigest) return; + tool.terminal = true; + const hostStatus = toolResultActivityStatus(isError, omitted ? undefined : result); + tool.status = hostStatus === 'completed' ? 'completed' : 'failed'; + tool.meta.hostStatus = hostStatus; + if (durationMs !== undefined) tool.meta.durationMs = durationMs; + tool.meta.resultPending = omitted; + if (omitted) { + // content omission is a status signal. Preserve the client's existing content. + await this.#publish(tool); + } else { + tool.authoritative = true; + const presentation = bounded(formatToolResultContent(result), TOOL_CHARS); + const raw = JSON.stringify(result); + tool.meta.truncated = presentation.dropped > 0; + tool.meta.droppedChars = presentation.dropped; + await this.#publish(tool, { + content: textContent(presentation.text), + ...(raw.length <= TOOL_CHARS && presentation.dropped === 0 ? { rawOutput: result } : {}), + }); + } + tool.resultDigest = resultDigest; + this.#release(tool); + } + + async #call(tool: ToolState, call: ToolCallProjection): Promise { + tool.name = call.toolName; + tool.title = bounded(call.displayName ?? call.toolName, AUXILIARY_CHARS).text; + tool.kind = toolKind(call.activityKind); + if (call.operationId) tool.meta.operationId = call.operationId; + if (call.stepId) tool.meta.stepId = call.stepId; + if (!tool.terminal) { + const preview = + call.args === undefined + ? call.argsPreview + : projectToolArgsPreview(call.toolName, call.args); + tool.inputPreview = + preview === undefined ? '' : bounded(JSON.stringify(preview), AUXILIARY_CHARS).text; + } + await this.#publishCall(tool, rawInput(call.toolName, call.args)); + } + + async #publishCall(tool: ToolState, input: { rawInput?: unknown }): Promise { + const digest = digestValue({ + title: tool.title, + name: tool.name, + kind: tool.kind, + input, + preview: tool.inputPreview, + stepId: tool.meta.stepId, + operationId: tool.meta.operationId, + }); + if (tool.callDigest === digest) return; + await this.#publish(tool, input); + tool.callDigest = digest; + } + + #release(tool: ToolState): void { + tool.output = undefined; + tool.progress = undefined; + tool.inputPreview = ''; + tool.preview = ''; + this.#account(tool); + } + + async #publish( + tool: ToolState, + extra: { content?: ToolCallContent[]; rawInput?: unknown; rawOutput?: unknown } = {}, + ): Promise { + const fixedChars = fixedStateChars(tool); + // Keep recent progress and use the remaining per-tool budget for output. + tool.progress?.trimTo(Math.min(AUXILIARY_CHARS, TOOL_CHARS - fixedChars), TOOL_CHUNKS); + tool.output?.trimTo( + TOOL_CHARS - fixedChars - (tool.progress?.charLength ?? 0), + TOOL_CHUNKS - (tool.progress?.length ?? 0), + ); + this.#account(tool); + if (this.#retainedChars > PROMPT_CHARS) + throw projectionError( + 'tool_presentation_capacity', + 'ACP aggregate tool presentation limit exceeded', + ); + const dropped = (tool.output?.droppedChars ?? 0) + (tool.progress?.droppedChars ?? 0); + const content: ToolCallContent[] = []; + if (tool.inputPreview) + content.push(...textContent(`Input preview (not full input): ${tool.inputPreview}`)); + if (dropped) content.push(...textContent(`[${dropped} earlier output characters truncated]`)); + for (const output of tool.output?.values() ?? []) { + content.push({ + type: 'content', + content: { + type: 'text', + text: `[${output.stream}]${output.redacted ? ' [redacted]' : ''} ${output.chunk}`, + _meta: { + maka: { sequence: output.seq, stream: output.stream, redacted: output.redacted }, + }, + }, + }); + } + for (const progress of tool.progress?.values() ?? []) + content.push(...textContent(`Progress: ${progress}`)); + if (tool.preview) content.push(...textContent(tool.preview)); + const update = { + toolCallId: tool.id, + title: tool.title, + kind: tool.kind, + status: tool.status, + _meta: { + maka: { + turnId: tool.turnId, + ...(tool.name ? { toolName: tool.name } : {}), + ...tool.meta, + ...(!tool.terminal ? { truncated: dropped > 0, droppedChars: dropped } : {}), + }, + }, + ...(!tool.terminal ? { content } : {}), + ...extra, + }; + const digest = digestValue(update); + if (tool.lastDigest === digest) return; + await this.notify( + tool.created + ? { sessionUpdate: 'tool_call_update', ...update } + : { sessionUpdate: 'tool_call', ...update }, + ); + tool.created = true; + tool.lastDigest = digest; + } + + #account(tool: ToolState): void { + const chars = + fixedStateChars(tool) + (tool.output?.charLength ?? 0) + (tool.progress?.charLength ?? 0); + this.#retainedChars += chars - tool.retainedChars; + tool.retainedChars = chars; + } +} + +function fixedStateChars(tool: ToolState): number { + return ( + tool.inputPreview.length + + tool.preview.length + + tool.title.length + + (tool.name?.length ?? 0) + + JSON.stringify(tool.meta).length + ); +} + +function digestValue(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function interactionToolId(pending: InteractionPendingSnapshot): string { + return pending.request.kind === 'sandbox_boundary' + ? pending.interactionId + : pending.request.toolUseId; +} + +function rawInput(toolName: string, args: unknown): { rawInput?: unknown } { + // The shared transcript intentionally projects these two tools' arguments. + if (args === undefined || toolName === 'WriteStdin' || toolName === 'todo_write') return {}; + return JSON.stringify(args).length <= TOOL_CHARS ? { rawInput: args } : {}; +} + +function bounded(text: string, max: number): { text: string; dropped: number } { + if (text.length <= max) return { text, dropped: 0 }; + const suffix = '\n[Result truncated]'; + let length = max - suffix.length; + const before = text.charCodeAt(length - 1); + if (before >= 0xd800 && before <= 0xdbff) length -= 1; + return { text: `${text.slice(0, length)}${suffix}`, dropped: text.length - length }; +} + +function textContent(text: string): ToolCallContent[] { + return [{ type: 'content', content: { type: 'text', text } }]; +} + +function outputBuffer(): BoundedChunkBuffer { + return new BoundedChunkBuffer({ + maxChars: TOOL_CHARS, + maxChunks: TOOL_CHUNKS, + textOf: (chunk) => chunk.chunk, + withText: (chunk, text) => ({ ...chunk, chunk: text }), + sequence: (chunk) => chunk.seq, + }); +} + +function progressBuffer(): BoundedChunkBuffer { + return new BoundedChunkBuffer({ + maxChars: TOOL_CHARS, + maxChunks: TOOL_CHUNKS, + textOf: (chunk) => chunk, + withText: (_chunk, text) => text, + }); +} + +function toolKind(kind: ToolActivityKind | undefined): ToolKind { + switch (kind) { + case 'read': + return 'read'; + case 'search': + case 'websearch': + case 'explore': + return 'search'; + case 'webfetch': + return 'fetch'; + case 'edit': + return 'edit'; + case 'command': + return 'execute'; + default: + return 'other'; + } +} + +function projectionError(code: string, message: string): RequestError { + return RequestError.internalError({ source: 'adapter', code }, message); +} diff --git a/packages/cli/src/bounded-chunk-buffer.ts b/packages/cli/src/bounded-chunk-buffer.ts index 581ddba94b..27b1176bc5 100644 --- a/packages/cli/src/bounded-chunk-buffer.ts +++ b/packages/cli/src/bounded-chunk-buffer.ts @@ -45,6 +45,21 @@ export class BoundedChunkBuffer { return this.dropped; } + get charLength(): number { + return this.retainedChars; + } + + /** Share a presentation budget with other buffers without rebuilding sequence history. */ + trimTo(maxChars: number, maxChunks: number): void { + const previousLength = this.length; + const previousChars = this.retainedChars; + this.trim(Math.max(0, maxChars), Math.max(0, maxChunks)); + if (previousLength !== this.length || previousChars !== this.retainedChars) { + this.revision += 1; + this.cachedValues = undefined; + } + } + get version(): number { return this.revision; } @@ -93,8 +108,8 @@ export class BoundedChunkBuffer { this.chunks.splice(index < 0 ? this.chunks.length : index, 0, chunk); } - private trim(): void { - let excess = this.retainedChars - this.options.maxChars; + private trim(maxChars = this.options.maxChars, maxChunks = this.options.maxChunks): void { + let excess = this.retainedChars - maxChars; while (excess > 0 && this.length > 0) { const first = this.chunks[this.head]; if (first === undefined) break; @@ -110,7 +125,7 @@ export class BoundedChunkBuffer { this.dropped += cut; excess = 0; } - while (this.length > this.options.maxChunks) { + while (this.length > maxChunks) { const first = this.chunks[this.head]; if (first === undefined) break; this.dropFirst(first, this.options.textOf(first).length); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 071f83ef17..0d5038c295 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -143,7 +143,7 @@ function helpText(cliCommand: string): string { ...( [ ['', 'Start the TUI'], - ['--acp', 'Serve ACP v1 over stdio (sessions, prompts, streaming, cancellation)'], + ['--acp', 'Serve ACP v1 over stdio (sessions, tools, permissions, forms, stdio MCP)'], ['run ...', 'Run one non-interactive model turn'], ['-p ...', `Alias for ${cliCommand} run`], ['activate ...', 'Run one Cloud Session activation and emit JSONL'], diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts index ca0849514d..281a7130e2 100644 --- a/packages/cli/src/mcp-capability-provider.ts +++ b/packages/cli/src/mcp-capability-provider.ts @@ -35,6 +35,10 @@ const CAPABILITY_VERSION = '0'; export function createMcpCapabilityProvider( manager: Pick, + options: { + readonly admission?: 'mcp'; + readonly onCurrentRegistrationRetired?: () => void | Promise; + } = {}, ): ClientCapabilityProvider | undefined { const toolSnapshot = manager.toolSnapshot(); const tools = [...toolSnapshot.tools].sort( @@ -79,6 +83,7 @@ export function createMcpCapabilityProvider( version: CAPABILITY_VERSION, affinity: 'session', hostPathAccess: 'none', + ...(options.admission ? { admission: options.admission } : {}), label: servers.size === 1 ? `MCP: ${chunk[0]?.source.descriptor.serverId ?? 'tools'}`.slice(0, 128) @@ -95,11 +100,15 @@ export function createMcpCapabilityProvider( } const canonical = decodeClientCapabilityReplaceInput({ registrationId: '00000000-0000-4000-8000-000000000000', + ...(options.admission ? { sessionId: 'mcp-manifest-validation' } : {}), offers, }); return { offers: () => canonical.offers, + ...(options.onCurrentRegistrationRetired + ? { currentRegistrationRetired: options.onCurrentRegistrationRetired } + : {}), call: async (frame, options) => { const binding = bindings.get( capabilityBindingKey(frame.offerId, frame.serverId, frame.toolName), diff --git a/packages/cli/src/mcp-capability-publication.ts b/packages/cli/src/mcp-capability-publication.ts new file mode 100644 index 0000000000..53a354c661 --- /dev/null +++ b/packages/cli/src/mcp-capability-publication.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; + +export type McpCapabilityPublicationState = + | 'unavailable' + | 'publishing' + | 'published' + | 'not_published' + | 'error'; + +interface McpCapabilityPublicationOptions { + readonly connectionIdentity: () => string | undefined; + readonly revision: () => number; + readonly createProvider: () => ClientCapabilityProvider | undefined; + readonly replace: (provider: ClientCapabilityProvider) => Promise; + readonly unregister: () => Promise; + readonly onState: (state: McpCapabilityPublicationState) => void; +} + +/** Coalesces MCP snapshots and never commits a publication from an obsolete connection. */ +export class McpCapabilityPublication { + readonly #options: McpCapabilityPublicationOptions; + #requested = false; + #task: Promise | undefined; + #closeTask: Promise | undefined; + #closed = false; + #state: McpCapabilityPublicationState = 'unavailable'; + #published: { identity: string; revision: number; registered: boolean } | undefined; + + constructor(options: McpCapabilityPublicationOptions) { + this.#options = options; + } + + invalidate(): void { + this.#published = undefined; + } + + request(): void { + if (this.#closed) return; + this.#requested = true; + if (this.#task) return; + this.#task = this.#run().finally(() => { + this.#task = undefined; + if (this.#requested && !this.#closed) this.request(); + }); + } + + async settle(): Promise { + this.request(); + while (this.#task) await this.#task; + return this.#closed ? 'unavailable' : this.#state; + } + + close(): Promise { + this.#closeTask ??= this.#close(true); + return this.#closeTask; + } + + /** Stops local publication state after the Host has already retired its current registration. */ + retire(): Promise { + this.#closeTask ??= this.#close(false); + return this.#closeTask; + } + + async #close(unregister: boolean): Promise { + this.#closed = true; + this.#requested = false; + await this.#task; + try { + if ( + unregister && + this.#published?.registered && + this.#published.identity === this.#options.connectionIdentity() + ) { + await this.#options.unregister(); + } + } finally { + this.#published = undefined; + } + } + + async #run(): Promise { + while (this.#requested && !this.#closed) { + this.#requested = false; + await this.#publish(); + } + } + + async #publish(): Promise { + const identity = this.#options.connectionIdentity(); + if (identity === undefined) { + this.#setState('unavailable'); + return; + } + const revision = this.#options.revision(); + if (this.#published?.identity === identity && this.#published.revision === revision) { + this.#setState(this.#published.registered ? 'published' : 'not_published'); + return; + } + let provider: ClientCapabilityProvider | undefined; + this.#setState('publishing'); + try { + provider = this.#options.createProvider(); + if (provider) await this.#options.replace(provider); + else if (this.#published?.identity === identity && this.#published.registered) { + await this.#options.unregister(); + } + } catch { + try { + await provider?.close?.(); + } catch { + /* Rejected provider cleanup is best effort. */ + } + if (this.#isCurrent(identity, revision)) this.#setState('error'); + else if (!this.#closed) this.#requested = true; + return; + } + // Even an obsolete snapshot may have committed on the current connection. + // Retain that fact so an empty replacement or close can unregister it. + if (this.#options.connectionIdentity() === identity) { + this.#published = { identity, revision, registered: provider !== undefined }; + } + if (!this.#isCurrent(identity, revision)) { + if (!this.#closed) this.#requested = true; + return; + } + this.#setState(provider ? 'published' : 'not_published'); + } + + #isCurrent(identity: string, revision: number): boolean { + return ( + !this.#closed && + this.#options.connectionIdentity() === identity && + this.#options.revision() === revision + ); + } + + #setState(state: McpCapabilityPublicationState): void { + if (this.#closed) return; + this.#state = state; + this.#options.onState(state); + } +} diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 6460b83697..affe6f0eb5 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -36,16 +36,20 @@ import { RuntimeHostSubscriptionError, type RuntimeHostConnection, type RuntimeHostSessionSubscription, + type DecodedSessionTranscriptPage, } from '@maka/runtime-host/client'; import { InteractionAnsweredSnapshot, InteractionPendingSnapshot, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, SessionContinuitySnapshot, SessionDomainChangedFrame, SubscriptionFrame, type GoalProjection, + type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import type { MakaPreparedSessionTurn } from './session-driver.js'; @@ -66,6 +70,15 @@ export interface RuntimeHostSessionChannelOpenResult { terminalTurn?: TerminalTurnSnapshot; } +/** A new prompt's immutable durable lower cut, retained across subscription recovery. */ +export interface RuntimeHostPromptTranscript { + reconcile( + onMessages: (messages: readonly StoredMessage[]) => Promise, + signal?: AbortSignal, + ): Promise; + dispose(): void; +} + export interface RuntimeHostSessionChannelOptions { connection: Pick; /** Optional opener pinned to the concrete Host connection used for first attachment. */ @@ -131,6 +144,10 @@ export class RuntimeHostSessionChannel { #recoveryAttemptsWithoutLiveFrame = 0; #recoveryAwaitingLiveFrame: RuntimeHostSessionSubscription | undefined; #recoveryStableTimer: ReturnType | undefined; + #transcriptThrough: number | null; + #transcriptGeneration = new AbortController(); + readonly #lifetime = new AbortController(); + readonly #trackedPromptTurns = new Set(); private constructor( subscription: RuntimeHostSessionSubscription, @@ -140,6 +157,7 @@ export class RuntimeHostSessionChannel { ) { this.#connection = connection; this.#subscription = subscription; + this.#transcriptThrough = subscription.transcriptBootstrap?.throughSequence ?? null; this.sessionId = subscription.snapshot.session.sessionId; this.messages = messages; this.#now = options.now; @@ -268,6 +286,141 @@ export class RuntimeHostSessionChannel { return this.#pendingStartedTurns.keys().next().value; } + /** Call before turn.start: this deliberately does not implement historical turn lookup. */ + trackPromptTranscript(turnId: string): RuntimeHostPromptTranscript { + if (this.#closing || this.#failure) + throw this.#failure ?? new Error('Session channel is closed'); + const afterSequence = this.#transcriptThrough; + if (this.#trackedPromptTurns.has(turnId)) + throw new Error('Prompt transcript already has an observer'); + this.#trackedPromptTurns.add(turnId); + const disposed = new AbortController(); + let inFlight: Promise | undefined; + let requested = 0; + return { + reconcile: (onMessages, signal) => { + requested += 1; + if (inFlight) return awaitTranscript(inFlight, signal); + const lifetime = AbortSignal.any([ + this.#lifetime.signal, + disposed.signal, + ...(signal ? [signal] : []), + ]); + let task!: Promise; + task = (async () => { + try { + let served: number; + do { + served = requested; + await this.#reconcilePromptTranscript(turnId, afterSequence, onMessages, lifetime); + } while (served !== requested); + } finally { + // Clear before settling: a watermark can arrive between this + // task's completion and a separately queued .then cleanup. + if (inFlight === task) inFlight = undefined; + } + })(); + inFlight = task; + return task; + }, + dispose: () => { + if (disposed.signal.aborted) return; + this.#trackedPromptTurns.delete(turnId); + disposed.abort(new Error('Prompt transcript observation disposed')); + }, + }; + } + + async #reconcilePromptTranscript( + turnId: string, + afterSequence: number | null, + onMessages: (messages: readonly StoredMessage[]) => Promise, + lifetime: AbortSignal, + ): Promise { + for (;;) { + lifetime.throwIfAborted(); + if (this.#failure) throw this.#failure; + if (this.#recoveryTask) await awaitTranscript(this.#recoveryTask, lifetime); + const subscription = this.#subscription; + const generation = this.#transcriptGeneration.signal; + const signal = AbortSignal.any([lifetime, generation]); + const throughSequence = this.#transcriptThrough; + if (throughSequence === null || throughSequence === afterSequence) return; + if (afterSequence !== null && throughSequence < afterSequence) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript moved behind the prompt admission cut', + ); + } + try { + let cursor: string | null = null; + do { + signal.throwIfAborted(); + const page: SessionTranscriptPage = await awaitTranscript( + subscription.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence, + cursor, + anchorSequence: cursor === null ? afterSequence : null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }), + signal, + ); + let assemblyBytes = 0; + const decoded: DecodedSessionTranscriptPage = await awaitTranscript( + subscription.decodeTranscriptPage( + page, + decodeStoredMessage, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + (delta) => { + assemblyBytes += delta; + if (assemblyBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { + throw new RangeError( + 'Prompt transcript assembly exceeds the existing range byte limit', + ); + } + }, + ), + signal, + ); + signal.throwIfAborted(); + if (subscription !== this.#subscription) break; + if ( + decoded.nextCursor !== null && + (decoded.nextCursor === cursor || decoded.messages.length === 0) + ) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Prompt transcript cursor did not advance', + ); + } + // Durable event ordinals may be sparse. The correlated cursor, not + // consecutive message numbers, establishes coverage of this cut. + const messages = decoded.messages + .map((entry) => entry.message) + .filter((message) => message.turnId === turnId); + if (messages.length) await awaitTranscript(onMessages(messages), signal); + signal.throwIfAborted(); + if ( + messages.some( + (message) => message.type === 'turn_state' && message.status !== 'running', + ) + ) + return; + cursor = decoded.nextCursor; + } while (cursor !== null); + if (subscription === this.#subscription) return; + } catch (error) { + lifetime.throwIfAborted(); + if (this.#failure) throw this.#failure; + if (generation.aborted || subscription !== this.#subscription) continue; + if (!this.#canRecover(error)) throw error; + this.#scheduleRecovery(subscription); + } + } + } + activate(claimedTurnId?: string): void { if (this.#closing || this.#activated) return; this.#activated = true; @@ -349,6 +502,8 @@ export class RuntimeHostSessionChannel { async #close(): Promise { this.#closing = true; this.#closeController.abort(new Error('Runtime Host Session channel is closed')); + this.#lifetime.abort(new Error('Session channel closed')); + this.#trackedPromptTurns.clear(); this.#clearRecoveryStableTimer(); this.#recoveryAwaitingLiveFrame = undefined; this.#pendingStartedTurns.clear(); @@ -458,6 +613,9 @@ export class RuntimeHostSessionChannel { return; } this.#subscription = replacement; + this.#transcriptGeneration.abort(new Error('Session transcript subscription replaced')); + this.#transcriptGeneration = new AbortController(); + this.#transcriptThrough = replacement.transcriptBootstrap?.throughSequence ?? null; this.#subscribeSessionDomainChanges(replacement); this.#ready = false; this.#pendingFrames.length = 0; @@ -637,6 +795,12 @@ export class RuntimeHostSessionChannel { } #accept(frame: SubscriptionFrame): void { + if (frame.kind === 'subscription.transcript_advanced') { + this.#transcriptThrough = frame.throughSequence; + // A tool_result can arrive before its durable watermark. Wake only + // registered prompt consumers once the same subscription can page it. + for (const turnId of this.#trackedPromptTurns) this.#onTranscriptSettlement(turnId); + } if (frame.kind === 'subscription.session_domain_changed') { if (frame.domain === 'runtime_resource') { for (const resource of frame.resources) { @@ -734,11 +898,22 @@ export class RuntimeHostSessionChannel { #fail(error: unknown): void { if (this.#failure) return; this.#failure = error instanceof Error ? error : new Error(String(error)); + this.#lifetime.abort(this.#failure); for (const queue of this.#turns.values()) queue.fail(this.#failure); this.#onFailed?.(this.#failure); } } +function awaitTranscript(task: Promise, signal?: AbortSignal): Promise { + if (!signal) return task; + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason); + signal.addEventListener('abort', aborted, { once: true }); + void task.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted)); + }); +} + class SessionEventQueue implements AsyncIterable, AsyncIterator { readonly #items: SessionEvent[] = []; readonly #onLag: () => void; diff --git a/packages/cli/src/tui-mcp-control.ts b/packages/cli/src/tui-mcp-control.ts index 31dac5580f..b9d59c0283 100644 --- a/packages/cli/src/tui-mcp-control.ts +++ b/packages/cli/src/tui-mcp-control.ts @@ -47,6 +47,8 @@ import type { } from '@maka/runtime-host/client'; import { createMcpCapabilityProvider } from './mcp-capability-provider.js'; +import { McpCapabilityPublication } from './mcp-capability-publication.js'; + const RUNTIME_HOST_CREDENTIAL_ENV = 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'; export type TuiMcpPublicationState = @@ -256,15 +258,7 @@ class TuiMcpControllerImpl implements TuiMcpController { | undefined; #actionLane: Promise = Promise.resolve(); #publicationSuppressed = false; - #publicationRequested = false; - #publicationTask: Promise | undefined; - #published: - | { - readonly identity: string; - readonly revision: number; - readonly registered: boolean; - } - | undefined; + readonly #publication: McpCapabilityPublication; #snapshot: TuiMcpSnapshot = freezeSnapshot({ initialization: 'loading', configuration: 'synchronizing', @@ -283,6 +277,26 @@ class TuiMcpControllerImpl implements TuiMcpController { connection.setCredential && connection.removeCredential, ), }); + this.#publication = new McpCapabilityPublication({ + connectionIdentity: () => + this.#availability.kind === 'connected' + ? connectionIdentity(this.#availability) + : undefined, + revision: () => this.#deps.manager.toolSnapshot().revision, + createProvider: () => this.#deps.createProvider(this.#deps.manager), + replace: (provider) => this.#connection.replaceClientCapabilities(provider), + unregister: () => this.#connection.unregisterClientCapabilities(), + onState: (state) => { + this.#updateSnapshot({ + publication: + state === 'unavailable' + ? this.#availability.kind === 'unavailable' + ? (this.#availability.reason ?? 'host_unavailable') + : 'waiting' + : state, + }); + }, + }); this.#disposeManagerChange = deps.manager.onChange(() => { try { this.#refreshManagerSnapshot(); @@ -297,7 +311,7 @@ class TuiMcpControllerImpl implements TuiMcpController { (availability) => { this.#availability = availability; if (availability.kind === 'unavailable') { - this.#published = undefined; + this.#publication.invalidate(); this.#updateSnapshot({ publication: availability.reason ?? 'host_unavailable', ...(availability.reason === 'provider_conflict' @@ -378,15 +392,11 @@ class TuiMcpControllerImpl implements TuiMcpController { this.#disposeConnectionAvailability(); this.#listeners.clear(); this.#preparedImport = undefined; - this.#publicationRequested = false; + const publicationClosing = this.#publication.close().catch(() => undefined); const managerClosing = this.#deps.manager.close(); await this.#actionLane.catch(() => undefined); this.#config = undefined; - await this.#publicationTask?.catch(() => undefined); - if (this.#availability.kind === 'connected') { - await this.#connection.unregisterClientCapabilities().catch(() => undefined); - } - this.#published = undefined; + await publicationClosing; await this.#connection.closePublication?.().catch(() => undefined); await managerClosing; await this.#initialization.catch(() => undefined); @@ -618,9 +628,12 @@ class TuiMcpControllerImpl implements TuiMcpController { } async #settlePublication(): Promise { - this.#requestPublication(); - while (!this.#closed && (this.#publicationTask || this.#publicationRequested)) { - await this.#publicationTask?.catch(() => undefined); + if ( + !this.#closed && + this.#snapshot.initialization === 'ready' && + !this.#publicationSuppressed + ) { + await this.#publication.settle(); } if ( this.#snapshot.publication === 'error' || @@ -670,73 +683,9 @@ class TuiMcpControllerImpl implements TuiMcpController { } #requestPublication(): void { - if (this.#closed) { - this.#publicationRequested = false; - return; - } - if (this.#snapshot.initialization !== 'ready' || this.#publicationSuppressed) return; - this.#publicationRequested = true; - if (this.#publicationTask) return; - this.#publicationTask = this.#runPublicationQueue().finally(() => { - this.#publicationTask = undefined; - if (this.#publicationRequested) this.#requestPublication(); - }); - } - - async #runPublicationQueue(): Promise { - while (this.#publicationRequested && !this.#closed) { - this.#publicationRequested = false; - await this.#publishCurrentSnapshot(); - } - } - - async #publishCurrentSnapshot(): Promise { - const availability = this.#availability; - if (availability.kind !== 'connected') { - this.#updateSnapshot({ publication: availability.reason ?? 'host_unavailable' }); - return; - } - const identity = connectionIdentity(availability); - const revision = this.#deps.manager.toolSnapshot().revision; - if (this.#published?.identity === identity && this.#published.revision === revision) { - this.#updateSnapshot({ - publication: this.#snapshot.toolCount === 0 ? 'not_published' : 'published', - }); - return; - } - let provider: ClientCapabilityProvider | undefined; - this.#updateSnapshot({ publication: 'publishing' }); - try { - provider = this.#deps.createProvider(this.#deps.manager); - if (provider) { - await this.#connection.replaceClientCapabilities(provider); - } else if (this.#published?.identity === identity && this.#published.registered) { - await this.#connection.unregisterClientCapabilities(); - } - } catch { - await closeProvider(provider); - if (this.#isCurrent(identity, revision)) { - this.#updateSnapshot({ publication: 'error' }); - } else { - this.#requestPublication(); - } + if (this.#closed || this.#snapshot.initialization !== 'ready' || this.#publicationSuppressed) return; - } - if (!this.#isCurrent(identity, revision)) { - this.#requestPublication(); - return; - } - this.#published = { identity, revision, registered: provider !== undefined }; - this.#updateSnapshot({ publication: provider ? 'published' : 'not_published' }); - } - - #isCurrent(identity: string, revision: number): boolean { - return ( - !this.#closed && - this.#availability.kind === 'connected' && - connectionIdentity(this.#availability) === identity && - this.#deps.manager.toolSnapshot().revision === revision - ); + this.#publication.request(); } #updateSnapshot( @@ -795,14 +744,6 @@ function connectionIdentity( return `${availability.hostEpoch}\0${availability.connectionId}`; } -async function closeProvider(provider: ClientCapabilityProvider | undefined): Promise { - try { - await provider?.close?.(); - } catch { - // A rejected provider never crossed into Host ownership. - } -} - function cloneConfig(config: McpConfigFile): McpConfigFile { return structuredClone(config); } diff --git a/packages/core/src/__tests__/client-capability-grant.test.ts b/packages/core/src/__tests__/client-capability-grant.test.ts new file mode 100644 index 0000000000..acacc8d24f --- /dev/null +++ b/packages/core/src/__tests__/client-capability-grant.test.ts @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { decodeClientCapabilitySessionGrant } from '../client-capability-grant.js'; + +test('MCP grants preserve exact Session/provider/contract/tool authority without changing Desktop grants', () => { + for (const capability of ['mcp', 'desktop_mcp']) { + const record = { + version: 1, + sessionId: 'session', + providerId: 'provider', + contractId: 'contract', + serverId: 'server', + toolName: 'tool', + capability, + scope: { kind: 'mcp_tool', serverId: 'server', toolName: 'tool' }, + grantedAt: 1, + }; + assert.deepEqual(decodeClientCapabilitySessionGrant(record), record); + assert.throws( + () => decodeClientCapabilitySessionGrant({ ...record, scope: { kind: 'capability' } }), + /scope does not match/, + ); + assert.throws( + () => + decodeClientCapabilitySessionGrant({ + ...record, + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }), + /scope does not match/, + ); + } +}); diff --git a/packages/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts index d2ffca1f0f..327ba51911 100644 --- a/packages/core/src/client-capability-grant.ts +++ b/packages/core/src/client-capability-grant.ts @@ -21,7 +21,7 @@ import { defineObjectShape, hasExactShape } from './record-schema.js'; const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/; -export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp'; +export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp' | 'mcp'; export type ClientCapabilityGrantScope = | { readonly kind: 'browser_origin'; readonly origin: string } @@ -89,14 +89,14 @@ export function decodeClientCapabilityGrantTarget(value: unknown): ClientCapabil const record = plainRecord(value, 'Client Capability Grant target'); const capability = oneOf( record.capability, - ['browser', 'computer_use', 'desktop_mcp'] as const, + ['browser', 'computer_use', 'desktop_mcp', 'mcp'] as const, 'capability', ); const scope = decodeClientCapabilityGrantScope(record.scope); if ( (capability === 'browser' && scope.kind !== 'browser_origin') || (capability === 'computer_use' && scope.kind !== 'capability') || - (capability === 'desktop_mcp' && scope.kind !== 'mcp_tool') + ((capability === 'desktop_mcp' || capability === 'mcp') && scope.kind !== 'mcp_tool') ) { throw new Error('Client Capability Session Grant scope does not match capability'); } diff --git a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts index 337c27e998..b26281832a 100644 --- a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts @@ -22,6 +22,76 @@ import { test } from 'node:test'; import { ClientCapabilityChannel } from '../client/client-capability-channel.js'; import type { ClientCapabilityProvider } from '../client/client-capability.js'; +test('Session registrations mutate independently and reject reverse calls for another Session', async () => { + const replacements = new Map(); + const removals: string[] = []; + const written: unknown[] = []; + let providerCalls = 0; + let finishFirst!: () => void; + const firstPending = new Promise((resolve) => { + finishFirst = resolve; + }); + const provider: ClientCapabilityProvider = { + offers: () => [ + { + offerId: 'fixture', + version: '0', + affinity: 'session', + hostPathAccess: 'none', + admission: 'mcp', + label: 'Fixture', + tools: [{ serverId: 'fixture', name: 'echo', inputSchema: { type: 'object' } }], + }, + ], + call: async () => { + providerCalls += 1; + return { content: [] }; + }, + }; + const channel = new ClientCapabilityChannel({ + write: async (frame) => { + written.push(frame); + }, + replace: async (input) => { + replacements.set(input.sessionId!, input.registrationId); + if (input.sessionId === 'a') await firstPending; + return { registrationId: input.registrationId, revision: 1 }; + }, + unregister: async (input) => { + removals.push(input.registrationId); + return { registrationId: input.registrationId, revision: 2 }; + }, + onFailure: (error) => { + throw error; + }, + }); + const first = channel.replace(provider, 1_000, 'a'); + await assert.rejects(() => channel.replace(provider, 1_000, 'a'), /mutation is already pending/); + await channel.replace(provider, 1_000, 'b'); + await channel.unregister(1_000, 'b'); + assert.deepEqual(removals, [replacements.get('b')]); + finishFirst(); + await first; + channel.accept({ + kind: 'client.capability.call', + invocationId: 'wrong-target', + registrationId: replacements.get('a')!, + offerId: 'fixture', + serverId: 'fixture', + toolName: 'echo', + arguments: {}, + sessionId: 'b', + turnId: 'turn', + toolCallId: 'tool', + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(providerCalls, 0); + assert.equal((written[0] as { kind?: string }).kind, 'client.capability.rejected'); + await channel.unregister(1_000, 'a'); + assert.deepEqual(removals, [replacements.get('b'), replacements.get('a')]); + channel.close(new Error('closed')); +}); + test('Client Capability channel closes a provider after its final registration is released', async () => { let closeCalls = 0; const replacements: string[] = []; @@ -84,6 +154,63 @@ test('Client Capability channel closes a provider after its final registration i assert.equal(closeCalls, 1); }); +test('Host retirement clears the current slot without retiring an older replacement as current', async () => { + const registrations: string[] = []; + const retired: string[] = []; + const closed: string[] = []; + let channel!: ClientCapabilityChannel; + const provider = (name: string): ClientCapabilityProvider => ({ + offers: () => [ + { + offerId: 'fixture', + version: '0', + affinity: 'session', + hostPathAccess: 'none', + admission: 'mcp', + label: 'Fixture', + tools: [{ serverId: 'fixture', name: 'echo', inputSchema: { type: 'object' } }], + }, + ], + currentRegistrationRetired: () => { + retired.push(name); + }, + close: () => { + closed.push(name); + }, + }); + channel = new ClientCapabilityChannel({ + write: async () => undefined, + replace: async (input) => { + registrations.push(input.registrationId); + if (registrations.length === 2) { + channel.accept({ + kind: 'client.capability.registration_release', + registrationId: registrations[0]!, + }); + } + return { registrationId: input.registrationId, revision: registrations.length }; + }, + unregister: async (input) => ({ registrationId: input.registrationId, revision: 3 }), + onFailure: (error) => { + throw error; + }, + }); + + await channel.replace(provider('old'), 1_000, 'session'); + await channel.replace(provider('current'), 1_000, 'session'); + assert.deepEqual(retired, []); + assert.deepEqual(closed, ['old']); + + channel.accept({ + kind: 'client.capability.registration_release', + registrationId: registrations[1]!, + }); + assert.deepEqual(retired, ['current']); + assert.deepEqual(closed, ['old', 'current']); + await assert.rejects(channel.unregister(1_000, 'session'), /No Client Capability registration/); + channel.close(new Error('test complete')); +}); + test('Client Capability channel runs a self-described Host service through admission', async () => { let registrationId = ''; let accepted = false; diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index b6156c3eec..cba267451d 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -2077,7 +2077,7 @@ test('Host services never fail over to a different Session owner', async () => { assert.equal( ( await coordinator.handlers['client.capability.replace']( - { registrationId: 'owner-without-service', offers: [], services: [] }, + replacementInput('owner-without-service', 'placeholder'), connectionContext('connection-a'), ) ).ok, diff --git a/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts b/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts new file mode 100644 index 0000000000..58304e91f4 --- /dev/null +++ b/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts @@ -0,0 +1,403 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ClientCapabilitySessionGrantKey } from '@maka/core/client-capability-grant'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { + decodeClientCapabilityReplaceInput, + type ClientCapabilityHostFrame, + type ClientCapabilityReplaceInput, +} from '../protocol/index.js'; +import { HostClientCapabilityCoordinator } from '../server/client-capability-coordinator.js'; +import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; + +test('one connection isolates same-named Session tools, replacements and unregisters', async (t) => { + const { coordinator, attach, publish, invoke } = fixture(); + t.after(() => coordinator.close()); + attach('one'); + await publish('one', 'a', 'a-first'); + await publish('one', 'b', 'b-first'); + await coordinator.bindSession('a', 'one'); + await coordinator.bindSession('b', 'one'); + const a = coordinator.snapshotForSession('a')!; + const b = coordinator.snapshotForSession('b')!; + t.after(() => { + a.release(); + b.release(); + }); + assert.deepEqual(await invoke(a, 'a'), result('one:a-first:a')); + assert.deepEqual(await invoke(b, 'b'), result('one:b-first:b')); + await coordinator.bindSession('unrelated', 'one'); + assert.equal(coordinator.snapshotForSession('unrelated'), undefined); + + // A matching target ID alone does not opt an unrelated initiating provider + // into this publication, including the local-owner singleton fallback. + attach('other', 'other-client'); + await coordinator.bindSession('unbound', 'other'); + await publish('one', 'unbound', 'injected'); + await coordinator.bindSession('unbound', 'other'); + assert.equal(coordinator.snapshotForSession('unbound'), undefined); + await coordinator.bindSession('unbound', ''); + assert.equal(coordinator.snapshotForSession('unbound'), undefined); + + await publish('one', 'a', 'a-second'); + await coordinator.bindSession('a', 'one'); + const next = coordinator.snapshotForSession('a')!; + t.after(() => next.release()); + assert.deepEqual(await invoke(a, 'a'), result('one:a-first:a')); + assert.deepEqual(await invoke(next, 'a'), result('one:a-second:a')); + assert.deepEqual(await invoke(b, 'b'), result('one:b-first:b')); + assert.equal( + ( + await coordinator.handlers['client.capability.unregister']( + { registrationId: 'a-second' }, + context('one'), + ) + ).ok, + true, + ); + assert.equal(coordinator.snapshotForSession('a'), undefined); + assert.deepEqual(await invoke(b, 'b'), result('one:b-first:b')); + await assert.rejects(() => invoke(b, 'a'), /another Session/); +}); + +test('remote connections with one authenticated provider own independent Session slots and hand off a scope', async (t) => { + const { coordinator, attach, publish, invoke, sent } = fixture('remote_owner'); + t.after(() => coordinator.close()); + const aConnection = attach('a-connection'); + attach('b-connection'); + attach('default-replacement'); + await publish('a-connection', undefined, 'default-old', 'auxiliary'); + await publish('a-connection', 'a', 'a-old'); + await publish('b-connection', 'b', 'b-old'); + await coordinator.bindSession('a', 'a-connection'); + await coordinator.bindSession('b', 'b-connection'); + const a = coordinator.snapshotForSession('a')!; + const b = coordinator.snapshotForSession('b')!; + t.after(() => b.release()); + assert.deepEqual(await invoke(a, 'a'), result('a-connection:a-old:a')); + assert.deepEqual(await invoke(b, 'b'), result('b-connection:b-old:b')); + + await publish('default-replacement', undefined, 'default-new', 'auxiliary'); + assert.deepEqual(await invoke(a, 'a'), result('a-connection:a-old:a')); + assert.deepEqual(await invoke(b, 'b'), result('b-connection:b-old:b')); + const takeover = await coordinator.handlers['client.capability.replace']( + input('a', 'steal-a'), + context('default-replacement'), + ); + assert.equal(takeover.ok, true); + const handedOff = coordinator.snapshotForSession('a')!; + t.after(() => handedOff.release()); + assert.deepEqual(await invoke(handedOff, 'a'), result('default-replacement:steal-a:a')); + a.release(); + assert.ok( + sent.some( + (frame) => + frame.kind === 'client.capability.registration_release' && frame.registrationId === 'a-old', + ), + ); + const foreignRemoval = await coordinator.handlers['client.capability.unregister']( + { registrationId: 'a-old' }, + context('b-connection'), + ); + assert.equal(foreignRemoval.ok, false); + + await aConnection.close(); + assert.deepEqual(await invoke(b, 'b'), result('b-connection:b-old:b')); + attach('a-reconnected'); + await publish('a-reconnected', 'a', 'a-new'); + assert.equal((await coordinator.bindSession('a', 'a-reconnected')).ok, true); + const reconnected = coordinator.snapshotForSession('a')!; + t.after(() => reconnected.release()); + assert.deepEqual(await invoke(reconnected, 'a'), result('a-reconnected:a-new:a')); + assert.deepEqual(await invoke(b, 'b'), result('b-connection:b-old:b')); +}); + +test('Session retirement releases scoped registrations and prevents rebinding retired tools', async (t) => { + const { coordinator, attach, publish, sent } = fixture(); + t.after(() => coordinator.close()); + attach('one'); + await publish('one', 'archived', 'archive-registration'); + await publish('one', 'removed', 'remove-registration'); + await coordinator.bindSession('archived', 'one'); + await coordinator.bindSession('removed', 'one'); + + coordinator.retireSessions(['archived']); + coordinator.retireSessions(['removed']); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + sent + .filter((frame) => frame.kind === 'client.capability.registration_release') + .map((frame) => frame.registrationId), + ['archive-registration', 'remove-registration'], + ); + assert.equal((await coordinator.bindSession('archived', 'one')).ok, true); + assert.equal((await coordinator.bindSession('removed', 'one')).ok, true); + assert.equal(coordinator.snapshotForSession('archived'), undefined); + assert.equal(coordinator.snapshotForSession('removed'), undefined); +}); + +test('connection and Session publications reject overlapping identities in either direction', async (t) => { + const { coordinator, attach, publish } = fixture(); + t.after(() => coordinator.close()); + attach('one'); + await publish('one', 'a', 'a'); + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + input(undefined, 'default'), + context('one'), + ) + ).ok, + false, + ); + await publish('one', undefined, 'default-other', 'other'); + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + input('b', 'b-overlap', 'other'), + context('one'), + ) + ).ok, + false, + ); + await publish('one', 'b', 'b'); +}); + +test('republication removes contracts that disappeared during a scoped disconnect', async (t) => { + const { coordinator, attach, publish } = fixture(); + t.after(() => coordinator.close()); + const first = attach('first'); + await publish('first', 'a', 'old'); + await coordinator.bindSession('a', 'first'); + await first.close(); + attach('reconnected'); + await publish('reconnected', 'a', 'new', 'replacement'); + assert.equal((await coordinator.bindSession('a', 'reconnected')).ok, true); + const current = coordinator.snapshotForSession('a')!; + t.after(() => current.release()); + assert.deepEqual( + current.tools.map((tool) => tool.displayName), + ['replacement'], + ); +}); + +test('local and remote MCP admission grant only the declared tool in its target Session', async (t) => { + for (const principalKind of ['local_owner', 'remote_owner'] as const) { + const { coordinator, attach, publish, invoke, approvals, sent } = fixture(principalKind); + t.after(() => coordinator.close()); + attach('one'); + await publish('one', 'a', 'a-first'); + await publish('one', 'b', 'b-first'); + await coordinator.bindSession('a', 'one'); + await coordinator.bindSession('b', 'one'); + const a = coordinator.snapshotForSession('a')!; + const b = coordinator.snapshotForSession('b')!; + t.after(() => { + a.release(); + b.release(); + }); + assert.deepEqual(await invoke(a, 'a', true), result('one:a-first:a')); + assert.equal(approvals.length, 1); + assert.equal(approvals[0]?.capability, 'mcp'); + assert.deepEqual(approvals[0]?.scope, { + kind: 'mcp_tool', + serverId: 'fixture', + toolName: 'echo', + }); + await invoke(a, 'a', true); + assert.equal(approvals.length, 1); + await invoke(b, 'b', true); + assert.equal(approvals.length, 2); + assert.deepEqual( + approvals.map((approval) => approval.sessionId), + ['a', 'b'], + ); + + // A list refresh with the same contract does not widen or forget a grant. + await publish('one', 'a', 'a-refreshed'); + const refreshed = coordinator.snapshotForSession('a')!; + t.after(() => refreshed.release()); + await invoke(refreshed, 'a', true); + assert.equal(approvals.length, 2); + const calls = sent.filter((frame) => frame.kind === 'client.capability.call').length; + await assert.rejects(() => invoke(refreshed, 'b', true), /another Session/); + assert.equal(sent.filter((frame) => frame.kind === 'client.capability.call').length, calls); + } +}); + +test('MCP policy requires a target, session affinity, no Host paths and no services', () => { + const valid = input('a', 'a'); + assert.equal(decodeClientCapabilityReplaceInput(valid).sessionId, 'a'); + for (const invalid of [ + { ...valid, sessionId: undefined }, + { ...valid, sessionId: '' }, + { ...valid, services: [{ serviceId: 'form', version: '0' }] }, + { ...valid, offers: valid.offers.map((offer) => ({ ...offer, affinity: 'call' })) }, + { ...valid, offers: valid.offers.map((offer) => ({ ...offer, hostPathAccess: 'cwd' })) }, + { ...valid, offers: valid.offers.map((offer) => ({ ...offer, admission: 'browser' })) }, + ]) + assert.throws(() => decodeClientCapabilityReplaceInput(invalid)); +}); + +function fixture(principalKind: 'local_owner' | 'remote_owner' = 'local_owner') { + const approvals: ClientCapabilitySessionGrantKey[] = []; + const grants = new Map(); + const sent: ClientCapabilityHostFrame[] = []; + const key = (value: ClientCapabilitySessionGrantKey) => + JSON.stringify([ + value.sessionId, + value.providerId, + value.contractId, + value.capability, + value.scope, + ]); + const coordinator = new HostClientCapabilityCoordinator({ + activation: new RuntimePolicyActivationGate(), + onModelToolsChanged: () => undefined, + grants: { + readClientCapabilitySessionGrant: async (value) => + grants.has(key(value)) ? { ...value, version: 1, grantedAt: 0 } : undefined, + }, + interactions: { + requestClientCapabilityApproval: async (value) => { + // The provider has accepted but has not crossed the admission cut. + const latest = sent.at(-1); + assert.equal(latest?.kind, 'client.capability.call'); + const granted = { sessionId: value.sessionId, ...value.target }; + approvals.push(granted); + grants.set(key(granted), granted); + return 'allow'; + }, + }, + }); + const attach = (connectionId: string, clientId = 'shared-client') => { + const calls = new Map< + string, + Extract + >(); + const connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity(connectionId, clientId, 'principal', principalKind), + { + send: async (frame) => { + sent.push(frame); + if (frame.kind === 'client.capability.call') { + calls.set(frame.invocationId, frame); + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + const call = calls.get(frame.invocationId)!; + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: result(`${connectionId}:${call.registrationId}:${call.sessionId}`), + }); + } + }, + }, + ); + return connection; + }; + const publish = async ( + connectionId: string, + sessionId: string | undefined, + registrationId: string, + name = 'echo', + ) => { + const outcome = await coordinator.handlers['client.capability.replace']( + input(sessionId, registrationId, name), + context(connectionId), + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + }; + const invoke = async ( + snapshot: NonNullable>, + sessionId: string, + ask = false, + ) => { + const tool = snapshot.tools.find((candidate) => candidate.displayName === 'echo')!; + assert.ok(tool); + const invocation = { + sessionId, + turnId: 'turn', + runId: 'run', + toolCallId: 'call', + cwd: '/tmp', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; + if (!ask) return tool.impl({}, invocation); + const prepared = await tool.prepareExecution!( + {}, + { + ...invocation, + permissionMode: 'ask', + executionBoundary: createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ), + }, + ); + return prepared.execute(invocation); + }; + return { coordinator, attach, publish, invoke, approvals, sent }; +} + +function input( + sessionId: string | undefined, + registrationId: string, + name = 'echo', +): ClientCapabilityReplaceInput { + return { + registrationId, + ...(sessionId === undefined ? {} : { sessionId }), + offers: [ + { + offerId: 'mcp_fixture', + version: '0', + affinity: 'session', + hostPathAccess: 'none', + ...(sessionId === undefined ? {} : { admission: 'mcp' }), + label: 'Fixture', + tools: [{ serverId: 'fixture', name, inputSchema: { type: 'object' } }], + }, + ], + }; +} + +function context(connectionId: string) { + return { + hostEpoch: 'host', + connectionId, + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), + }; +} + +function result(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index b99987ebe1..59887832c9 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -244,6 +244,65 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' error instanceof ClientCapabilityInvocationError && error.code === 'provider_rejected', ); + const scopedProvider = (sessionId: string, value: string): ClientCapabilityProvider => ({ + offers: () => [ + { + offerId: 'mcp_scope', + version: '0', + affinity: 'session', + hostPathAccess: 'none', + admission: 'mcp', + label: 'Session MCP', + tools: [{ serverId: 'same_server', name: 'same_tool', inputSchema: { type: 'object' } }], + }, + ], + call: async (frame, options) => { + assert.equal(frame.sessionId, sessionId); + await options.accept({ kind: 'none' }); + return { content: [{ type: 'text', text: value }] }; + }, + }); + await Promise.all([ + client.replaceClientCapabilities(scopedProvider('scope-a', 'from-a'), { + sessionId: 'scope-a', + }), + client.replaceClientCapabilities(scopedProvider('scope-b', 'from-b'), { + sessionId: 'scope-b', + }), + ]); + for (const sessionId of ['scope-a', 'scope-b']) { + assert.equal((await coordinator.bindSession(sessionId, client.connectionId)).ok, true); + } + const invokeScoped = async (sessionId: string) => { + const scoped = coordinator!.snapshotForSession(sessionId)!; + try { + const echo = scoped.tools.find( + (candidate) => candidate.name === mcpProxyToolName('same_server', 'same_tool'), + )!; + assert.ok(echo); + return await echo.impl({}, { ...toolContext, sessionId }); + } finally { + scoped.release(); + } + }; + assert.deepEqual(await invokeScoped('scope-a'), { + content: [{ type: 'text', text: 'from-a' }], + }); + assert.deepEqual(await invokeScoped('scope-b'), { + content: [{ type: 'text', text: 'from-b' }], + }); + await client.replaceClientCapabilities(scopedProvider('scope-a', 'replacement-a'), { + sessionId: 'scope-a', + }); + assert.deepEqual(await invokeScoped('scope-a'), { + content: [{ type: 'text', text: 'replacement-a' }], + }); + await client.unregisterClientCapabilities({ sessionId: 'scope-a' }); + assert.deepEqual(await invokeScoped('scope-b'), { + content: [{ type: 'text', text: 'from-b' }], + }); + await client.unregisterClientCapabilities({ sessionId: 'scope-b' }); + const disconnectedClient = client; client = undefined; await disconnectedClient.close(); diff --git a/packages/runtime-host/src/client/capability-provider-service.ts b/packages/runtime-host/src/client/capability-provider-service.ts index 6980a1ed33..bc06b93048 100644 --- a/packages/runtime-host/src/client/capability-provider-service.ts +++ b/packages/runtime-host/src/client/capability-provider-service.ts @@ -181,12 +181,14 @@ function snapshotProvider(provider: ClientCapabilityProvider): ClientCapabilityP const call = provider.call?.bind(provider); const callService = provider.callService?.bind(provider); const close = provider.close?.bind(provider); + const currentRegistrationRetired = provider.currentRegistrationRetired?.bind(provider); return { offers: () => canonical.offers, ...(canonical.services === undefined ? {} : { services: () => canonical.services ?? [] }), ...(call ? { call } : {}), ...(callService ? { callService } : {}), ...(close ? { close } : {}), + ...(currentRegistrationRetired ? { currentRegistrationRetired } : {}), }; } diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index 4467f961ae..15fa7f3da9 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -39,6 +39,7 @@ import type { ClientCapabilityProvider } from './client-capability.js'; interface ClientCapabilityRegistration { readonly registrationId: string; + readonly sessionId?: string; readonly provider: ClientCapabilityProvider; readonly offers: ReturnType; readonly services: NonNullable>>; @@ -90,8 +91,8 @@ export class ClientCapabilityChannel { readonly #registrations = new Map(); readonly #invocations = new Map(); readonly #releasedRegistrationIds = new Set(); - #currentRegistrationId: string | undefined; - #mutationPending = false; + readonly #currentRegistrationIds = new Map(); + readonly #pendingMutations = new Set(); #closedError: Error | undefined; constructor(options: ClientCapabilityChannelOptions) { @@ -101,65 +102,77 @@ export class ClientCapabilityChannel { async replace( provider: ClientCapabilityProvider, timeoutMs: number, + sessionId?: string, ): Promise { this.#assertOpen(); - if (this.#mutationPending) { + if (this.#pendingMutations.has(sessionId)) { throw new Error('A Client Capability registration mutation is already pending'); } - this.#mutationPending = true; + this.#pendingMutations.add(sessionId); const registrationId = randomUUID(); let registration: ClientCapabilityRegistration | undefined; try { const services = provider.services?.() ?? []; const canonical = decodeClientCapabilityReplaceInput({ registrationId, + ...(sessionId === undefined ? {} : { sessionId }), offers: provider.offers(), ...(services.length === 0 ? {} : { services }), }); registration = { registrationId, + ...(canonical.sessionId === undefined ? {} : { sessionId: canonical.sessionId }), provider, offers: canonical.offers, services: canonical.services ?? [], }; this.#registrations.set(registrationId, registration); const result = await this.#options.replace(canonical, timeoutMs); + this.#assertOpen(); if (result.registrationId !== registrationId) { throw new Error('Runtime Host replaced a different Client Capability registration'); } - this.#currentRegistrationId = registrationId; + this.#currentRegistrationIds.set(sessionId, registrationId); this.#collectReleasedRegistrations(); return result; } catch (error) { - if (registration && this.#currentRegistrationId !== registrationId) { + if (registration && this.#currentRegistrationIds.get(sessionId) !== registrationId) { this.#registrations.delete(registrationId); } throw error; } finally { - this.#mutationPending = false; + this.#pendingMutations.delete(sessionId); + this.#settleReleasedCurrentRegistration(sessionId); + this.#collectReleasedRegistrations(); } } - async unregister(timeoutMs: number): Promise { + async unregister( + timeoutMs: number, + sessionId?: string, + ): Promise { this.#assertOpen(); - if (this.#mutationPending) { + if (this.#pendingMutations.has(sessionId)) { throw new Error('A Client Capability registration mutation is already pending'); } - const registrationId = this.#currentRegistrationId; + const registrationId = this.#currentRegistrationIds.get(sessionId); if (!registrationId) throw new Error('No Client Capability registration is active'); - this.#mutationPending = true; + this.#pendingMutations.add(sessionId); try { const result = await this.#options.unregister({ registrationId }, timeoutMs); + this.#assertOpen(); if (result.registrationId !== registrationId) { throw new Error('Runtime Host unregistered a different Client Capability registration'); } - if (this.#currentRegistrationId === registrationId) { - this.#currentRegistrationId = undefined; + if (this.#currentRegistrationIds.get(sessionId) === registrationId) { + this.#currentRegistrationIds.delete(sessionId); } this.#collectReleasedRegistrations(); return result; } finally { - this.#mutationPending = false; + this.#pendingMutations.delete(sessionId); + this.#settleReleasedCurrentRegistration(sessionId); + this.#collectReleasedRegistrations(); } } @@ -195,10 +208,17 @@ export class ClientCapabilityChannel { this.#invocations.delete(frame.invocationId); return; } - case 'client.capability.registration_release': + case 'client.capability.registration_release': { + const registration = this.#registrations.get(frame.registrationId); this.#releasedRegistrationIds.add(frame.registrationId); + // A successful replacement can enqueue the old release before its response. Defer + // current-slot retirement until the in-flight mutation establishes which ID won. + if (registration && !this.#pendingMutations.has(registration.sessionId)) { + this.#settleReleasedCurrentRegistration(registration.sessionId); + } this.#collectReleasedRegistrations(); return; + } case 'client.capability.admitted': { const invocation = this.#invocations.get(frame.invocationId); if (!invocation?.admission || !invocation.admission.resolve()) { @@ -236,7 +256,7 @@ export class ClientCapabilityChannel { ); this.#registrations.clear(); this.#releasedRegistrationIds.clear(); - this.#currentRegistrationId = undefined; + this.#currentRegistrationIds.clear(); for (const provider of providers) this.#closeProvider(provider); } @@ -249,7 +269,13 @@ export class ClientCapabilityChannel { const offered = offer?.tools.some( (tool) => tool.serverId === frame.serverId && tool.name === frame.toolName, ); - if (!registration || !offer || !offered || !registration.provider.call) { + if ( + !registration || + (registration.sessionId !== undefined && registration.sessionId !== frame.sessionId) || + !offer || + !offered || + !registration.provider.call + ) { void this.#options .write({ kind: 'client.capability.rejected', @@ -464,9 +490,10 @@ export class ClientCapabilityChannel { #collectReleasedRegistrations(): void { for (const registrationId of this.#releasedRegistrationIds) { - if (registrationId === this.#currentRegistrationId) continue; - this.#releasedRegistrationIds.delete(registrationId); + if ([...this.#currentRegistrationIds.values()].includes(registrationId)) continue; const registration = this.#registrations.get(registrationId); + if (registration && this.#pendingMutations.has(registration.sessionId)) continue; + this.#releasedRegistrationIds.delete(registrationId); if (!registration) continue; this.#registrations.delete(registrationId); if ( @@ -479,6 +506,15 @@ export class ClientCapabilityChannel { } } + #settleReleasedCurrentRegistration(sessionId?: string): void { + const registrationId = this.#currentRegistrationIds.get(sessionId); + if (!registrationId || !this.#releasedRegistrationIds.has(registrationId)) return; + const registration = this.#registrations.get(registrationId); + this.#currentRegistrationIds.delete(sessionId); + this.#collectReleasedRegistrations(); + if (registration) this.#notifyCurrentRegistrationRetired(registration.provider); + } + #closeProvider(provider: ClientCapabilityProvider): void { try { void Promise.resolve(provider.close?.()).catch((error: unknown) => @@ -489,6 +525,16 @@ export class ClientCapabilityChannel { } } + #notifyCurrentRegistrationRetired(provider: ClientCapabilityProvider): void { + try { + void Promise.resolve(provider.currentRegistrationRetired?.()).catch((error: unknown) => + this.#options.onFailure(asError(error)), + ); + } catch (error) { + this.#options.onFailure(asError(error)); + } + } + #assertOpen(): void { if (this.#closedError) throw this.#closedError; } diff --git a/packages/runtime-host/src/client/client-capability.ts b/packages/runtime-host/src/client/client-capability.ts index f0a4946333..4420401199 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -27,6 +27,11 @@ import type { ClientCapabilityServiceOffer, } from '../protocol/index.js'; +export interface ClientCapabilityRegistrationOptions { + readonly sessionId?: string; + readonly timeoutMs?: number; +} + /** A Client-owned open-world capability provider registered on one Host connection. */ export interface ClientCapabilityProvider { offers(): readonly ClientCapabilityOffer[]; @@ -53,4 +58,6 @@ export interface ClientCapabilityProvider { ): Promise>; /** Release provider-owned resources after its final registration is retired. */ close?(): void | Promise; + /** The Host authoritatively retired this provider while its registration was current. */ + currentRegistrationRetired?(): void | Promise; } diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index e73b359891..87bdef7514 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -80,7 +80,10 @@ import { type RuntimeHostSessionSubscription, } from './session-subscription.js'; import { ClientCapabilityChannel } from './client-capability-channel.js'; -import type { ClientCapabilityProvider } from './client-capability.js'; +import type { + ClientCapabilityProvider, + ClientCapabilityRegistrationOptions, +} from './client-capability.js'; import { readRuntimeHostProcessIdentity, type RuntimeHostProcessIdentity, @@ -270,9 +273,11 @@ export interface RuntimeHostConnection { close(): Promise; replaceClientCapabilities( provider: ClientCapabilityProvider, - timeoutMs?: number, + options?: number | ClientCapabilityRegistrationOptions, ): Promise; - unregisterClientCapabilities(timeoutMs?: number): Promise; + unregisterClientCapabilities( + options?: number | ClientCapabilityRegistrationOptions, + ): Promise; subscribeConfigurationChanges(listener: (revision: number) => void): () => void; subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void; subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void; @@ -696,15 +701,19 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { async replaceClientCapabilities( provider: ClientCapabilityProvider, - timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, + options?: number | ClientCapabilityRegistrationOptions, ): Promise { - return this.#clientCapabilities.replace(provider, timeoutMs); + const { timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, sessionId } = + typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); + return this.#clientCapabilities.replace(provider, timeoutMs, sessionId); } async unregisterClientCapabilities( - timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, + options?: number | ClientCapabilityRegistrationOptions, ): Promise { - return this.#clientCapabilities.unregister(timeoutMs); + const { timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, sessionId } = + typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); + return this.#clientCapabilities.unregister(timeoutMs, sessionId); } subscribeConfigurationChanges(listener: (revision: number) => void): () => void { diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 052caee5fd..a5ee11ddc9 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -167,7 +167,10 @@ export { type RuntimeHostCandidateLaunchBarrier, } from './candidate-launch-barrier.js'; export { runHostedExecution, type RunHostedExecutionInput } from './hosted-execution.js'; -export { type ClientCapabilityProvider } from './client-capability.js'; +export { + type ClientCapabilityProvider, + type ClientCapabilityRegistrationOptions, +} from './client-capability.js'; export { readRuntimeHostAgentGraphEpochs, type AgentGraphEpochDirectory, diff --git a/packages/runtime-host/src/client/reconnecting-connection.ts b/packages/runtime-host/src/client/reconnecting-connection.ts index 27e579d13e..9e2885c7a5 100644 --- a/packages/runtime-host/src/client/reconnecting-connection.ts +++ b/packages/runtime-host/src/client/reconnecting-connection.ts @@ -29,7 +29,10 @@ import { type ScheduledTaskChangedFrame, type SubscriptionOpenInput, } from '../protocol/index.js'; -import type { ClientCapabilityProvider } from './client-capability.js'; +import type { + ClientCapabilityProvider, + ClientCapabilityRegistrationOptions, +} from './client-capability.js'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, @@ -225,17 +228,17 @@ class RuntimeHostReconnectingConnectionImpl implements RuntimeHostReconnectingCo async replaceClientCapabilities( provider: ClientCapabilityProvider, - timeoutMs?: number, + options?: number | ClientCapabilityRegistrationOptions, ): Promise { const connection = this.#requireCurrent('client.capability.replace'); - return connection.replaceClientCapabilities(provider, timeoutMs); + return connection.replaceClientCapabilities(provider, options); } async unregisterClientCapabilities( - timeoutMs?: number, + options?: number | ClientCapabilityRegistrationOptions, ): Promise { const connection = this.#requireCurrent('client.capability.unregister'); - return connection.unregisterClientCapabilities(timeoutMs); + return connection.unregisterClientCapabilities(options); } subscribeConfigurationChanges(listener: (revision: number) => void): () => void { diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index f50025487c..e66a710e73 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -110,6 +110,8 @@ export interface ClientCapabilityOffer { readonly version: string; readonly affinity: ClientCapabilityAffinity; readonly hostPathAccess: ClientCapabilityHostPathAccess; + /** Request exact MCP tool admission; this does not confer provider trust. */ + readonly admission?: 'mcp'; readonly label: string; readonly description?: string; readonly tools: readonly ClientCapabilityToolDescriptor[]; @@ -122,6 +124,8 @@ export interface ClientCapabilityServiceOffer { export interface ClientCapabilityReplaceInput { readonly registrationId: string; + /** Restrict publication to this Session; omission keeps the connection-wide slot. */ + readonly sessionId?: string; readonly offers: readonly ClientCapabilityOffer[]; readonly services?: readonly ClientCapabilityServiceOffer[]; } @@ -300,7 +304,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi record, 'Client Capability replacement', ['registrationId', 'offers'], - ['services'], + ['services', 'sessionId'], ); if (!Array.isArray(record.offers) || record.offers.length > CLIENT_CAPABILITY_MAX_OFFERS) { throw invalidProtocolFrame('Invalid Client Capability offers'); @@ -314,6 +318,18 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi } const offers = record.offers.map((offer) => decodeClientCapabilityOffer(offer)); const services = serviceValues.map((service) => decodeClientCapabilityServiceOffer(service)); + const sessionId = + record.sessionId === undefined ? undefined : requireEntityId(record.sessionId, 'sessionId'); + if ( + sessionId !== undefined && + (services.length > 0 || + offers.some((offer) => offer.affinity !== 'session' || offer.hostPathAccess !== 'none')) + ) { + throw invalidProtocolFrame('Session capabilities must be path-independent session tools'); + } + if (offers.some((offer) => offer.admission === 'mcp') && sessionId === undefined) { + throw invalidProtocolFrame('MCP admission requires a target Session'); + } const offerIds = new Set(); const serviceContracts = new Set(); const toolIdentities = new Set(); @@ -344,6 +360,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi } const decoded = { registrationId: requireEntityId(record.registrationId, 'registrationId'), + ...(sessionId === undefined ? {} : { sessionId }), offers, ...(record.services === undefined ? {} : { services }), }; @@ -707,7 +724,7 @@ function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { record, 'Client Capability offer', ['offerId', 'version', 'affinity', 'hostPathAccess', 'label', 'tools'], - ['description'], + ['description', 'admission'], ); if ( !Array.isArray(record.tools) || @@ -716,11 +733,15 @@ function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { ) { throw invalidProtocolFrame('Invalid Client Capability offer tools'); } + if (record.admission !== undefined && record.admission !== 'mcp') { + throw invalidProtocolFrame('Invalid Client Capability admission'); + } return { offerId: requireEntityId(record.offerId, 'offerId'), version: requireString(record.version, 'version', 64), affinity: decodeClientCapabilityAffinity(record.affinity), hostPathAccess: decodeClientCapabilityHostPathAccess(record.hostPathAccess), + ...(record.admission === 'mcp' ? { admission: 'mcp' as const } : {}), label: requireString(record.label, 'label', 128), ...(record.description === undefined ? {} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b1f16e7e5b..afab28cce3 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 154 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 155 as const; +// 155: Session-scoped capability publication and MCP admission require compatible +// Client and Host builds; older peers do not enforce their isolation contract. // 154: External Session import results distinguish committed Sessions from typed source limits. // 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses. diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index b1ce6da249..0e0e4a4d12 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -34,6 +34,7 @@ import { import { type ToolGroup } from '@maka/runtime/tool-availability'; import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store'; import { + decodeClientCapabilityReplaceInput, type ClientCapabilityOffer, type ClientCapabilityAdmissionEvidence, type ClientCapabilityOwnerIdentity, @@ -95,6 +96,7 @@ interface ClientProviderState { readonly capabilityOwner?: ClientCapabilityOwnerIdentity; activeConnectionId?: string; current?: CapabilityRegistration; + readonly sessionRegistrations: Map; readonly registrations: Map; } @@ -109,6 +111,7 @@ interface CapabilityRegistration { readonly providerId: string; readonly connectionId: string; readonly registrationId: string; + readonly sessionId?: string; readonly trustedProvider: boolean; readonly offersByContract: ReadonlyMap; readonly servicesByContract: ReadonlyMap; @@ -124,12 +127,15 @@ interface FrozenOfferBinding { interface FrozenToolBinding { readonly offerId: string; readonly hostPathAccess: ClientCapabilityOffer['hostPathAccess']; + readonly admission?: ClientCapabilityOffer['admission']; readonly descriptor: ClientCapabilityToolDescriptor; } -type SessionCapabilityBinding = - | { readonly kind: 'bound'; readonly providerId: string } - | { readonly kind: 'lost'; readonly providerId: string }; +interface SessionCapabilityBinding { + readonly kind: 'bound' | 'lost'; + readonly providerId: string; + readonly sessionId?: string; +} type SessionBindingMode = 'strict' | 'degrade'; interface SessionCapabilityState { @@ -227,12 +233,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService this.#interactions = options.interactions; this.#grants = options.grants; this.#invocations = new ClientCapabilityInvocationBroker({ - senderFor: (connectionId) => { - const connection = this.#connections.get(connectionId); - return connection && this.#activeConnection(connection.provider) === connection - ? connection.sender - : undefined; - }, + senderFor: (registration) => this.#registrationConnection(registration)?.sender, onRegistrationIdle: (registration) => this.#releaseRegistrationIfUnused(registration), }); } @@ -331,10 +332,15 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return this.#activation.runMutation(() => { const provider = [...this.#providers.values()].find( (candidate) => - providerAuthorityDigest(candidate) === binding && this.#activeConnection(candidate), + providerAuthorityDigest(candidate) === binding && + (this.#activeConnection(candidate) || + (candidate.sessionRegistrations.get(sessionId) && + this.#registrationConnection(candidate.sessionRegistrations.get(sessionId)!))), ); if (!provider) return false; - const connection = this.#activeConnection(provider)!; + const scoped = provider.sessionRegistrations.get(sessionId); + const connection = + (scoped && this.#registrationConnection(scoped)) || this.#activeConnection(provider)!; const selection = this.#selectSessionState(sessionId, connection.connectionId, 'strict'); if (!selection.ok || this.#toolProviderBinding(selection.state, toolNames) !== binding) return false; @@ -353,8 +359,10 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService for (const [contractId, binding] of state?.sessionBindings ?? []) { if (binding.kind !== 'bound') continue; const provider = this.#providers.get(binding.providerId); - const offer = provider?.current?.offersByContract.get(contractId); - if (!provider || !this.#activeConnection(provider) || !offer) continue; + const registration = this.#bindingRegistration(binding); + const offer = registration?.offersByContract.get(contractId); + if (!provider || !registration || !this.#registrationConnection(registration) || !offer) + continue; for (const tool of offer.offer.tools) { if (!missing.delete(mcpProxyToolName(tool.serverId, tool.name))) continue; if (selected && selected !== provider) return undefined; @@ -423,8 +431,11 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService mode: SessionBindingMode, ): SessionBindingSelection { const initiatingProvider = this.#connections.get(initiatingConnectionId)?.provider; + const initiatingScoped = initiatingProvider?.sessionRegistrations.get(sessionId); const directProvider = - initiatingProvider?.current && this.#activeConnection(initiatingProvider) + initiatingProvider && + ((initiatingProvider.current && this.#activeConnection(initiatingProvider)) || + (initiatingScoped && this.#registrationConnection(initiatingScoped))) ? initiatingProvider : undefined; const associatedProviders = @@ -462,7 +473,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ? initiatingProviderId : undefined); const previous = previousState?.sessionBindings ?? new Map(); - const eligible = this.#eligibleOffersByContract(); + const eligible = this.#eligibleOffersByContract(sessionId); const next = new Map(); const selected: SelectedOfferBinding[] = []; const sessionContractIds = new Set([ @@ -478,11 +489,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService let candidate: SelectedOfferBinding | undefined; if (previousBinding?.kind === 'bound') { candidate = candidates.find( - (entry) => entry.registration.providerId === previousBinding.providerId, + (entry) => + entry.registration.providerId === previousBinding.providerId && + entry.registration.sessionId === previousBinding.sessionId, ); if (!candidate) { if (mode === 'degrade') { - next.set(contractId, { kind: 'lost', providerId: previousBinding.providerId }); + next.set(contractId, { ...previousBinding, kind: 'lost' }); continue; } return { @@ -493,7 +506,9 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } } else if (previousBinding?.kind === 'lost') { candidate = candidates.find( - (entry) => entry.registration.providerId === previousBinding.providerId, + (entry) => + entry.registration.providerId === previousBinding.providerId && + entry.registration.sessionId === previousBinding.sessionId, ); if (!candidate) { if (mode === 'degrade') { @@ -506,8 +521,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } } else { - candidate = selectProviderCandidate(candidates, initiatingProviderId); - if (!candidate && initiatingProviderId === undefined && candidates.length > 1) { + const selectable = candidates.filter( + (entry) => + entry.registration.sessionId === undefined || + entry.registration.providerId === initiatingProviderId, + ); + candidate = selectProviderCandidate(selectable, initiatingProviderId); + if (!candidate && initiatingProviderId === undefined && selectable.length > 1) { if (mode === 'degrade') continue; return { ok: false, @@ -520,6 +540,9 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService next.set(contractId, { kind: 'bound', providerId: candidate.registration.providerId, + ...(candidate.registration.sessionId === undefined + ? {} + : { sessionId: candidate.registration.sessionId }), }); selected.push(candidate); } @@ -532,6 +555,9 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService next.set(candidate.offer.contractId, { kind: 'lost', providerId: candidate.registration.providerId, + ...(candidate.registration.sessionId === undefined + ? {} + : { sessionId: candidate.registration.sessionId }), }); } else { next.delete(candidate.offer.contractId); @@ -590,9 +616,9 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService )) { if (binding.kind === 'lost') continue; const provider = this.#providers.get(binding.providerId); - const registration = provider?.current; + const registration = this.#bindingRegistration(binding); const offer = registration?.offersByContract.get(contractId); - if (!provider || !this.#activeConnection(provider) || !registration || !offer) { + if (!provider || !registration || !this.#registrationConnection(registration) || !offer) { throw new ClientCapabilityInvocationError( 'capability_lost', 'A Session-bound Client Capability provider is unavailable', @@ -831,8 +857,19 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } retireSessions(sessionIds: readonly string[]): void { - for (const sessionId of new Set(sessionIds)) this.#sessions.delete(sessionId); - for (const provider of this.#providers.values()) this.#deleteProviderIfUnused(provider); + const retiredSessionIds = new Set(sessionIds); + for (const sessionId of retiredSessionIds) this.#sessions.delete(sessionId); + for (const provider of this.#providers.values()) { + for (const sessionId of retiredSessionIds) { + const registration = provider.sessionRegistrations.get(sessionId); + if (!registration) continue; + provider.sessionRegistrations.delete(sessionId); + this.#revision += 1; + if (hasModelToolOffers(registration)) this.#onModelToolsChanged(); + this.#releaseRegistrationIfUnused(registration); + } + this.#deleteProviderIfUnused(provider); + } } releaseConnection(connectionId: string): Promise { @@ -855,16 +892,21 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService if (this.#connections.get(connection.connectionId) !== connection) return; const { provider } = connection; this.#connections.delete(connection.connectionId); - if (provider.activeConnectionId !== connection.connectionId) { - this.#deleteProviderIfUnused(provider); - return; + if (provider.activeConnectionId === connection.connectionId) { + provider.activeConnectionId = undefined; + if (provider.current) { + const registration = provider.current; + provider.current = undefined; + this.#markBindingsLost(provider.providerId); + this.#removeTurnBindings(provider.providerId); + this.#revision += 1; + if (hasModelToolOffers(registration)) this.#onModelToolsChanged(); + } } - provider.activeConnectionId = undefined; - if (provider.current) { - const registration = provider.current; - provider.current = undefined; - this.#markBindingsLost(provider.providerId); - this.#removeTurnBindings(provider.providerId); + for (const [sessionId, registration] of provider.sessionRegistrations) { + if (registration.connectionId !== connection.connectionId) continue; + provider.sessionRegistrations.delete(sessionId); + this.#markBindingsLost(provider.providerId, undefined, sessionId); this.#revision += 1; if (hasModelToolOffers(registration)) this.#onModelToolsChanged(); } @@ -915,7 +957,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } const { provider } = connection; - if (connection.superseded) { + if (connection.superseded && input.sessionId === undefined) { return { ok: false, error: { @@ -952,6 +994,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService input, provider.trustedProvider, ); + const overlapping = + registration.sessionId === undefined + ? [...provider.sessionRegistrations.values()] + : provider.current + ? [provider.current] + : []; + assertRegistrationToolsDoNotOverlap(registration, overlapping); } catch (error) { return { ok: false, @@ -961,16 +1010,41 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, }; } - const previous = provider.current; + const previous = this.#currentRegistration(provider, registration.sessionId); const previousConnectionId = provider.activeConnectionId; - if (previousConnectionId && previousConnectionId !== context.connectionId) { - const previousConnection = this.#connections.get(previousConnectionId); - if (previousConnection) previousConnection.superseded = true; - this.#invocations.releaseConnection(previousConnectionId); + if (registration.sessionId !== undefined) { + provider.sessionRegistrations.set(registration.sessionId, registration); + } else { + if (previousConnectionId && previousConnectionId !== context.connectionId) { + const previousConnection = this.#connections.get(previousConnectionId); + if (previousConnection) previousConnection.superseded = true; + for (const retired of provider.registrations.values()) { + if (retired.sessionId === undefined && retired.connectionId === previousConnectionId) { + void this.#invocations.releaseRegistration(retired); + } + } + } + provider.activeConnectionId = context.connectionId; + provider.current = registration; } - provider.activeConnectionId = context.connectionId; - provider.current = registration; const currentContracts = new Set(registration.offersByContract.keys()); + if (registration.sessionId !== undefined) { + // Discovery may have changed while this scope's connection was away. + // Republishing is a complete replacement, including previously lost + // contracts; keep other Sessions and legacy provider leases untouched. + const previousBindings = this.#sessions.get(registration.sessionId)?.sessionBindings; + const removed = new Set( + [...(previousBindings ?? [])] + .filter( + ([contractId, binding]) => + binding.providerId === provider.providerId && + binding.sessionId === registration.sessionId && + !currentContracts.has(contractId), + ) + .map(([contractId]) => contractId), + ); + this.#retireBindings(provider.providerId, removed, registration.sessionId); + } if (previous) { this.#retireBindings( provider.providerId, @@ -979,6 +1053,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService (contractId) => !currentContracts.has(contractId), ), ), + registration.sessionId, ); this.#removeTurnBindings( provider.providerId, @@ -987,9 +1062,10 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService (contractId) => !currentContracts.has(contractId), ), ), + registration.sessionId, ); } - this.#restoreBindings(provider.providerId, currentContracts); + this.#restoreBindings(provider.providerId, currentContracts, registration.sessionId); provider.registrations.set(registration.registrationId, registration); this.#revision += 1; if (hasModelToolOffers(previous) || hasModelToolOffers(registration)) { @@ -1013,10 +1089,12 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ): ReturnType { return this.#activation.runMutation(async () => { const provider = this.#connections.get(context.connectionId)?.provider; + const registration = provider?.registrations.get(input.registrationId); if ( - provider?.activeConnectionId !== context.connectionId || - !provider.current || - provider.current.registrationId !== input.registrationId + !provider || + !registration || + registration.connectionId !== context.connectionId || + this.#currentRegistration(provider, registration.sessionId) !== registration ) { return { ok: false, @@ -1026,10 +1104,18 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, }; } - const registration = provider.current; - provider.current = undefined; - this.#retireBindings(provider.providerId, new Set(registration.offersByContract.keys())); - this.#removeTurnBindings(provider.providerId, new Set(registration.offersByContract.keys())); + if (registration.sessionId === undefined) provider.current = undefined; + else provider.sessionRegistrations.delete(registration.sessionId); + this.#retireBindings( + provider.providerId, + new Set(registration.offersByContract.keys()), + registration.sessionId, + ); + this.#removeTurnBindings( + provider.providerId, + new Set(registration.offersByContract.keys()), + registration.sessionId, + ); this.#revision += 1; if (hasModelToolOffers(registration)) this.#onModelToolsChanged(); this.#releaseRegistrationIfUnused(registration); @@ -1100,6 +1186,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService toolSnapshot: () => snapshot, prepareTool: async (binding, args, options): Promise => { const { selectedBinding, dynamicBinding } = resolveBinding(binding); + assertRegistrationSession(dynamicBinding.registration, options.context.sessionId); const prepared = this.#invocations.prepare( dynamicBinding.registration, dynamicBinding.tool, @@ -1121,6 +1208,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService dynamicBinding.registration, dynamicBinding.tool, evidence, + options.context.sessionId, ); if (!target) { return { @@ -1157,6 +1245,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, callTool: (binding, args, options) => { const { dynamicBinding } = resolveBinding(binding); + assertRegistrationSession(dynamicBinding.registration, options.context.sessionId); return this.#invocations.invoke( dynamicBinding.registration, dynamicBinding.tool, @@ -1241,6 +1330,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ? { capabilityOwner: Object.freeze({ ...identity.capabilityOwner }) } : {}), registrations: new Map(), + sessionRegistrations: new Map(), }; this.#providers.set(providerId, provider); } else if ( @@ -1257,15 +1347,20 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return provider; } - #eligibleOffersByContract(): Map { + #eligibleOffersByContract(sessionId?: string): Map { const eligible = new Map(); for (const provider of this.#providers.values()) { - const registration = provider.current; - if (!this.#activeConnection(provider) || !registration) continue; - for (const offer of registration.offersByContract.values()) { - const candidates = eligible.get(offer.contractId) ?? []; - candidates.push({ registration, offer }); - eligible.set(offer.contractId, candidates); + const registrations = [ + provider.current, + sessionId === undefined ? undefined : provider.sessionRegistrations.get(sessionId), + ]; + for (const registration of registrations) { + if (!registration || !this.#registrationConnection(registration)) continue; + for (const offer of registration.offersByContract.values()) { + const candidates = eligible.get(offer.contractId) ?? []; + candidates.push({ registration, offer }); + eligible.set(offer.contractId, candidates); + } } } for (const candidates of eligible.values()) { @@ -1308,7 +1403,11 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return { registration: candidate.registration, tool }; } - #markBindingsLost(providerId: string, contracts?: ReadonlySet): void { + #markBindingsLost( + providerId: string, + contracts?: ReadonlySet, + scopeSessionId?: string, + ): void { for (const [sessionId, state] of this.#sessions) { const bindings = state.sessionBindings; let next: Map | undefined; @@ -1316,27 +1415,33 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService if ( binding.kind !== 'bound' || binding.providerId !== providerId || + binding.sessionId !== scopeSessionId || (contracts && !contracts.has(contractId)) ) { continue; } next ??= new Map(bindings); - next.set(contractId, { kind: 'lost', providerId }); + next.set(contractId, { ...binding, kind: 'lost' }); } if (next) this.#storeSessionState(sessionId, { ...state, sessionBindings: next }); } } - #restoreBindings(providerId: string, contracts: ReadonlySet): void { + #restoreBindings( + providerId: string, + contracts: ReadonlySet, + scopeSessionId?: string, + ): void { for (const [sessionId, state] of this.#sessions) { const next = new Map(state.sessionBindings); for (const [contractId, binding] of state.sessionBindings) { if ( binding.kind === 'lost' && binding.providerId === providerId && + binding.sessionId === scopeSessionId && contracts.has(contractId) ) { - next.set(contractId, { kind: 'bound', providerId }); + next.set(contractId, { ...binding, kind: 'bound' }); } } if (bindingMapsEqual(state.sessionBindings, next)) continue; @@ -1344,14 +1449,19 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } } - #retireBindings(providerId: string, contracts: ReadonlySet): void { + #retireBindings( + providerId: string, + contracts: ReadonlySet, + scopeSessionId?: string, + ): void { for (const [sessionId, state] of this.#sessions) { const bindings = state.sessionBindings; const next = new Map(bindings); for (const [contractId, binding] of bindings) { if ( - binding.kind === 'bound' && + (binding.kind === 'bound' || scopeSessionId !== undefined) && binding.providerId === providerId && + binding.sessionId === scopeSessionId && contracts.has(contractId) ) { next.delete(contractId); @@ -1362,7 +1472,11 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } } - #removeTurnBindings(providerId: string, contracts?: ReadonlySet): void { + #removeTurnBindings( + providerId: string, + contracts?: ReadonlySet, + scopeSessionId?: string, + ): void { for (const [sessionId, state] of this.#sessions) { const bindings = state.turnBindings; const next = new Map(bindings); @@ -1370,6 +1484,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService if ( binding.kind === 'bound' && binding.providerId === providerId && + binding.sessionId === scopeSessionId && (!contracts || contracts.has(contractId)) ) { next.delete(contractId); @@ -1415,7 +1530,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService #releaseRegistrationIfUnused(registration: CapabilityRegistration): void { const provider = this.#providers.get(registration.providerId); if ( - provider?.current === registration || + (provider && this.#currentRegistration(provider, registration.sessionId) === registration) || registration.snapshotRefs !== 0 || this.#invocations.holdsRegistration(registration) ) { @@ -1439,9 +1554,35 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return connection?.provider === provider && !connection.superseded ? connection : undefined; } + #currentRegistration( + provider: ClientProviderState, + sessionId?: string, + ): CapabilityRegistration | undefined { + return sessionId === undefined + ? provider.current + : provider.sessionRegistrations.get(sessionId); + } + + #registrationConnection( + registration: CapabilityRegistration, + ): ClientProviderConnection | undefined { + const connection = this.#connections.get(registration.connectionId); + if (!connection || connection.provider.providerId !== registration.providerId) return; + // Session publications have their own owning connection. A replacement of + // the legacy connection-wide publication cannot supersede those channels. + if (registration.sessionId !== undefined) return connection; + return this.#activeConnection(connection.provider) === connection ? connection : undefined; + } + + #bindingRegistration(binding: SessionCapabilityBinding): CapabilityRegistration | undefined { + const provider = this.#providers.get(binding.providerId); + return provider ? this.#currentRegistration(provider, binding.sessionId) : undefined; + } + #deleteProviderIfUnused(provider: ClientProviderState): void { if ( provider.current || + provider.sessionRegistrations.size > 0 || provider.activeConnectionId || provider.registrations.size > 0 || [...this.#connections.values()].some((connection) => connection.provider === provider) || @@ -1468,12 +1609,42 @@ function trustedClientToolActivityKind( return descriptor.activityKind; } +function assertRegistrationSession(registration: CapabilityRegistration, sessionId: string): void { + if (registration.sessionId !== undefined && registration.sessionId !== sessionId) { + throw new ClientCapabilityInvocationError( + 'capability_lost', + 'Client Capability belongs to another Session', + ); + } +} + +function assertRegistrationToolsDoNotOverlap( + registration: CapabilityRegistration, + overlapping: readonly CapabilityRegistration[], +): void { + const names = new Set( + [...registration.offersByContract.values()].flatMap(({ offer }) => + offer.tools.map((tool) => mcpProxyToolName(tool.serverId, tool.name)), + ), + ); + for (const candidate of overlapping) { + for (const { offer } of candidate.offersByContract.values()) { + if (offer.tools.some((tool) => names.has(mcpProxyToolName(tool.serverId, tool.name)))) { + throw new Error( + 'Connection and Session capabilities expose conflicting model tool identities', + ); + } + } + } +} + function freezeRegistration( providerId: string, connectionId: string, input: ClientCapabilityReplaceInput, trustedProvider: boolean, ): CapabilityRegistration { + input = decodeClientCapabilityReplaceInput(input); const offers = input.offers.map((offer) => Object.freeze({ ...offer, @@ -1504,6 +1675,7 @@ function freezeRegistration( toolsByIdentity.set(identity, { offerId: offer.offerId, hostPathAccess: offer.hostPathAccess, + ...(offer.admission === undefined ? {} : { admission: offer.admission }), descriptor, }); } @@ -1527,6 +1699,7 @@ function freezeRegistration( providerId, connectionId, registrationId: input.registrationId, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), trustedProvider, offersByContract, servicesByContract, @@ -1539,8 +1712,26 @@ function managedClientCapabilityGrantTarget( registration: CapabilityRegistration, tool: FrozenToolBinding, evidence: ClientCapabilityAdmissionEvidence, + sessionId: string, ): ClientCapabilityGrantTarget | undefined { const { serverId, name: toolName } = tool.descriptor; + if (tool.admission === 'mcp') { + if ( + registration.sessionId !== sessionId || + tool.hostPathAccess !== 'none' || + evidence.kind !== 'none' + ) { + throw new Error('MCP admission requires its target Session and path-independent evidence'); + } + return Object.freeze({ + providerId: registration.providerId, + contractId, + serverId, + toolName, + capability: 'mcp', + scope: Object.freeze({ kind: 'mcp_tool', serverId, toolName }), + }); + } if (!registration.trustedProvider) { throw new Error('Managed Client Capability requires a trusted Desktop provider'); } @@ -1649,6 +1840,7 @@ function capabilityGroupId(offer: ClientCapabilityOffer): string { version: offer.version, affinity: offer.affinity, hostPathAccess: offer.hostPathAccess, + admission: offer.admission, tools, }), ) @@ -1775,7 +1967,8 @@ function bindingMapsEqual( if ( !candidate || candidate.kind !== binding.kind || - candidate.providerId !== binding.providerId + candidate.providerId !== binding.providerId || + candidate.sessionId !== binding.sessionId ) { return false; } diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index b8a2669241..da55ff87af 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -135,7 +135,7 @@ export interface PreparedClientCapabilityInvocation { export interface ClientCapabilityInvocationBrokerOptions< Registration extends ClientCapabilityInvocationRegistration, > { - readonly senderFor: (connectionId: string) => ClientCapabilityConnectionSender | undefined; + readonly senderFor: (registration: Registration) => ClientCapabilityConnectionSender | undefined; readonly onRegistrationIdle: (registration: Registration) => void; readonly scheduleTimeout?: (callback: () => void, timeoutMs: number) => () => void; } @@ -268,7 +268,7 @@ export class ClientCapabilityInvocationBroker< requestInteraction: ClientCapabilityInteractionHandler | undefined, frameFor: (invocationId: string) => ClientCapabilityHostFrame, ): PreparedClientCapabilityInvocation { - const sender = this.#senderFor(registration.connectionId); + const sender = this.#senderFor(registration); if (!sender) { throw new ClientCapabilityInvocationError( 'capability_lost', @@ -363,7 +363,7 @@ export class ClientCapabilityInvocationBroker< invocation.requestInteraction = requestInteraction ?? invocation.requestInteraction; invocation.phase = 'admitted'; this.#armTimer(invocation); - const currentSender = this.#senderFor(invocation.registration.connectionId); + const currentSender = this.#senderFor(invocation.registration); if (!currentSender) { this.#settle( invocation, @@ -396,7 +396,7 @@ export class ClientCapabilityInvocationBroker< cancel: () => { const invocation = this.#invocations.get(invocationId); if (!invocation) return; - const currentSender = this.#senderFor(invocation.registration.connectionId); + const currentSender = this.#senderFor(invocation.registration); void currentSender ?.send({ kind: 'client.capability.cancel', invocationId }) .catch(() => {}); @@ -538,10 +538,18 @@ export class ClientCapabilityInvocationBroker< } } - async releaseConnection(connectionId: string): Promise { + releaseConnection(connectionId: string): Promise { + return this.#releaseWhere((registration) => registration.connectionId === connectionId); + } + + releaseRegistration(registration: Registration): Promise { + return this.#releaseWhere((candidate) => candidate === registration); + } + + async #releaseWhere(matches: (registration: Registration) => boolean): Promise { const interactions: Promise[] = []; for (const invocation of [...this.#invocations.values()]) { - if (invocation.registration.connectionId !== connectionId) continue; + if (!matches(invocation.registration)) continue; if (invocation.phase === 'dispatched' || invocation.phase === 'accepted') { invocation.providerAvailability.abort( new ClientCapabilityInvocationError( @@ -684,7 +692,7 @@ export class ClientCapabilityInvocationBroker< ); return; } - const sender = this.#senderFor(invocation.registration.connectionId); + const sender = this.#senderFor(invocation.registration); if (!sender) { this.#settle( invocation, @@ -761,7 +769,7 @@ export class ClientCapabilityInvocationBroker< invocation.cancelTimer = this.#scheduleTimeout(() => { const current = this.#invocations.get(invocation.invocationId); if (current !== invocation) return; - const sender = this.#senderFor(current.registration.connectionId); + const sender = this.#senderFor(current.registration); void sender ?.send({ kind: 'client.capability.cancel', invocationId: current.invocationId }) .catch(() => {}); @@ -798,7 +806,7 @@ export class ClientCapabilityInvocationBroker< } this.#rememberRetired(invocation.invocationId); if (releaseRemote) { - const sender = this.#senderFor(invocation.registration.connectionId); + const sender = this.#senderFor(invocation.registration); void sender ?.send({ kind: 'client.capability.release', diff --git a/packages/runtime-host/src/test-only/client-capability-host.ts b/packages/runtime-host/src/test-only/client-capability-host.ts index 3d59826eb1..2ef42e756a 100644 --- a/packages/runtime-host/src/test-only/client-capability-host.ts +++ b/packages/runtime-host/src/test-only/client-capability-host.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { ClientCapabilitySessionGrantKey } from '@maka/core/client-capability-grant'; + export { HostClientCapabilityCoordinator, type ClientCapabilitySnapshot, @@ -31,18 +33,11 @@ export function clientCapabilityCoordinatorTestAdmission() { }, }, grants: { - readClientCapabilitySessionGrant: async (key: { - sessionId: string; - providerId: string; - contractId: string; - serverId: string; - toolName: string; - capability: 'browser' | 'computer_use' | 'desktop_mcp'; - scope: - | { kind: 'browser_origin'; origin: string } - | { kind: 'capability' } - | { kind: 'mcp_tool'; serverId: string; toolName: string }; - }) => ({ version: 1 as const, ...key, grantedAt: 0 }), + readClientCapabilitySessionGrant: async (key: ClientCapabilitySessionGrantKey) => ({ + version: 1 as const, + ...key, + grantedAt: 0, + }), }, }; } diff --git a/packages/ui/src/client-capability-prompt.tsx b/packages/ui/src/client-capability-prompt.tsx index 0e54cc1603..f41dddd645 100644 --- a/packages/ui/src/client-capability-prompt.tsx +++ b/packages/ui/src/client-capability-prompt.tsx @@ -106,6 +106,7 @@ function clientCapabilityLabel( case 'computer_use': return copy.computerUse; case 'desktop_mcp': + case 'mcp': if (request.scope.kind !== 'mcp_tool') break; return copy.desktopMcp(request.scope.serverId, request.scope.toolName); } From 2741fb4b87a2b7953a463e8d089f888bd7a9f63f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:49:02 +0800 Subject: [PATCH 2/2] fix(cli): accept authoritative MCP retirement during close --- .../cli/src/__tests__/acp-session-mcp.test.ts | 40 +++++++++++++++++++ packages/cli/src/acp/session-mcp.ts | 11 ++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 0c193e5fff..e9a134771a 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -213,6 +213,44 @@ test('authoritative retirement of the current Session registration closes its MC assert.equal(host.listenerCount(), 0); }); +test('authoritative retirement makes an in-flight local close succeed', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const unregister = deferred(); + host.unregister = () => unregister.promise; + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + host.connection, + ); + t.after(async () => { + unregister.resolve(); + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const provider = host.replacements[0]!.provider; + assert.ok(provider.currentRegistrationRetired); + + const closing = mcp.close(); + await waitFor(() => host.unregisters.length === 1, { timeoutMs: 5_000, pollMs: 10 }); + const retiring = provider.currentRegistrationRetired(); + unregister.reject( + new RuntimeHostOperationError( + 'client.capability.unregister', + 'invalid_request', + 'Client Capability registration is not current', + ), + ); + + await Promise.all([closing, retiring]); + assert.deepEqual(host.unregisters, [{ sessionId }]); + assert.equal(host.listenerCount(), 0); + await assertFixtureExited(root, 'fixture'); +}); + test('one failed MCP discovery closes every prepared server without publishing a partial group', { timeout: 20_000, }, async (t) => { @@ -453,6 +491,7 @@ function fakeHost() { }[], unregisters: [] as (number | ClientCapabilityRegistrationOptions | undefined)[], replace: async (): Promise => undefined, + unregister: async (): Promise => undefined, listenerCount: () => listeners.size, emit: (availability: RuntimeHostConnectionAvailability) => { for (const listener of listeners) listener(availability); @@ -467,6 +506,7 @@ function fakeHost() { }, unregisterClientCapabilities: async (options) => { host.unregisters.push(options); + await host.unregister(); return { registrationId: 'registration', revision: host.replacements.length + 1 }; }, subscribeConnectionAvailability: (listener) => { diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index bd484846cd..c0e674e392 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -105,6 +105,7 @@ export class AcpSessionMcp { #availability: RuntimeHostConnectionAvailability | undefined; #prepared = false; #closed = false; + #authoritativelyRetired = false; #closeTask: Promise | undefined; constructor(sessionId: string, config: McpConfigFile, connection: AcpMcpConnection) { @@ -172,6 +173,7 @@ export class AcpSessionMcp { } #retire(): Promise { + this.#authoritativelyRetired = true; return this.#close(false); } @@ -183,7 +185,14 @@ export class AcpSessionMcp { const managerClose = this.#manager.close(); this.#closeTask = (async () => { try { - await (unregister ? this.#publication.close() : this.#publication.retire()); + try { + await (unregister ? this.#publication.close() : this.#publication.retire()); + } catch (error) { + // Session retirement is authoritative. If it wins the race with a local + // unregister, the obsolete unregister may reject even though withdrawal + // has already completed on the Host. + if (!this.#authoritativelyRetired) throw error; + } } finally { await managerClose; }