From 163a897f8df1e139f9b51a872889878e8fdddf1e Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 9 Sep 2026 12:28:49 +0800 Subject: [PATCH] feat(mcp): connect Desktop and TUI to Host form continuation Generated-by: OpenAI Codex --- .../mcp-form-host-integration.test.ts | 145 ++++++++ .../main/runtime-host-native-capabilities.ts | 1 + .../mcp-form-host-integration.test.ts | 161 +++++++++ packages/cli/src/mcp-capability-provider.ts | 5 +- .../test-only/client-capability-form-host.ts | 329 ++++++++++++++++++ .../src/test-only/client-capability-host.ts | 2 + 6 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts create mode 100644 packages/cli/src/__tests__/mcp-form-host-integration.test.ts create mode 100644 packages/runtime-host/src/test-only/client-capability-form-host.ts diff --git a/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts b/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts new file mode 100644 index 0000000000..cf797e6aa9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { MCP_CONFIG_VERSION } from '@maka/core/mcp'; +import { McpClientManager } from '@maka/mcp'; +import { createMcpFormFixture } from '@maka/mcp/test-only/form-server'; +import { withClientCapabilityFormHost } from '@maka/runtime-host/test-only/client-capability-host'; +import { buildMcpToolsWithIdentities } from '@maka/runtime/mcp-tools'; +import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; + +const values = { name: 'Ada', email: 'ada@example.com', confirm: true }; + +for (const action of ['accept', 'decline', 'cancel'] as const) { + test(`Desktop modern MCP ${action} travels through the canonical Host form and back to the server`, { timeout: 15_000 }, async () => { + await withFixture(async (manager, server, provider) => { + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const pending = await host.pending(); + assert.equal(pending.request.kind, 'form'); + assert.equal(server.calls.length, 1); + // The real broker has paused its execution timer while the Host owns the form. + assert.equal(host.timers.size, 0); + await new Promise((resolve) => setTimeout(resolve, action === 'accept' ? 1_100 : 30)); + assert.equal(host.timers.size, 0); + assert.equal((await host.store.listSessionPending('session_1')).length, 1); + if (action === 'accept') { + const rejected = await host.answer(pending.requestId, { action, values: { ...values, confirm: 'yes' } }); + assert.equal(rejected.ok, false); + assert.equal(server.calls.length, 1); + } + const answered = await host.answer(pending.requestId, action === 'accept' ? { action, values } : { action }); + assert.equal(answered.ok, true, JSON.stringify(answered)); + const settled = await result; + assert.deepEqual(settled.result, { content: [{ type: 'text', text: 'complete' }] }); + assert.equal(server.calls.length, 2); + assert.notEqual(server.calls[0]?.id, server.calls[1]?.id); + assert.deepEqual(server.calls[1]?.params.arguments, server.calls[0]?.params.arguments); + assert.equal(server.calls[1]?.params.requestState, 'opaque-state'); + assert.deepEqual(server.calls[1]?.params.inputResponses, { + form: action === 'accept' ? { action, content: values } : { action }, + }); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + assert.equal(host.events.filter((event) => event.type === 'form_answer_ack').length, 1); + assert.equal(JSON.stringify(host.events).includes('opaque-state'), false); + assert.ok(manager.status('fixture')); + }); + }); + }); +} + +test('Desktop handles a second MCP question as a new canonical Host form', { timeout: 15_000 }, async () => { + await withFixture(async (_manager, server, provider) => { + const originalRespond = server.respond; + let answeredRounds = 0; + server.respond = (params) => { + if (params.inputResponses && ++answeredRounds === 1) return originalRespond({ name: params.name }); + return originalRespond(params); + }; + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const first = await host.pending(); + assert.equal((await host.answer(first.requestId, { action: 'decline' })).ok, true); + const second = await host.pending(); + assert.notEqual(second.requestId, first.requestId); + assert.equal(server.calls.length, 2); + assert.equal((await host.answer(second.requestId, { action: 'accept', values })).ok, true); + assert.deepEqual((await result).result, { content: [{ type: 'text', text: 'complete' }] }); + assert.equal(server.calls.length, 3); + assert.deepEqual(server.calls[1]?.params.inputResponses, { form: { action: 'decline' } }); + assert.deepEqual(server.calls[2]?.params.inputResponses, { form: { action: 'accept', content: values } }); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + }); + }); +}); + +for (const exit of ['stop', 'disconnect', 'provider loss'] as const) { + test(`Desktop ${exit} closes a pending modern MCP Host form without a user answer or retry`, { timeout: 15_000 }, async () => { + await withFixture(async (manager, server, provider) => { + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const pending = await host.pending(); + if (exit === 'stop') await host.stop(); + else if (exit === 'provider loss') await host.disconnectProvider(); + else await manager.disconnect('fixture'); + await result; + const stored = await host.store.readInteraction(pending.requestId); + assert.equal(stored?.outcome?.outcome.kind, 'closure'); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + assert.equal(server.calls.length, 1); + assert.equal(host.events.filter((event) => event.type === 'form_answer_ack').length, 0); + assert.equal((await host.answer(pending.requestId, { action: 'accept', values })).ok, false); + assert.equal(host.timers.size, 0); + }); + }); + }); +} + +async function withFixture(run: ( + manager: McpClientManager, + server: Awaited>, + provider: ReturnType, +) => Promise) { + const server = await createMcpFormFixture(); + const manager = new McpClientManager({ timeouts: { callToolMs: 1_000 } }); + try { + await manager.sync({ version: MCP_CONFIG_VERSION, mcpServers: { + fixture: { url: server.url, transport: 'streamable-http', protocol: '2026-07-28' }, + } }); + const provider = createProvider(manager); + try { await run(manager, server, provider); } finally { await provider.close(); } + } finally { + await manager.close(); + await server.close(); + } +} + +function createProvider(manager: McpClientManager) { + return createDesktopNativeCapabilityProvider({ + browserTools: [], resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, computerUseTools: [] as never, + releaseDesktopInteractionSession() {}, + additionalGroups: () => [{ + offerId: 'desktop_mcp_fixture', label: 'MCP fixture', description: 'Modern form fixture', + tools: buildMcpToolsWithIdentities(manager), dynamic: true, + }], + }); +} diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index eecd4eed54..fdaf0fe129 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -442,6 +442,7 @@ async function invokeNativeTool( toolCallId: frame.toolCallId, abortSignal: signal, emitOutput() {}, + requestUserForm: options.requestInteraction, ...(options.progress ? { emitProgress: options.progress } : {}), }); const output = await (admissionEvidence.kind === "browser_url" diff --git a/packages/cli/src/__tests__/mcp-form-host-integration.test.ts b/packages/cli/src/__tests__/mcp-form-host-integration.test.ts new file mode 100644 index 0000000000..a8e495f734 --- /dev/null +++ b/packages/cli/src/__tests__/mcp-form-host-integration.test.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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { MCP_CONFIG_VERSION } from '@maka/core/mcp'; +import { McpClientManager } from '@maka/mcp'; +import { createMcpFormFixture } from '@maka/mcp/test-only/form-server'; +import { withClientCapabilityFormHost } from '@maka/runtime-host/test-only/client-capability-host'; +import { createMcpCapabilityProvider } from '../mcp-capability-provider.js'; + +const values = { name: 'Ada', email: 'ada@example.com', confirm: true }; + +for (const action of ['accept', 'decline', 'cancel'] as const) { + test(`TUI modern MCP ${action} travels through the canonical Host form and back to the server`, { + timeout: 15_000, + }, async () => { + await withFixture(async (manager, server, provider) => { + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const pending = await host.pending(); + assert.equal(pending.request.kind, 'form'); + assert.equal(server.calls.length, 1); + // The real broker has paused its execution timer while the Host owns the form. + assert.equal(host.timers.size, 0); + await new Promise((resolve) => setTimeout(resolve, action === 'accept' ? 1_100 : 30)); + assert.equal(host.timers.size, 0); + assert.equal((await host.store.listSessionPending('session_1')).length, 1); + if (action === 'accept') { + const rejected = await host.answer(pending.requestId, { + action, + values: { ...values, confirm: 'yes' }, + }); + assert.equal(rejected.ok, false); + assert.equal(server.calls.length, 1); + } + const answered = await host.answer( + pending.requestId, + action === 'accept' ? { action, values } : { action }, + ); + assert.equal(answered.ok, true, JSON.stringify(answered)); + const settled = await result; + assert.deepEqual(settled.result, { content: [{ type: 'text', text: 'complete' }] }); + assert.equal(server.calls.length, 2); + assert.notEqual(server.calls[0]?.id, server.calls[1]?.id); + assert.deepEqual(server.calls[1]?.params.arguments, server.calls[0]?.params.arguments); + assert.equal(server.calls[1]?.params.requestState, 'opaque-state'); + assert.deepEqual(server.calls[1]?.params.inputResponses, { + form: action === 'accept' ? { action, content: values } : { action }, + }); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + assert.equal(host.events.filter((event) => event.type === 'form_answer_ack').length, 1); + assert.equal(JSON.stringify(host.events).includes('opaque-state'), false); + assert.ok(manager.status('fixture')); + }); + }); + }); +} + +test('TUI handles a second MCP question as a new canonical Host form', { + timeout: 15_000, +}, async () => { + await withFixture(async (_manager, server, provider) => { + const originalRespond = server.respond; + let answeredRounds = 0; + server.respond = (params) => { + if (params.inputResponses && ++answeredRounds === 1) + return originalRespond({ name: params.name }); + return originalRespond(params); + }; + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const first = await host.pending(); + assert.equal((await host.answer(first.requestId, { action: 'decline' })).ok, true); + const second = await host.pending(); + assert.notEqual(second.requestId, first.requestId); + assert.equal(server.calls.length, 2); + assert.equal((await host.answer(second.requestId, { action: 'accept', values })).ok, true); + assert.deepEqual((await result).result, { content: [{ type: 'text', text: 'complete' }] }); + assert.equal(server.calls.length, 3); + assert.deepEqual(server.calls[1]?.params.inputResponses, { form: { action: 'decline' } }); + assert.deepEqual(server.calls[2]?.params.inputResponses, { + form: { action: 'accept', content: values }, + }); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + }); + }); +}); + +for (const exit of ['stop', 'disconnect', 'provider loss'] as const) { + test(`TUI ${exit} closes a pending modern MCP Host form without a user answer or retry`, { + timeout: 15_000, + }, async () => { + await withFixture(async (manager, server, provider) => { + await withClientCapabilityFormHost(provider, async (host) => { + const result = host.start(); + const pending = await host.pending(); + if (exit === 'stop') await host.stop(); + else if (exit === 'provider loss') await host.disconnectProvider(); + else await manager.disconnect('fixture'); + await result; + const stored = await host.store.readInteraction(pending.requestId); + assert.equal(stored?.outcome?.outcome.kind, 'closure'); + assert.equal((await host.store.listSessionPending('session_1')).length, 0); + assert.equal(server.calls.length, 1); + assert.equal(host.events.filter((event) => event.type === 'form_answer_ack').length, 0); + assert.equal( + (await host.answer(pending.requestId, { action: 'accept', values })).ok, + false, + ); + assert.equal(host.timers.size, 0); + }); + }); + }); +} + +async function withFixture( + run: ( + manager: McpClientManager, + server: Awaited>, + provider: ReturnType, + ) => Promise, +) { + const server = await createMcpFormFixture(); + const manager = new McpClientManager({ timeouts: { callToolMs: 1_000 } }); + try { + await manager.sync({ + version: MCP_CONFIG_VERSION, + mcpServers: { + fixture: { url: server.url, transport: 'streamable-http', protocol: '2026-07-28' }, + }, + }); + const provider = createProvider(manager); + await run(manager, server, provider); + } finally { + await manager.close(); + await server.close(); + } +} + +function createProvider(manager: McpClientManager) { + const provider = createMcpCapabilityProvider(manager); + assert.ok(provider); + return provider; +} diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts index 92f331fd58..ca0849514d 100644 --- a/packages/cli/src/mcp-capability-provider.ts +++ b/packages/cli/src/mcp-capability-provider.ts @@ -107,7 +107,10 @@ export function createMcpCapabilityProvider( if (!binding) throw new Error('MCP capability is not part of the published snapshot'); await options.accept({ kind: 'none' }); return projectMcpResult( - await manager.callTool(binding, frame.arguments, { signal: options.signal }), + await manager.callTool(binding, frame.arguments, { + signal: options.signal, + requestInteraction: options.requestInteraction, + }), ); }, }; diff --git a/packages/runtime-host/src/test-only/client-capability-form-host.ts b/packages/runtime-host/src/test-only/client-capability-form-host.ts new file mode 100644 index 0000000000..1c9a04d4b4 --- /dev/null +++ b/packages/runtime-host/src/test-only/client-capability-form-host.ts @@ -0,0 +1,329 @@ +/* + * 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 { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { SessionHeader } from '@maka/core/session'; +import type { SessionEvent } from '@maka/core/events'; +import type { InteractionFormResult } from '@maka/core/interaction'; +import { ToolRuntime } from '@maka/runtime/tool-runtime'; +import { bindRuntimeInteractionRun } from '@maka/runtime/interaction-authority'; +import { + openInteractiveExecutionStoresForWrite, + type ExecutionStoresWriter, +} from '@maka/storage/execution-stores'; +import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type InteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + HostInteractionCoordinator, + type HostInteractionCoordinatorOptions, +} from '../server/interaction-coordinator.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; +import { ClientCapabilityChannel } from '../client/client-capability-channel.js'; +import type { ClientCapabilityProvider } from '../client/client-capability.js'; +import { ClientCapabilityInvocationBroker } from '../server/client-capability-invocation-broker.js'; + +const RUN = Object.freeze({ sessionId: 'session_1', turnId: 'turn_1', runId: 'run_1' }); + +/** Real Runtime/Host form authority and production capability transport, with an in-memory wire. */ +export async function withClientCapabilityFormHost( + provider: ClientCapabilityProvider, + run: (host: Awaited>) => Promise, +): Promise { + await withStore(async ({ store }) => { + const host = await createFormHost(provider, store); + try { + await run(host); + } finally { + await host.close(); + } + }); +} + +async function createFormHost( + provider: ClientCapabilityProvider, + store: InteractiveInteractionStoreWriterFacade, +) { + const interactions = createInteractionCoordinator(store); + const binding = await bindRuntimeInteractionRun(interactions, RUN); + const events: SessionEvent[] = []; + const timers = new Set<() => void>(); + let registrationId = ''; + let channel!: ClientCapabilityChannel; + const broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ + send: async (frame) => { + queueMicrotask(() => channel.accept(frame)); + }, + }), + onRegistrationIdle: () => {}, + scheduleTimeout: (callback, timeoutMs) => { + timers.add(callback); + const timer = setTimeout(() => { + timers.delete(callback); + callback(); + }, timeoutMs); + return () => { + clearTimeout(timer); + timers.delete(callback); + }; + }, + }); + channel = new ClientCapabilityChannel({ + write: async (frame) => { + broker.accept('provider', frame); + }, + replace: async (input) => { + registrationId = input.registrationId; + return { registrationId, revision: 1 }; + }, + unregister: async (input) => ({ registrationId: input.registrationId, revision: 2 }), + onFailure: (error) => { + throw error; + }, + }); + await channel.replace(provider, 1_000); + const offer = provider.offers()[0]; + assert.ok(offer); + const descriptor = offer.tools[0]; + assert.ok(descriptor); + const runtime = new ToolRuntime({ + sessionId: RUN.sessionId, + header: sessionHeader(), + connection: llmConnection(), + modelId: 'model-1', + readExecutionBoundary: async () => + createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + turnId: RUN.turnId, + runId: RUN.runId, + invocationId: 'invocation-1', + hostedInteraction: binding, + runtimeCommitSink: { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + }); + const controller = new AbortController(); + let current: ReturnType | undefined; + return { + events, + store, + timers, + start() { + current = runtime.settleToolCall({ + tool: { + name: 'CapabilityForm', + description: 'Invoke a published capability through the real broker.', + parameters: z.object({}), + nesting: 'direct_only', + impl: (args, context) => + broker.invoke( + { connectionId: 'provider', registrationId }, + { offerId: offer.offerId, hostPathAccess: offer.hostPathAccess, descriptor }, + args, + { ...RUN, toolCallId: 'tool-1', cwd: '/tmp' }, + context.abortSignal, + 1_000, + undefined, + context.requestUserForm, + ), + }, + turnId: RUN.turnId, + stepId: 'step-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: controller.signal, + eventSink: { + push: (event) => { + if (event.type === 'form_request') binding.assertPendingAdmission(event); + events.push(event); + }, + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }); + return current; + }, + async pending() { + for (let attempt = 0; attempt < 500; attempt += 1) { + const pending = await store.listSessionPending(RUN.sessionId); + if ( + pending[0] && + events.some( + (event) => event.type === 'form_request' && event.requestId === pending[0]?.requestId, + ) + ) + return pending[0]; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('MCP form was not published by the Host'); + }, + answer(interactionId: string, result: InteractionFormResult) { + return interactions.handlers['interaction.answer']( + { + sessionId: RUN.sessionId, + interactionId, + answer: { kind: 'form', ...result }, + }, + connectionContext(), + ); + }, + async disconnectProvider() { + channel.close(new Error('Provider disconnected')); + await broker.releaseConnection('provider'); + }, + async stop() { + controller.abort(new DOMException('Turn stopped', 'AbortError')); + await binding.close('turn_stopped'); + await binding.settleLocalClosures(); + await current; + }, + async close() { + controller.abort(new DOMException('Test finished', 'AbortError')); + await binding.close('turn_terminal'); + await binding.settleLocalClosures(); + await current; + channel.close(new Error('Test finished')); + broker.close(); + binding.release(); + await interactions.close(); + }, + }; +} + +function sessionHeader(): SessionHeader { + return { + id: RUN.sessionId, + workspaceRoot: '/tmp', + cwd: '/tmp', + createdAt: 1, + name: 'test', + titleIsManual: false, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'connection-1', + connectionLocked: true, + model: 'model-1', + permissionMode: 'ask', + schemaVersion: 1, + }; +} + +function llmConnection(): LlmConnection { + return { + slug: 'connection-1', + name: 'test', + providerType: 'openai', + defaultModel: 'model-1', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; +} + +function nextId(): () => string { + let value = 0; + return () => `event-${++value}`; +} + +function nextNow(): () => number { + let value = 100; + return () => ++value; +} + +function connectionContext(connectionId = 'form-ui'): ConnectionContext { + return { + hostEpoch: 'host_epoch_1', + connectionId, + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), + }; +} + +function createInteractionCoordinator( + store: InteractiveInteractionStoreWriterFacade, +): HostInteractionCoordinator { + let now = 100; + const options: HostInteractionCoordinatorOptions = { + store, + sandboxBoundaries: { + createSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary publication'); + }, + readSandboxBoundaryRequest: async () => undefined, + listPendingSandboxBoundaryRequests: async () => [], + settleSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary settlement'); + }, + listHeaders: async () => [], + }, + sessionAdmission: new SessionAdmissionGate(), + sessions: { probeSessionRemoval: async () => ({ kind: 'present' }) }, + now: () => ++now, + preflightSessionSnapshot: () => true, + refreshCanonicalContinuity: async () => undefined, + onPoison: () => undefined, + onSandboxBoundarySettled: async () => undefined, + }; + return new HostInteractionCoordinator(options); +} + +interface StoreContext { + readonly owner: InteractiveRootOwner; + readonly store: InteractiveInteractionStoreWriterFacade; + readonly stores: ExecutionStoresWriter<'interactive'>; +} + +async function withStore(run: (context: StoreContext) => Promise): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-client-capability-admission-')); + const root = join(base, 'root'); + await mkdir(root); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + await run({ owner, store: stores.interactionStore, stores }); + } finally { + if (!owner.closed) await owner.close(); + await rm(owner.controlDirectory, { recursive: true, force: true }); + await rm(base, { recursive: true, force: true }); + } +} 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 c82d429b45..3d59826eb1 100644 --- a/packages/runtime-host/src/test-only/client-capability-host.ts +++ b/packages/runtime-host/src/test-only/client-capability-host.ts @@ -46,3 +46,5 @@ export function clientCapabilityCoordinatorTestAdmission() { }, }; } + +export { withClientCapabilityFormHost } from './client-capability-form-host.js';