diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 4bffffaf3a..6057991861 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 31 | -| platform-contract | 33 | +| platform-contract | 34 | -Total Windows-excluded declarations: **91** +Total Windows-excluded declarations: **92** ## Inventory @@ -35,6 +35,7 @@ Total Windows-excluded declarations: **91** | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` keeps the inherited PATH and does not log shell stderr when capture fails | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` bounds shell output instead of buffering until the global timeout | `process.platform === 'win32'` | +| platform-contract | `packages/cli/src/__tests__/acp-prompt-content.test.ts` rejects a FIFO without blocking the process | `process.platform === 'win32'` | | portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` shortens POSIX paths under the home directory | `process.platform === 'win32'` | | portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` keeps POSIX paths outside the home directory absolute | `process.platform === 'win32'` | | portable-candidate | `packages/cli/src/__tests__/runtime-host-local-managed-activation.test.ts` local CLI cold-starts through the installed ${legacy ? 'legacy' : 'Node'} operator | `process.platform === 'win32'` | diff --git a/packages/cli/src/__tests__/acp-agent.test.ts b/packages/cli/src/__tests__/acp-agent.test.ts index 709f0f3ca4..ae5c53f293 100644 --- a/packages/cli/src/__tests__/acp-agent.test.ts +++ b/packages/cli/src/__tests__/acp-agent.test.ts @@ -23,13 +23,13 @@ import { client, methods, RequestError } from '@agentclientprotocol/sdk'; import { createMakaAcpAgent } from '../acp/maka-acp-agent.js'; describe('Maka ACP agent', () => { - test('returns the Maka identity and advertises only Session listing', async () => { + test('returns the Maka identity and advertises Session listing and close', async () => { await client({ name: 'test-client' }).connectWith( createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), async (agent) => { assert.deepEqual(await agent.request(methods.agent.initialize, { protocolVersion: 1 }), { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {} } }, + agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }); @@ -107,21 +107,50 @@ describe('Maka ACP agent', () => { ]); }); - test('does not implement or advertise session/close', async () => { - await client({ name: 'test-client' }).connectWith( - createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), + test('routes prompt, cancel, and close through the Session registry', async () => { + const prompts: unknown[] = []; + const cancellations: unknown[] = []; + const closes: unknown[] = []; + const updates: unknown[] = []; + const testClient = client({ name: 'test-client' }).onNotification( + methods.client.session.update, + ({ params }) => void updates.push(params), + ); + await testClient.connectWith( + createMakaAcpAgent({ + version: '0.2.0', + sessionRegistry: fakeSessionRegistry({ prompts, cancellations, closes }), + }), async (agent) => { - await assert.rejects( - agent.request(methods.agent.session.close, { sessionId: 'session-1' }), - (error: unknown) => { - assert.ok(error instanceof RequestError); - assert.equal(error.code, -32601); - assert.deepEqual(error.data, { method: 'session/close' }); - return true; - }, + assert.deepEqual( + await agent.request(methods.agent.session.prompt, { + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'hello' }], + }), + { stopReason: 'end_turn' }, + ); + await agent.notify(methods.agent.session.cancel, { sessionId: 'session-1' }); + assert.deepEqual( + await agent.request(methods.agent.session.close, { sessionId: 'session-1' }), + {}, ); }, ); + assert.deepEqual(prompts, [ + { sessionId: 'session-1', prompt: [{ type: 'text', text: 'hello' }] }, + ]); + assert.deepEqual(cancellations, [{ sessionId: 'session-1' }]); + assert.deepEqual(closes, [{ sessionId: 'session-1' }]); + assert.deepEqual(updates, [ + { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + messageId: 'message-1', + }, + }, + ]); }); test('does not implement session/set_mode', async () => { @@ -158,7 +187,14 @@ describe('Maka ACP agent', () => { }); function fakeSessionRegistry( - observations: { creates?: unknown[]; lists?: unknown[]; configurationRequests?: unknown[] } = {}, + observations: { + creates?: unknown[]; + lists?: unknown[]; + configurationRequests?: unknown[]; + prompts?: unknown[]; + cancellations?: unknown[]; + closes?: unknown[]; + } = {}, ) { return { create: async (params: unknown) => { @@ -196,5 +232,22 @@ function fakeSessionRegistry( ], }; }, + prompt: async (params: unknown, context: { notify(notification: unknown): Promise }) => { + observations.prompts?.push(params); + await context.notify({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + messageId: 'message-1', + }, + }); + return { stopReason: 'end_turn' as const }; + }, + cancel: async (params: unknown) => void observations.cancellations?.push(params), + close: async (params: unknown) => { + observations.closes?.push(params); + return {}; + }, }; } diff --git a/packages/cli/src/__tests__/acp-child-process-harness.ts b/packages/cli/src/__tests__/acp-child-process-harness.ts index fd071d469d..25ecf797b9 100644 --- a/packages/cli/src/__tests__/acp-child-process-harness.ts +++ b/packages/cli/src/__tests__/acp-child-process-harness.ts @@ -48,6 +48,7 @@ export interface AcpChildProcessHarnessOptions { readonly model?: { readonly id: string; readonly thinkingLevels: readonly ThinkingLevel[]; + readonly baseUrl?: string; }; } @@ -364,7 +365,7 @@ async function seedModelConnection( slug: 'acp-fixture-model', name: 'ACP fixture model', providerType: 'openai-compatible', - baseUrl: 'https://acp-model.invalid/v1', + baseUrl: model.baseUrl ?? 'https://acp-model.invalid/v1', enabled: true, enabledModelIds: [model.id], ...(model.thinkingLevels.length === 0 diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index f0b2b129bc..5347537348 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -19,10 +19,20 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; -import { realpath } from 'node:fs/promises'; +import { realpath, writeFile } from 'node:fs/promises'; +import { createServer, type ServerResponse } from 'node:http'; +import { join } from 'node:path'; import { PassThrough } from 'node:stream'; +import { pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; -import { RequestError, methods } from '@agentclientprotocol/sdk'; +import { methods, type SessionNotification } from '@agentclientprotocol/sdk'; +import { waitFor } from '@maka/core/test-only/async-primitives'; +import { connectRuntimeHost } from '@maka/runtime-host/client'; +import { + ARTIFACT_INGEST_CHUNK_MAX_BYTES, + RUNTIME_HOST_PROTOCOL_VERSION, +} from '@maka/runtime-host/protocol'; +import { getRuntimeHostSession } from '../runtime-host-session-update.js'; import { pipeCapturedStdout, StdoutCaptureBridge, @@ -119,7 +129,7 @@ describe('Maka ACP child process', () => { await harness.withClient(async ({ context }) => { assert.deepEqual(await context.request(methods.agent.initialize, { protocolVersion: 1 }), { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {} } }, + agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }); @@ -194,14 +204,16 @@ describe('Maka ACP child process', () => { true, ); - await assert.rejects( - context.request(methods.agent.session.close, { sessionId: first.sessionId }), - (error: unknown) => { - assert.ok(error instanceof RequestError); - assert.equal(error.code, -32601); - assert.deepEqual(error.data, { method: 'session/close' }); - return true; - }, + assert.deepEqual( + await context.request(methods.agent.session.close, { sessionId: first.sessionId }), + {}, + ); + const listedAfterClose = await context.request(methods.agent.session.list, { + cwd: harness.workspaceRoot, + }); + assert.equal( + listedAfterClose.sessions.some((session) => session.sessionId === first.sessionId), + true, ); }); @@ -325,8 +337,340 @@ describe('Maka ACP child process', () => { { startRuntimeHost: true }, ); }); + + test('publishes local resource links as Session Artifacts before prompting the real Host', { + timeout: 30_000, + }, async () => { + const model = await startAcpModelFixture(); + try { + await withAcpChildProcessHarness( + async (harness) => { + const path = join(harness.workspaceRoot, 'notes.txt'); + const contents = 'x'.repeat(ARTIFACT_INGEST_CHUNK_MAX_BYTES + 7); + await writeFile(path, contents); + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [ + { type: 'text', text: 'COMPLETE_ME' }, + { + type: 'resource_link', + uri: pathToFileURL(path).href, + name: 'notes.txt', + mimeType: 'text/plain', + }, + ], + }), + { stopReason: 'end_turn' }, + ); + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + if (connected.kind !== 'connected') assert.fail('Host connection unavailable'); + try { + const listed = await connected.connection.request('artifact.query', { + kind: 'list_start', + sessionId, + }); + assert.equal(listed.kind, 'page'); + if (listed.kind !== 'page') assert.fail('Expected Artifact page'); + assert.equal(listed.artifacts.length, 1); + assert.equal(listed.artifacts[0]!.name, 'notes.txt'); + assert.equal(listed.artifacts[0]!.sizeBytes, contents.length); + } finally { + await connected.connection.close(); + } + await context.request(methods.agent.session.close, { sessionId }); + }); + }, + { + startRuntimeHost: true, + model: { id: 'attachment-fixture', thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('Host admission rejects an extra attachment before starting a Turn and close releases capacity', { + timeout: 60_000, + }, async () => { + const model = await startAcpModelFixture(); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const ids: string[] = []; + for (let index = 0; index < 17; index += 1) { + ids.push( + ( + await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }) + ).sessionId, + ); + } + const prompt = (sessionId: string) => + context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }); + // Independent Sessions can finish concurrently. All attachments must + // remain retained after completion before we test the next admission. + await Promise.all( + ids.slice(0, 16).map(async (id) => { + assert.deepEqual(await prompt(id), { stopReason: 'end_turn' }); + }), + ); + await assert.rejects(prompt(ids[16]!), (error: unknown) => { + assert.equal( + (error as { data?: { operation?: string } }).data?.operation, + 'subscription.open', + ); + return true; + }); + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') assert.fail(); + try { + const untouched = await connected.connection.openSessionSubscription({ + sessionId: ids[16]!, + transcript: { kind: 'none' }, + }); + assert.equal( + untouched.snapshot.rootTurn, + null, + 'capacity rejection must not create a Turn', + ); + await untouched.close(); + await context.request(methods.agent.session.close, { sessionId: ids[0]! }); + assert.ok(await getRuntimeHostSession(connected.connection, ids[0]!)); + assert.deepEqual(await prompt(ids[16]!), { stopReason: 'end_turn' }); + } finally { + await connected.connection.close(); + } + }); + }, + { + startRuntimeHost: true, + // This operation covers 17 creates and 17 complete Turns, not one RPC. + timeoutMs: 45_000, + model: { id: 'capacity-fixture', thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + + test('streams, cancels, and closes through the real ACP and Runtime Host process boundary', { + timeout: 30_000, + }, async () => { + const model = await startAcpModelFixture(); + try { + await withAcpChildProcessHarness( + async (harness) => { + 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: [], + }); + + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }), + { stopReason: 'end_turn' }, + ); + assert.equal( + updates.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('ACP fixture completed'), + ), + true, + ); + + // A second client mutates the Host while ACP retains its attachment. + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') assert.fail('Host connection unavailable'); + try { + const current = await getRuntimeHostSession( + connected.connection, + created.sessionId, + ); + assert.ok(current); + const changed = await connected.connection.request('session.configuration.update', { + sessionId: created.sessionId, + expectedRevision: current.revision, + patch: { permissionMode: 'bypass', thinkingLevel: 'low' }, + }); + assert.equal(changed.kind, 'committed'); + await waitFor( + () => + updates.some( + ({ update }) => + update.sessionUpdate === 'config_option_update' && + update.configOptions.some( + (option) => + option.id === 'permission_mode' && option.currentValue === 'bypass', + ) && + update.configOptions.some( + (option) => + option.id === 'thinking_level' && option.currentValue === 'low', + ), + ), + { timeoutMs: 5000, pollMs: 10, message: 'external configuration notification' }, + ); + } finally { + await connected.connection.close(); + } + + const cancelled = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'CANCEL_ME' }], + }); + await model.cancelStarted; + await context.notify(methods.agent.session.cancel, { sessionId: created.sessionId }); + assert.deepEqual(await cancelled, { stopReason: 'cancelled' }); + assert.deepEqual( + await context.request(methods.agent.session.close, { + sessionId: created.sessionId, + }), + {}, + ); + }, + (app) => + app.onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }), + ); + + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + assert.equal(harness.stderr, ''); + }, + { + startRuntimeHost: true, + model: { + id: 'acp-stream-fixture', + thinkingLevels: ['low'], + baseUrl: model.baseUrl, + }, + }, + ); + } finally { + await model.close(); + } + }); }); +async function startAcpModelFixture(): Promise<{ + readonly baseUrl: string; + readonly cancelStarted: Promise; + close(): Promise; +}> { + let markCancelStarted!: () => void; + const cancelStarted = new Promise((resolve) => { + markCancelStarted = resolve; + }); + const server = createServer((request, response) => { + void readBody(request) + .then((body) => { + if (body.includes('CANCEL_ME')) { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write(`data: ${JSON.stringify(modelChunk('partial', null))}\n\n`); + markCancelStarted(); + request.once('close', () => response.end()); + return; + } + if (body.includes('COMPLETE_ME')) { + respondModelText(response, 'ACP fixture completed.'); + return; + } + respondModelText(response, 'ACP fixture session'); + }) + .catch((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`, + cancelStarted, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function respondModelText(response: ServerResponse, text: string): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write(`data: ${JSON.stringify(modelChunk(text, null))}\n\n`); + response.write(`data: ${JSON.stringify(modelChunk('', 'stop'))}\n\n`); + response.end('data: [DONE]\n\n'); +} + +function modelChunk(text: string, finishReason: 'stop' | null) { + return { + id: 'chatcmpl-acp-fixture', + object: 'chat.completion.chunk', + created: 1, + model: 'acp-stream-fixture', + choices: [ + { + index: 0, + delta: finishReason === null ? { role: 'assistant', content: text } : {}, + finish_reason: finishReason, + }, + ], + ...(finishReason === 'stop' + ? { usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } } + : {}), + }; +} + +function readBody(request: import('node:http').IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); +} + function assertJsonRpcMessage(message: unknown): void { assert.ok(message && typeof message === 'object' && !Array.isArray(message)); const record = message as Record; diff --git a/packages/cli/src/__tests__/acp-prompt-content.test.ts b/packages/cli/src/__tests__/acp-prompt-content.test.ts new file mode 100644 index 0000000000..af881cf946 --- /dev/null +++ b/packages/cli/src/__tests__/acp-prompt-content.test.ts @@ -0,0 +1,318 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, test } from 'node:test'; +import { RequestError, type ContentBlock } from '@agentclientprotocol/sdk'; +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { + ARTIFACT_INGEST_CHUNK_MAX_BYTES, + type ArtifactIngestInput, +} from '@maka/runtime-host/protocol'; +import { mapAcpPromptContent, publishAcpPromptAttachments } from '../acp/prompt-content.js'; + +describe('ACP prompt content', () => { + test('rejects a FIFO without blocking the process', { + skip: process.platform === 'win32', + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-fifo-')); + const fifo = join(root, 'pipe'); + execFileSync('mkfifo', [fifo]); + const child = spawn( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import assert from 'node:assert/strict'; + import { mapAcpPromptContent } from ${JSON.stringify(new URL('../acp/prompt-content.js', import.meta.url).href)}; + await assert.rejects(mapAcpPromptContent([{ type: 'resource_link', name: 'pipe', uri: process.argv[1] }]), + { data: { field: 'prompt', reason: 'resource_not_file' } }); + `, + pathToFileURL(fifo).href, + ], + { stdio: 'pipe' }, + ); + let stderr = ''; + child.stderr.on('data', (data) => { + stderr += data; + }); + const timeout = setTimeout(() => child.kill('SIGKILL'), 3000); + try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', resolve); + }); + assert.equal(code, 0, `FIFO reader must reject and exit: ${stderr}`); + } finally { + clearTimeout(timeout); + child.kill(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('joins ordered text blocks with paragraph separators', async () => { + assert.deepEqual( + await mapAcpPromptContent([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ]), + { text: 'first\n\nsecond' }, + ); + }); + + test('maps an ordinary local resource link to an external-file attachment', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-prompt-')); + const path = join(root, 'note.txt'); + await writeFile(path, 'hello'); + const uri = pathToFileURL(path).href; + try { + assert.deepEqual( + await mapAcpPromptContent([ + { type: 'text', text: 'read this' }, + { type: 'resource_link', uri, name: 'note.txt', mimeType: 'text/plain' }, + ]), + { + text: `read this\n\n${uri}`, + displayText: 'read this', + attachments: [ + { + kind: 'other', + name: 'note.txt', + mimeType: 'text/plain', + bytes: 5, + ref: { kind: 'external_file', absolutePath: await realpath(path) }, + }, + ], + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps a resource-only prompt model-visible while its display text is empty', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-prompt-')); + const path = join(root, 'image.bin'); + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const uri = pathToFileURL(path).href; + try { + const mapped = await mapAcpPromptContent([ + { type: 'resource_link', uri, name: basename(path), mimeType: 'application/octet-stream' }, + ]); + assert.equal(mapped.text, uri); + assert.equal(mapped.displayText, ''); + assert.equal(mapped.attachments?.[0]?.kind, 'image'); + assert.equal(mapped.attachments?.[0]?.mimeType, 'image/png'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects unadvertised content kinds and non-local or non-file resources', async () => { + for (const prompt of [ + [{ type: 'image', data: '', mimeType: 'image/png' }], + [{ type: 'audio', data: '', mimeType: 'audio/wav' }], + [{ type: 'resource', resource: { uri: 'file:///tmp/x', text: 'x' } }], + [{ type: 'resource_link', uri: 'https://example.com/x', name: 'x' }], + [{ type: 'resource_link', uri: 'file:///tmp', name: 'tmp' }], + ] as ContentBlock[][]) { + await assert.rejects(mapAcpPromptContent(prompt), invalidPromptContent); + } + }); + + test('enforces the shared attachment count and size limits before admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-prompt-')); + const path = join(root, 'small.txt'); + await writeFile(path, 'x'); + const resource = { + type: 'resource_link' as const, + uri: pathToFileURL(path).href, + name: 'small.txt', + }; + try { + await assert.rejects( + mapAcpPromptContent(Array.from({ length: MAX_ATTACHMENT_COUNT + 1 }, () => resource)), + invalidPromptContent, + ); + await assert.rejects( + mapAcpPromptContent([resource], { + openFile: async () => ({ + size: MAX_ATTACHMENT_BYTES + 1, + isFile: true, + prefix: new Uint8Array(), + canonicalPath: path, + }), + }), + invalidPromptContent, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('publishes bounded chunks and uses canonical Host attachment metadata in input order', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-publish-')); + const path = join(root, 'notes.txt'); + const bytes = Buffer.alloc(ARTIFACT_INGEST_CHUNK_MAX_BYTES + 7, 'x'); + await writeFile(path, bytes); + const prompt = [ + { type: 'text' as const, text: 'first' }, + { type: 'resource_link' as const, uri: pathToFileURL(path).href, name: 'notes.txt' }, + { type: 'text' as const, text: 'last' }, + ]; + const operations: ArtifactIngestInput[] = []; + const attachment = { + kind: 'other' as const, + name: 'canonical-notes.txt', + mimeType: 'text/plain', + bytes: bytes.length, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'artifact-1' }, + }; + const connection = { + request: async (operation: string, input: ArtifactIngestInput) => { + assert.equal(operation, 'artifact.ingest'); + operations.push(input); + if (input.kind === 'begin') { + assert.equal(input.totalBytes, bytes.length); + assert.equal( + input.contentSha256, + `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + ); + return { kind: 'upload_opened', uploadId: input.uploadId, nextOffset: 0 }; + } + if (input.kind === 'chunk') { + const chunk = Buffer.from(input.chunkBase64, 'base64'); + assert.ok(chunk.length <= ARTIFACT_INGEST_CHUNK_MAX_BYTES); + return { + kind: 'chunk_accepted', + uploadId: input.uploadId, + nextOffset: input.offset + chunk.length, + }; + } + assert.equal(input.kind, 'commit'); + return { kind: 'committed', uploadId: input.uploadId, attachment }; + }, + } as unknown as Pick; + try { + const content = await mapAcpPromptContent(prompt); + const published = await publishAcpPromptAttachments(content, { + sessionId: 'session-1', + connection, + assertActive: () => undefined, + }); + assert.deepEqual(published, { ...content, attachments: [attachment] }); + assert.equal(published.text, `first\n\n${pathToFileURL(path).href}\n\nlast`); + assert.equal(published.displayText, 'first\n\nlast'); + assert.deepEqual( + operations.map((input) => input.kind), + ['begin', 'chunk', 'chunk', 'commit'], + ); + assert.equal(new Set(operations.map((input) => input.uploadId)).size, 1); + assert.deepEqual( + Buffer.concat( + operations.flatMap((input) => + input.kind === 'chunk' ? [Buffer.from(input.chunkBase64, 'base64')] : [], + ), + ), + bytes, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + for (const interruption of ['cancelled', 'failed'] as const) { + test(`aborts an open Artifact upload when a chunk is ${interruption}`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-publish-')); + const path = join(root, 'notes.txt'); + await writeFile(path, 'hello'); + const failure = new Error(interruption); + let active = true; + const operations: ArtifactIngestInput['kind'][] = []; + const connection = { + request: async (_operation: string, input: ArtifactIngestInput) => { + operations.push(input.kind); + if (input.kind === 'begin') + return { kind: 'upload_opened', uploadId: input.uploadId, nextOffset: 0 }; + if (input.kind === 'chunk') { + if (interruption === 'failed') throw failure; + active = false; + return { kind: 'chunk_accepted', uploadId: input.uploadId, nextOffset: 5 }; + } + assert.equal(input.kind, 'abort'); + return { kind: 'upload_aborted', uploadId: input.uploadId }; + }, + } as unknown as Pick; + try { + const content = await mapAcpPromptContent([ + { type: 'resource_link', uri: pathToFileURL(path).href, name: 'notes.txt' }, + ]); + await assert.rejects( + publishAcpPromptAttachments(content, { + sessionId: 'session-1', + connection, + assertActive: () => { + if (!active) throw failure; + }, + }), + (error: unknown) => error === failure, + ); + assert.deepEqual(operations, ['begin', 'chunk', 'abort']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } + + test('rechecks the file size before opening an Artifact upload', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-acp-publish-')); + const path = join(root, 'notes.txt'); + await writeFile(path, 'hello'); + try { + const content = await mapAcpPromptContent([ + { type: 'resource_link', uri: pathToFileURL(path).href, name: 'notes.txt' }, + ]); + await truncate(path, MAX_ATTACHMENT_BYTES + 1); + await assert.rejects( + publishAcpPromptAttachments(content, { + sessionId: 'session-1', + connection: { + request: async () => assert.fail('Oversized files must not open an upload'), + }, + assertActive: () => undefined, + }), + { data: { field: 'prompt', reason: 'resource_too_large' } }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function invalidPromptContent(error: unknown): boolean { + return error instanceof RequestError && error.code === -32602; +} diff --git a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts new file mode 100644 index 0000000000..45aec10a3d --- /dev/null +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -0,0 +1,189 @@ +/* + * 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 type { SessionEvent } from '@maka/core/events'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { AcpSessionEventMapper } from '../acp/session-event-mapper.js'; + +describe('ACP Session event mapper', () => { + test('streams text and thinking while deduplicating matching completion events', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + + await mapper.accept(event({ type: 'text_delta', messageId: 'answer', text: 'hel' })); + await mapper.accept(event({ type: 'text_delta', messageId: 'answer', text: 'lo' })); + await mapper.accept(event({ type: 'text_complete', messageId: 'answer', text: 'hello' })); + await mapper.accept(event({ type: 'thinking_delta', messageId: 'thought', text: 'hmm' })); + await mapper.accept(event({ type: 'thinking_complete', messageId: 'thought', text: 'hmm' })); + + assert.deepEqual( + notifications.map(({ update }) => update), + [ + chunk('agent_message_chunk', 'answer', 'hel'), + chunk('agent_message_chunk', 'answer', 'lo'), + chunk('agent_thought_chunk', 'thought', 'hmm'), + ], + ); + }); + + test('rejects non-prefix revisions instead of reporting a second message or success', async () => { + for (const kind of ['text', 'thinking'] as const) { + for (const text of ['new', '']) { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(event({ type: `${kind}_delta`, messageId: 'answer', text: 'old' })); + await assert.rejects( + mapper.accept(event({ type: `${kind}_complete`, messageId: 'answer', text })), + { data: { source: 'adapter', code: 'unsupported_stream_revision' } }, + ); + await assert.rejects(mapper.accept(event({ type: 'complete', stopReason: 'end_turn' }))); + assert.deepEqual( + notifications.map(({ update }) => update), + [chunk(kind === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk', 'answer', 'old')], + ); + } + } + }); + + test('serializes canonical transcript replacement with live notifications', async () => { + const notifications: SessionNotification[] = []; + let releaseFirst!: () => void; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + let calls = 0; + const mapper = new AcpSessionEventMapper({ + sessionId: 'session-1', + notify: async (notification) => { + calls += 1; + if (calls === 1) await firstPending; + notifications.push(notification); + }, + }); + + const live = mapper.accept(event({ type: 'text_delta', messageId: 'answer', text: 'old' })); + const replacement = mapper.replaceTranscript('turn-1', [ + { + type: 'assistant', + id: 'answer', + turnId: 'turn-1', + ts: 2, + text: 'older', + modelId: 'model', + }, + ]); + releaseFirst(); + await Promise.all([live, replacement]); + + assert.equal(notifications.length, 2); + const update = notifications[1]?.update; + assert.equal(update?.sessionUpdate, 'agent_message_chunk'); + if (update?.sessionUpdate !== 'agent_message_chunk') return; + assert.equal(update.content.type === 'text' && update.content.text, 'er'); + assert.equal(update.messageId, 'answer'); + }); + + test('rejects a canonical message that clears already delivered thinking', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(event({ type: 'thinking_delta', messageId: 'answer', text: 'old' })); + await assert.rejects( + mapper.replaceTranscript('turn-1', [ + { + type: 'assistant', + id: 'answer', + turnId: 'turn-1', + ts: 2, + text: 'answer', + modelId: 'model', + }, + ]), + { data: { source: 'adapter', code: 'unsupported_stream_revision' } }, + ); + await assert.rejects(mapper.accept(event({ type: 'complete', stopReason: 'end_turn' }))); + assert.equal(notifications.length, 1); + }); + + test('leaves terminal classification to the Runtime Host Session channel', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.accept(event({ type: 'error', recoverable: true, message: 'retry' })); + await mapper.accept(event({ type: 'error', recoverable: false, message: 'failed' })); + await mapper.accept(event({ type: 'complete', stopReason: 'end_turn' })); + await mapper.accept(event({ type: 'abort', reason: 'crash' })); + await mapper.accept(event({ type: 'text_delta', messageId: 'answer', text: 'projected' })); + + assert.deepEqual( + notifications.map(({ update }) => update), + [chunk('agent_message_chunk', 'answer', 'projected')], + ); + }); + + test('flush waits for every already accepted notification', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + let delivered = false; + const mapper = new AcpSessionEventMapper({ + sessionId: 'session-1', + notify: async () => { + await pending; + delivered = true; + }, + }); + + const accepting = mapper.accept( + event({ type: 'text_delta', messageId: 'answer', text: 'pending' }), + ); + let flushed = false; + const flushing = mapper.flush().then(() => { + flushed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(delivered, false); + assert.equal(flushed, false); + release(); + await flushing; + assert.equal(delivered, true); + assert.equal(flushed, true); + await accepting; + }); +}); + +function eventMapper(notifications: SessionNotification[]): AcpSessionEventMapper { + return new AcpSessionEventMapper({ + sessionId: 'session-1', + notify: async (notification) => void notifications.push(notification), + }); +} + +function chunk( + sessionUpdate: 'agent_message_chunk' | 'agent_thought_chunk', + messageId: string, + text: string, +) { + return { sessionUpdate, content: { type: 'text' as const, text }, messageId }; +} + +function event>(value: T): SessionEvent { + return { id: 'event', turnId: 'turn-1', ts: 1, ...value } as unknown as SessionEvent; +} diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index bb37240505..f7b511380b 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -18,24 +18,32 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; import { RequestError, type NewSessionRequest, + type SessionNotification, type SessionConfigOption, type SetSessionConfigOptionRequest, } from '@agentclientprotocol/sdk'; +import type { StoredMessage } from '@maka/core/session'; import { THINKING_LEVELS, type ThinkingLevel } from '@maka/core/model-thinking'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, + RuntimeHostSubscriptionError, + type RuntimeHostSessionSubscription, } from '@maka/runtime-host/client'; import { SESSION_CATALOG_CWD_MAX_BYTES, + SESSION_CONTINUITY_SCHEMA_VERSION, type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { AcpSessionRegistry, type AcpSessionRegistryConnection } from '../acp/session-registry.js'; @@ -130,6 +138,15 @@ describe('ACP Session registry', () => { value: 'bypass', }), ], + [ + 'turn.start', + () => + registry.prompt( + { sessionId: 'session-closed', prompt: [{ type: 'text', text: 'hello' }] }, + promptContext([]), + ), + ], + ['session.close', () => registry.close({ sessionId: 'session-closed' })], ] as const) { await assert.rejects(request(), (error: unknown) => { assert.ok(error instanceof RequestError); @@ -332,6 +349,1166 @@ describe('ACP Session registry', () => { await registry.dispose(); }); + test('rejects unsupported prompt content before opening a real Session channel', async () => { + let subscriptionOpens = 0; + const turnRequests: string[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + turnRequests.push(operation); + return catalogSession('session-prompt-validation'); + }, + openSessionSubscriptionOnce: async () => { + subscriptionOpens += 1; + return new FakeSubscription(continuitySnapshot('session-prompt-validation')); + }, + }), + newSessionId: () => 'session-prompt-validation', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + turnRequests.length = 0; + + await assertInvalidParams( + registry.prompt( + { + sessionId: 'session-prompt-validation', + prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], + }, + promptContext([]), + ), + { field: 'prompt', reason: 'unsupported_content_type' }, + ); + + assert.equal(subscriptionOpens, 0); + assert.deepEqual(turnRequests, []); + await registry.dispose(); + }); + + test('shares a concurrent first real Session channel and consumes events before turn.start settles', async () => { + const sessionId = 'session-concurrent-prompt'; + const notifications: SessionNotification[] = []; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const turnIds = ['turn-a', 'turn-b']; + const startedTurnIds: string[] = []; + let subscriptionOpens = 0; + let turnTail = Promise.resolve(); + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation !== 'turn.start') throw new Error(`Unexpected operation ${operation}`); + const turnId = (input as { turnId: string }).turnId; + const turn = runningTurn(sessionId, turnId); + const emit = turnTail.then(async () => { + startedTurnIds.push(turnId); + subscription.setRoot(turn); + subscription.appendText(turnId, turn.runId, turnId, true); + await waitFor(() => + notifications.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === turnId, + ), + ); + subscription.setRoot(completedTurn(sessionId, turnId)); + }); + turnTail = emit.catch(() => undefined); + await emit; + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + }, + openSessionSubscriptionOnce: async () => { + subscriptionOpens += 1; + return subscription; + }, + }), + newSessionId: () => sessionId, + newTurnId: () => turnIds.shift()!, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + + const first = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'one' }] }, + promptContext(notifications), + ); + const second = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'two' }] }, + promptContext(notifications), + ); + + assert.deepEqual(await Promise.all([first, second]), [ + { stopReason: 'end_turn' }, + { stopReason: 'end_turn' }, + ]); + assert.equal(subscriptionOpens, 1); + assert.deepEqual(new Set(startedTurnIds), new Set(['turn-a', 'turn-b'])); + await registry.dispose(); + assert.equal(subscription.closeCalls, 1); + }); + + test('latches cancellation while the real Session subscription is opening', async () => { + const sessionId = 'session-cancel-before-attach'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const opening = deferred(); + let subscriptionOpens = 0; + let turnStarts = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') turnStarts += 1; + return {}; + }, + openSessionSubscriptionOnce: async () => { + subscriptionOpens += 1; + return opening.promise; + }, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn-cancelled', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'cancel me' }] }, + promptContext([]), + ); + await waitFor(() => subscriptionOpens === 1); + const cancellation = registry.cancel({ sessionId }); + opening.resolve(subscription); + + await cancellation; + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(turnStarts, 0); + await registry.dispose(); + assert.equal(subscription.closeCalls, 1); + }); + + test('aborting a prompt signal closes its pending initial transcript hydration', async () => { + const sessionId = 'session-aborted-hydration'; + const transcript = deferred(); + const subscription = new FakeSubscription(continuitySnapshot(sessionId), transcript.promise); + const abort = new AbortController(); + let turnStarts = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') turnStarts += 1; + assert.fail(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + let finished = false; + const prompt = registry + .prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + { ...promptContext([]), signal: abort.signal }, + ) + .then((result) => { + finished = true; + return result; + }); + try { + await waitFor(() => subscription.nextCalls > 0); + abort.abort(); + await waitFor(() => finished); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(subscription.closeCalls, 1); + transcript.resolve([]); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(turnStarts, 0, 'late transcript completion must not admit the cancelled prompt'); + } finally { + transcript.resolve([]); + await registry.dispose(); + await prompt; + } + }); + + test('aborting one prompt preserves the shared initial attachment for another prompt', async () => { + const sessionId = 'session-shared-open-abort'; + const opening = deferred(); + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const abort = new AbortController(); + let opens = 0; + const starts: string[] = []; + const turnIds = ['cancelled-turn', 'continuing-turn']; + 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); + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + subscription.setRoot(completedTurn(sessionId, turnId)); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + assert.fail(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + return opening.promise; + }, + }), + newSessionId: () => sessionId, + newTurnId: () => turnIds.shift()!, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const cancelled = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'cancel this one' }] }, + { ...promptContext([]), signal: abort.signal }, + ); + const continuing = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'continue this one' }] }, + promptContext([]), + ); + try { + await waitFor(() => opens === 1); + abort.abort(); + opening.resolve(subscription); + assert.deepEqual(await cancelled, { stopReason: 'cancelled' }); + assert.deepEqual(await continuing, { stopReason: 'end_turn' }); + assert.equal(opens, 1); + assert.deepEqual(starts, ['continuing-turn']); + assert.equal(subscription.closeCalls, 0); + } finally { + opening.resolve(subscription); + await registry.dispose(); + await Promise.allSettled([cancelled, continuing]); + } + }); + + for (const action of ['close', 'dispose'] as const) { + test(`${action} during real Session channel open prevents Turn admission`, async () => { + const sessionId = `session-open-${action}`; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const opening = deferred(); + let subscriptionOpens = 0; + let turnStarts = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') turnStarts += 1; + return {}; + }, + openSessionSubscriptionOnce: async () => { + subscriptionOpens += 1; + return opening.promise; + }, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn-never-admitted', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'cancel me' }] }, + promptContext([]), + ); + await waitFor(() => subscriptionOpens === 1); + const cleanup = action === 'close' ? registry.close({ sessionId }) : registry.dispose(); + opening.resolve(subscription); + + await cleanup; + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(turnStarts, 0); + assert.equal(subscription.closeCalls, 1); + await registry.dispose(); + }); + } + + test('waits for the real channel root identity before issuing exactly one turn.stop', async () => { + const sessionId = 'session-cancel-live'; + const turn = runningTurn(sessionId, 'turn-live', 'run-live'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const start = deferred(); + const stopInputs: unknown[] = []; + let startRequests = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + startRequests += 1; + return start.promise; + } + if (operation === 'turn.stop') { + stopInputs.push(input); + subscription.setRoot({ + ...turn, + status: 'cancelled', + terminalEventId: 'terminal-live', + abortSource: 'user', + }); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + promptContext([]), + ); + await waitFor(() => startRequests === 1); + const cancel = registry.cancel({ sessionId }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(stopInputs, []); + + subscription.setRoot(turn); + start.resolve({ + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); + await cancel; + await registry.cancel({ sessionId }); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.deepEqual(stopInputs, [{ sessionId, turnId: turn.turnId, runId: turn.runId }]); + await registry.dispose(); + }); + + test('shutdown stops a late admission after closing its real Session channel', async () => { + const sessionId = 'session-late-start'; + const turn = runningTurn(sessionId, 'turn-late', 'run-late'); + const start = deferred(); + const stop = deferred(); + const calls: string[] = []; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + let startRequests = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + startRequests += 1; + return start.promise; + } + if (operation === 'turn.stop') { + assert.deepEqual(input, { sessionId, turnId: turn.turnId, runId: turn.runId }); + calls.push('stop'); + return stop.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + close: async () => { + calls.push('connection.close'); + }, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + promptContext([]), + ); + await waitFor(() => startRequests === 1); + const disposal = registry.dispose(); + await waitFor(() => subscription.closeCalls === 1); + start.resolve({ + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); + try { + await waitFor(() => calls.includes('stop')); + assert.deepEqual(calls, ['stop']); + } finally { + stop.resolve({}); + await disposal; + } + + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.deepEqual(calls, ['stop', 'connection.close']); + }); + + test('shutdown closes the Host when an outcome-unknown query never settles', async () => { + const sessionId = 'session-pending-query-on-shutdown'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const start = deferred(); + const query = deferred(); + const calls: string[] = []; + let startRequests = 0; + let queries = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + startRequests += 1; + return start.promise; + } + if (operation === 'turn.query') { + queries += 1; + return query.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + close: async () => { + calls.push('connection.close'); + }, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn-pending-query', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + promptContext([]), + ); + await waitFor(() => startRequests === 1); + const disposal = registry.dispose(); + start.reject( + new RuntimeHostRequestInterruptedError( + 'turn.start', + 'command', + 'dispatched', + 'connection_lost', + ), + ); + await waitFor(() => queries === 1); + + let settled = false; + const outcome = Promise.all([disposal, prompt]).then((value) => { + settled = true; + return value; + }); + try { + await waitFor(() => settled); + assert.deepEqual(calls, ['connection.close']); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + } finally { + query.resolve(completedTurn(sessionId, 'turn-pending-query')); + await outcome; + } + }); + + for (const admission of ['not_found', 'terminal', 'running'] as const) { + test(`retries failed admission reads after healthy recovery until ${admission} is authoritative`, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const sessionId = `session-admission-retry-${admission}`; + const turn = runningTurn(sessionId, 'turn-unknown', 'run-authoritative'); + const first = new FakeSubscription(continuitySnapshot(sessionId)); + const replacement = new FakeSubscription( + continuitySnapshot(sessionId), + Promise.resolve([]), + 'subscription-recovered', + ); + let queries = 0; + let recoveries = 0; + const stops: unknown[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + throw new RuntimeHostRequestInterruptedError( + 'turn.start', + 'command', + 'dispatched', + 'connection_lost', + ); + } + if (operation === 'turn.query') { + queries += 1; + if (queries <= 2) { + throw new RuntimeHostOperationError( + 'turn.query', + 'internal_failure', + 'Temporary admission read failure', + ); + } + if (admission === 'not_found') { + throw new RuntimeHostOperationError('turn.query', 'not_found', 'Not admitted'); + } + return admission === 'running' + ? turn + : completedTurn(sessionId, turn.turnId, turn.runId); + } + if (operation === 'turn.stop') { + stops.push(input); + return completedTurn(sessionId, turn.turnId, turn.runId); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => first, + openSessionSubscription: async () => { + recoveries += 1; + return replacement; + }, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + let outcome: PromiseSettledResult | undefined; + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext([]), + ); + const observed = Promise.allSettled([prompt]).then(([result]) => { + outcome = result; + }); + try { + await waitFor(() => queries === 1); + first.fail(new RuntimeHostSubscriptionError('connection_closed', 'Connection was lost')); + await waitFor(() => recoveries === 1 && replacement.nextCalls > 0); + // No root and no further frames will arrive. Only another admission read + // can establish whether the lost command ran; recovery itself is healthy. + for (let attempt = 0; attempt < 12 && !outcome; attempt += 1) { + t.mock.timers.tick(1_000); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(outcome, 'a transient read failure must not strand the prompt'); + assert.equal(outcome.status, 'rejected'); + assert.ok(queries >= 3); + assert.deepEqual( + stops, + admission === 'running' ? [{ sessionId, turnId: turn.turnId, runId: turn.runId }] : [], + ); + await registry.close({ sessionId }); + assert.equal(replacement.closeCalls, 1); + const settledQueries = queries; + t.mock.timers.tick(60_000); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(queries, settledQueries, 'settled admission must cancel retry work'); + } finally { + await registry.dispose(); + await observed; + } + }); + } + + for (const cleanup of ['prompt', 'cancel_and_close'] as const) { + test(`persistent admission read failures let ${cleanup} finish`, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.mock.method(console, 'error', () => undefined); + const sessionId = 'session-admission-read-unavailable'; + const first = new FakeSubscription(continuitySnapshot(sessionId)); + const replacement = new FakeSubscription( + continuitySnapshot(sessionId), + Promise.resolve([]), + 'subscription-recovered', + ); + let queries = 0; + let recoveries = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + throw new RuntimeHostRequestInterruptedError( + 'turn.start', + 'command', + 'dispatched', + 'connection_lost', + ); + } + if (operation === 'turn.query') { + queries += 1; + throw new RuntimeHostOperationError( + 'turn.query', + 'internal_failure', + 'Admission store unavailable', + ); + } + assert.fail(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => first, + openSessionSubscription: async () => { + recoveries += 1; + return replacement; + }, + }), + newSessionId: () => sessionId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext([]), + ); + void prompt.catch(() => undefined); + await waitFor(() => queries === 1); + first.fail(new RuntimeHostSubscriptionError('connection_closed', 'Connection was lost')); + await waitFor(() => recoveries === 1 && replacement.nextCalls > 0); + const operations: Promise[] = [prompt]; + if (cleanup === 'cancel_and_close') { + operations.push(registry.cancel({ sessionId }), registry.close({ sessionId })); + } + let outcomes: PromiseSettledResult[] | undefined; + const observed = Promise.allSettled(operations).then((results) => { + outcomes = results; + }); + try { + for (let attempt = 0; attempt < 12 && !outcomes; attempt += 1) { + t.mock.timers.tick(1_000); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(outcomes, 'unavailable admission facts need a finite failure outcome'); + if (cleanup === 'cancel_and_close') { + assert.deepEqual(outcomes[0], { + status: 'fulfilled', + value: { stopReason: 'cancelled' }, + }); + assert.equal(outcomes[2]?.status, 'rejected', 'close must report an unconfirmed Stop'); + } else { + assert.equal( + outcomes[0]?.status, + 'rejected', + 'unknown admission needs an error response', + ); + await registry.close({ sessionId }); + } + assert.equal(replacement.closeCalls, 1, 'close must release its subscription on failure'); + const settledQueries = queries; + t.mock.timers.tick(60_000); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(queries, settledQueries, 'closed sessions must not retain retry work'); + } finally { + await registry.dispose(); + await observed; + } + }); + } + + for (const action of ['close', 'dispose'] as const) { + test(`${action} closes the real Session channel when Stop delivery fails`, async (t) => { + const diagnostic = t.mock.method(console, 'error', () => undefined); + const sessionId = `session-stop-failure-${action}`; + const turn = runningTurn(sessionId, 'turn-stop-failure', 'run-stop-failure'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const stopFailure = new Error('stop failed'); + const stopInputs: unknown[] = []; + let startResponses = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + subscription.setRoot(turn); + await waitFor(() => subscription.nextCalls >= 2); + startResponses += 1; + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stopInputs.push(input); + throw stopFailure; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => turn.turnId, + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + promptContext([]), + ); + await waitFor(() => startResponses === 1); + + const cleanup = action === 'close' ? registry.close({ sessionId }) : registry.dispose(); + const [cleanupOutcome] = await Promise.allSettled([cleanup]); + if (action === 'close') { + assert.equal(cleanupOutcome?.status, 'rejected'); + if (cleanupOutcome?.status === 'rejected') assert.equal(cleanupOutcome.reason, stopFailure); + await assertInvalidParams( + registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'late' }] }, + promptContext([]), + ), + { reason: 'unknown_session' }, + ); + } else { + assert.equal(cleanupOutcome?.status, 'fulfilled'); + } + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.deepEqual(stopInputs, [{ sessionId, turnId: turn.turnId, runId: turn.runId }]); + assert.equal(subscription.closeCalls, 1); + assert.equal(diagnostic.mock.callCount(), 1); + await registry.dispose(); + }); + } + + test('retires a failed real Session channel so the next prompt opens a fresh one', async () => { + const sessionId = 'session-reattach'; + const first = new FakeSubscription(continuitySnapshot(sessionId)); + const second = new FakeSubscription( + continuitySnapshot(sessionId), + Promise.resolve([]), + 'subscription-2', + ); + const subscriptions = [first, second]; + const stops: unknown[] = []; + let opens = 0; + let starts = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.stop') { + stops.push(input); + return {}; + } + if (operation !== 'turn.start') throw new Error(`Unexpected operation ${operation}`); + starts += 1; + const turnId = (input as { turnId: string }).turnId; + const turn = runningTurn(sessionId, turnId); + const subscription = starts === 1 ? first : second; + subscription.setRoot(turn); + await waitFor(() => subscription.nextCalls >= 2); + if (starts === 1) subscription.fail(new Error('subscription failed')); + else subscription.setRoot(completedTurn(sessionId, turnId)); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + }, + openSessionSubscriptionOnce: async () => subscriptions[opens++]!, + }), + newSessionId: () => sessionId, + newTurnId: (() => { + const ids = ['turn-first', 'turn-second']; + return () => ids.shift()!; + })(), + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + + await assert.rejects( + registry.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] }, promptContext([])), + { + data: { source: 'runtime_host', operation: 'subscription.open', code: 'internal_failure' }, + }, + ); + assert.deepEqual( + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'second' }] }, + promptContext([]), + ), + { stopReason: 'end_turn' }, + ); + assert.equal(opens, 2); + assert.equal(first.closeCalls, 1); + assert.deepEqual(stops, [{ sessionId, turnId: 'turn-first', runId: 'run-turn-first' }]); + await registry.dispose(); + assert.equal(second.closeCalls, 1); + }); + + for (const action of ['cancel', 'close', 'dispose'] as const) { + test(`${action} stops an externally started root observed by an idle real channel`, async () => { + const sessionId = `external-root-${action}`; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const stops: unknown[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + const local = runningTurn(sessionId, 'local'); + subscription.setRoot(local); + subscription.setRoot(completedTurn(sessionId, 'local')); + return { + kind: 'started', + turn: local, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stops.push(input); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'local', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext([]), + ); + const priorNextCalls = subscription.nextCalls; + subscription.setRoot(runningTurn(sessionId, 'external', 'external-run')); + await waitFor(() => subscription.nextCalls > priorNextCalls); + + if (action === 'dispose') await registry.dispose(); + else await registry[action]({ sessionId }); + assert.deepEqual(stops, [ + { + sessionId, + turnId: 'external', + runId: 'external-run', + }, + ]); + subscription.setRoot(null); + await registry.dispose(); + }); + } + + for (const action of ['cancel', 'close'] as const) { + for (const stopFails of [false, true]) { + test(`${action} retains the external root with a pending interaction when Stop ${stopFails ? 'fails' : 'succeeds'}`, async () => { + const sessionId = `external-interaction-${action}-${stopFails}`; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const external = runningTurn(sessionId, 'external', 'external-run'); + const stopFailure = new Error('External Stop failed'); + const stops: unknown[] = []; + let failStop = stopFails; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + const local = runningTurn(sessionId, 'local'); + subscription.setRoot(local); + subscription.setRoot(completedTurn(sessionId, 'local')); + return { + kind: 'started', + turn: local, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') { + stops.push(input); + if (failStop) throw stopFailure; + subscription.project({ + rootTurn: completedTurn(sessionId, external.turnId, external.runId), + interactions: { pending: [] }, + }); + return completedTurn(sessionId, external.turnId, external.runId); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'local', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + try { + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'attach' }] }, + promptContext([]), + ); + const nextCalls = subscription.nextCalls; + subscription.project({ + rootTurn: external, + interactions: { + pending: [ + { + schemaVersion: 1, + interactionId: 'external-question', + sessionId, + turnId: external.turnId, + runId: external.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'external-tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }, + ], + }, + }); + await waitFor(() => subscription.nextCalls > nextCalls); + if (action === 'close' && stopFails) { + await assert.rejects(registry.close({ sessionId }), (error) => error === stopFailure); + } else { + await registry[action]({ sessionId }); + } + assert.deepEqual(stops, [{ sessionId, turnId: external.turnId, runId: external.runId }]); + assert.equal(subscription.closeCalls, action === 'close' ? 1 : 0); + if (action === 'cancel') { + // A failed notification cannot erase the identity needed by a later close. + failStop = false; + await registry.close({ sessionId }); + assert.equal(stops.length, stopFails ? 2 : 1); + assert.equal(subscription.closeCalls, 1); + } + } finally { + failStop = false; + await registry.dispose(); + } + }); + } + } + + for (const failure of ['failed', 'stalled'] as const) { + test(`keeps a real channel prompt streaming after a ${failure} configuration refresh`, async (t) => { + t.mock.method(console, 'error', () => undefined); + const sessionId = `refresh-live-${failure}`; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const read = deferred(); + const notifications: SessionNotification[] = []; + let reads = 0; + let startResponses = 0; + let stops = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') { + reads += 1; + if (reads === 1) return read.promise; + return { + kind: 'session', + session: catalogSession(sessionId, '/workspace', { + revision: 3, + permissionMode: 'bypass', + }), + }; + } + if (operation === 'turn.stop') { + stops += 1; + return {}; + } + if (operation === 'turn.start') { + const turn = runningTurn(sessionId, 'turn', 'run'); + subscription.setRoot(turn); + startResponses += 1; + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext(notifications), + ); + await waitFor(() => startResponses === 1); + subscription.setMetadataRevision(2); + await waitFor(() => reads === 1); + if (failure === 'failed') read.reject(new Error('catalog unavailable')); + subscription.appendText('turn', 'run', 'still streaming'); + await waitFor(() => + notifications.some(({ update }) => update.sessionUpdate === 'agent_message_chunk'), + ); + assert.equal(stops, 0); + assert.equal(subscription.closeCalls, 0); + subscription.setRoot(completedTurn(sessionId, 'turn', 'run')); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + if (failure === 'failed') { + subscription.setMetadataRevision(3); + await waitFor(() => + notifications.some(({ update }) => update.sessionUpdate === 'config_option_update'), + ); + assert.equal(reads, 2); + } else { + read.resolve({ kind: 'session', session: catalogSession(sessionId) }); + } + await registry.dispose(); + }); + } + + test('suppresses a real channel configuration projection that finishes after close', async () => { + const sessionId = 'closing-options'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const read = deferred<{ kind: 'session'; session: SessionCatalogProjection }>(); + const notifications: SessionNotification[] = []; + let reading = false; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') { + reading = true; + return read.promise; + } + if (operation === 'turn.start') { + const turn = runningTurn(sessionId, 'turn'); + subscription.setRoot(turn); + subscription.setRoot(completedTurn(sessionId, 'turn')); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext(notifications), + ); + subscription.setMetadataRevision(2); + await waitFor(() => reading); + await registry.close({ sessionId }); + read.resolve({ + kind: 'session', + session: catalogSession(sessionId, '/workspace', { revision: 2, permissionMode: 'bypass' }), + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(notifications, []); + assert.equal(subscription.closeCalls, 1); + await registry.dispose(); + }); + + test('closing an active real channel prompt does not wait for a stalled configuration read', async () => { + const sessionId = 'stalled-options'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const read = deferred<{ kind: 'session'; session: SessionCatalogProjection }>(); + const notifications: SessionNotification[] = []; + let reading = false; + let startResponses = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') { + reading = true; + return read.promise; + } + if (operation === 'turn.stop') return {}; + if (operation === 'turn.start') { + const turn = runningTurn(sessionId, 'turn', 'run'); + subscription.setRoot(turn); + subscription.setMetadataRevision(2); + subscription.appendText('turn', 'run', 'pending'); + startResponses += 1; + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext(notifications), + ); + await waitFor(() => reading && startResponses === 1); + let closed = false; + const closing = registry.close({ sessionId }).then(() => { + closed = true; + }); + try { + await waitFor(() => closed); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(notifications.length, 1); + assert.equal(notifications[0]?.update.sessionUpdate, 'agent_message_chunk'); + } finally { + read.resolve({ + kind: 'session', + session: catalogSession(sessionId, '/workspace', { revision: 2 }), + }); + await closing; + await registry.dispose(); + } + }); + + test('maps real Session channel observation failures to stable ACP errors', async () => { + const sessionId = 'observation-failure'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + let startRequests = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.start') { + startRequests += 1; + return { kind: 'started' }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + newTurnId: () => 'turn', + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, + promptContext([]), + ); + await waitFor(() => startRequests === 1); + subscription.fail( + new RuntimeHostSubscriptionError('host_epoch_changed', 'Host identity changed'), + ); + await assert.rejects(prompt, (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.deepEqual(error.data, { + source: 'runtime_host', + operation: 'subscription.open', + code: 'subscription_failure', + reason: 'host_epoch_changed', + }); + return true; + }); + assert.equal(subscription.closeCalls, 1); + await registry.dispose(); + }); + test('returns projected configuration and owns only a representable successful create', async () => { const requests: Array<{ operation: string; input: unknown }> = []; let subscriptionOpens = 0; @@ -441,7 +1618,7 @@ describe('ACP Session registry', () => { await registry.dispose(); }); - test('does not grant ownership after failed or legacy creates', async () => { + test('keeps failed creates unowned and returns committed IDs even for unsupported projections', async () => { for (const [name, createOutcome] of [ [ 'failed', @@ -471,6 +1648,15 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, }); + if (!(createOutcome instanceof Error)) { + assert.deepEqual(await registry.create({ cwd: '/workspace', mcpServers: [] }), { + sessionId, + }); + await registry.close({ sessionId }); + assert.equal(requests, 1); + await registry.dispose(); + continue; + } await assert.rejects(registry.create({ cwd: '/workspace', mcpServers: [] })); await assertInvalidParams( registry.setConfigOption({ @@ -485,6 +1671,38 @@ describe('ACP Session registry', () => { } }); + test('returns the committed ID on catalog failure without admitting mutations during projection', async () => { + const catalog = deferred(); + let projecting = false; + const connection = fakeConnection({ request: async () => catalogSession('created') }); + const request = connection.request; + connection.request = (async (operation, input) => { + if (operation === 'connection.catalog.query') { + projecting = true; + return catalog.promise; + } + return request(operation, input); + }) as AcpSessionRegistryConnection['request']; + const registry = new AcpSessionRegistry({ + connect: async () => connection, + newSessionId: () => 'created', + }); + const creation = registry.create({ cwd: '/workspace', mcpServers: [] }); + await waitFor(() => projecting); + await assertInvalidParams( + registry.setConfigOption({ + sessionId: 'created', + configId: 'permission_mode', + value: 'bypass', + }), + { reason: 'unknown_session' }, + ); + catalog.reject(new Error('catalog unavailable')); + assert.deepEqual(await creation, { sessionId: 'created' }); + assert.deepEqual(await registry.close({ sessionId: 'created' }), {}); + await registry.dispose(); + }); + test('rejects non-owned and invalid configuration requests before Host I/O', async () => { let requests = 0; const registry = new AcpSessionRegistry({ @@ -1394,6 +2612,68 @@ describe('ACP Session registry', () => { }); await registry.dispose(); }); + for (const action of ['cancel', 'close', 'abort'] as const) { + test(`${action} during resource upload aborts staging without starting a Turn`, async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-acp-upload-lifecycle-')); + const file = join(workspace, 'notes.txt'); + await writeFile(file, 'read this file'); + const sessionId = `session-upload-${action}`; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const uploadStarted = deferred(); + const upload = deferred(); + const operations: string[] = []; + let uploadId: string | undefined; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId, workspace); + assert.equal( + operation, + 'artifact.ingest', + 'upload cancellation must prevent turn.start', + ); + const request = input as { kind: string; uploadId: string }; + operations.push(request.kind); + if (request.kind === 'begin') { + uploadId = request.uploadId; + uploadStarted.resolve(); + return upload.promise; + } + assert.equal(request.kind, 'abort'); + assert.equal(request.uploadId, uploadId); + return { kind: 'upload_aborted', uploadId }; + }, + openSessionSubscriptionOnce: async () => subscription, + }), + newSessionId: () => sessionId, + }); + const abort = new AbortController(); + let prompt: Promise | undefined; + try { + await registry.create({ cwd: workspace, mcpServers: [] }); + prompt = registry.prompt( + { + sessionId, + prompt: [{ type: 'resource_link', uri: pathToFileURL(file).href, name: 'notes.txt' }], + }, + { signal: abort.signal, notify: async () => undefined }, + ); + void prompt.catch(() => undefined); + await uploadStarted.promise; + if (action === 'abort') abort.abort(); + else await registry[action]({ sessionId }); + upload.resolve({ kind: 'upload_opened', uploadId, nextOffset: 0 }); + assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.deepEqual(operations, ['begin', 'abort']); + } finally { + upload.resolve({ kind: 'upload_opened', uploadId, nextOffset: 0 }); + await registry.dispose(); + await prompt?.catch(() => undefined); + await rm(workspace, { recursive: true, force: true }); + } + }); + } }); function fakeConnection( @@ -1401,15 +2681,204 @@ function fakeConnection( request?: (operation: string, input: unknown) => Promise; close?: () => Promise; thinkingLevels?: readonly ThinkingLevel[]; + openSessionSubscription?: AcpSessionRegistryConnection['openSessionSubscription']; + openSessionSubscriptionOnce?: AcpSessionRegistryConnection['openSessionSubscriptionOnce']; } = {}, ): AcpSessionRegistryConnection { return { - request: async (operation, input) => + reconnecting: true, + request: async (operation: string, input: unknown) => operation === 'connection.catalog.query' ? connectionCatalogPage(overrides.thinkingLevels ?? THINKING_LEVELS) : (overrides.request?.(operation, input) ?? {}), + openSessionSubscription: + overrides.openSessionSubscription ?? + (async () => { + throw new Error('Unexpected recoverable subscription open'); + }), + openSessionSubscriptionOnce: + overrides.openSessionSubscriptionOnce ?? + overrides.openSessionSubscription ?? + (async () => { + throw new Error('Unexpected initial subscription open'); + }), close: overrides.close ?? (async () => undefined), - } as AcpSessionRegistryConnection; + } as unknown as AcpSessionRegistryConnection; +} + +function promptContext(notifications: SessionNotification[]) { + return { + signal: new AbortController().signal, + notify: async (notification: SessionNotification) => void notifications.push(notification), + }; +} + +class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator { + readonly hostEpoch = 'host-1'; + readonly activeAssistantStreams = []; + readonly transcriptBootstrap = null; + readonly #frames: SubscriptionFrame[] = []; + readonly #waiters: Array<{ + resolve(result: IteratorResult): void; + reject(error: Error): void; + }> = []; + #sequence = 0; + #closed = false; + #failure: Error | undefined; + closeCalls = 0; + nextCalls = 0; + + constructor( + public snapshot: SessionContinuitySnapshot, + private readonly transcript: Promise = Promise.resolve([]), + readonly subscriptionId = 'subscription-1', + private readonly onClose: () => void = () => undefined, + ) {} + + subscribePtyData(): () => void { + return () => undefined; + } + + subscribeSessionDomainChanges(): () => void { + return () => undefined; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + next(): Promise> { + this.nextCalls += 1; + const frame = this.#frames.shift(); + if (frame) return Promise.resolve({ done: false, value: frame }); + if (this.#failure) return Promise.reject(this.#failure); + if (this.#closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject })); + } + + push(frame: SubscriptionFrame): void { + const waiter = this.#waiters.shift(); + if (waiter) waiter.resolve({ done: false, value: frame }); + else this.#frames.push(frame); + } + + setRoot(rootTurn: SessionContinuitySnapshot['rootTurn']): void { + this.project({ + rootTurn, + session: { + ...this.snapshot.session, + status: rootTurn && rootTurn.status === 'running' ? 'running' : 'active', + }, + }); + } + + setMetadataRevision(metadataRevision: number): void { + this.project({ + session: { ...this.snapshot.session, metadataRevision }, + }); + } + + appendText(turnId: string, runId: string, text: string, complete = false): void { + this.push({ + kind: 'subscription.session_delta', + hostEpoch: this.hostEpoch, + subscriptionId: this.subscriptionId, + sequence: ++this.#sequence, + sessionId: this.snapshot.session.sessionId, + delta: { + kind: 'text', + turnId, + runId, + messageId: `message-${turnId}`, + startOffset: 0, + text, + ...(complete ? { complete: true as const } : {}), + }, + }); + } + + project(overrides: Partial): void { + this.snapshot = { + ...this.snapshot, + ...overrides, + projectionRevision: this.snapshot.projectionRevision + 1, + }; + this.push({ + kind: 'subscription.session_projection', + hostEpoch: this.hostEpoch, + subscriptionId: this.subscriptionId, + sequence: ++this.#sequence, + snapshot: structuredClone(this.snapshot), + }); + } + + fail(error: Error): void { + this.#failure = error; + for (const waiter of this.#waiters.splice(0)) waiter.reject(error); + } + + async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + return (await this.transcript).map(decodeMessage); + } + + async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { + return []; + } + + async decodeTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async loadTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async close(): Promise { + this.closeCalls += 1; + if (this.#closed) return; + this.#closed = true; + this.onClose(); + for (const waiter of this.#waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } +} + +function continuitySnapshot( + sessionId: string, + overrides: Partial = {}, +): SessionContinuitySnapshot { + return { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId, + metadataRevision: 1, + status: 'active', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + ...overrides, + }; +} + +function runningTurn(sessionId: string, turnId: string, runId = `run-${turnId}`) { + return { sessionId, turnId, runId, status: 'running' as const }; +} + +function completedTurn(sessionId: string, turnId: string, runId = `run-${turnId}`) { + return { + sessionId, + turnId, + runId, + status: 'completed' as const, + completedAt: 2, + terminalEventId: `terminal-${turnId}`, + }; } function connectionCatalogPage(thinkingLevels: readonly ThinkingLevel[]) { diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index 0114d9234e..8129e0d8eb 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -20,11 +20,609 @@ import assert from 'node:assert/strict'; import { PassThrough, Readable, Writable } from 'node:stream'; import { describe, test } from 'node:test'; -import type { RuntimeHostConnection } from '@maka/runtime-host/client'; -import type { SessionCatalogProjection } from '@maka/runtime-host/protocol'; +import type { InteractionRequest } from '@maka/core/interaction'; +import type { StoredMessage } from '@maka/core/session'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, +} from '@maka/runtime-host/protocol'; +import { + createRuntimeHostReconnectingConnection, + isRuntimeHostReconnectingConnection, + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + RuntimeHostSubscriptionError, + type RuntimeHostConnection, + type RuntimeHostSessionSubscription, +} from '@maka/runtime-host/client'; import { runMakaAcpStdioServer } from '../acp/stdio-server.js'; describe('Maka ACP stdio server', () => { + for (const scenario of [ + 'recovery', + 'question', + 'form', + 'permission', + 'sandbox_boundary', + 'client_capability', + ] as const) { + test(`prompt through stdio handles ${scenario}`, { timeout: 5_000 }, async () => { + const stdin = new PassThrough(); + let created: SessionCatalogProjection | undefined; + let root: NonNullable | undefined; + let first: FakeSubscription | undefined; + let opens = 0; + const stops: unknown[] = []; + const snapshot = (projectionRevision = 1): SessionContinuitySnapshot => + continuitySnapshot({ + sessionId: created!.id, + projectionRevision, + rootTurn: root ?? null, + status: 'running', + }); + const connection = { + request: async (operation: string, input: { sessionId: string; turnId: string }) => { + if (operation === 'session.create') + 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 === 'turn.start') { + root = { + sessionId: input.sessionId, + turnId: input.turnId, + runId: 'run-1', + status: 'running', + }; + return { kind: 'started', turn: root }; + } + if (operation === 'turn.stop') { + stops.push(input); + return {}; + } + assert.fail(`Unexpected operation: ${operation}`); + }, + openSessionSubscription: async () => { + opens += 1; + if (opens === 1) return (first = new FakeSubscription(snapshot(), Promise.resolve([]))); + const replacement = new FakeSubscription( + snapshot(3), + Promise.resolve([]), + 'subscription-2', + ); + replacement.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-2', + sequence: 1, + snapshot: { + ...snapshot(4), + rootTurn: { + sessionId: root!.sessionId, + turnId: root!.turnId, + runId: root!.runId, + status: 'completed', + terminalEventId: 'terminal-1', + }, + }, + }); + return replacement; + }, + close: async () => undefined, + } as unknown as RuntimeHostConnection; + const harness = createHarness([], { stdin, connection }); + const run = harness.run(); + const response = (id: number) => + ( + harness.stdoutMessages() as Array<{ + id: number; + result?: { sessionId?: string; stopReason?: string }; + error?: { data?: { code?: string; kind?: string } }; + }> + ).find((message) => message.id === id); + const send = (id: number, method: string, params: unknown) => + stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + try { + send(1, 'session/new', { cwd: '/workspace', mcpServers: [] }); + await waitFor(() => Boolean(response(1))); + assert.ok(response(1)?.result?.sessionId); + send(2, 'session/prompt', { + sessionId: created!.id, + prompt: [{ type: 'text', text: 'Hello' }], + }); + await waitFor(() => Boolean(root)); + first!.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot(2), + }); + if (scenario === 'recovery') { + first!.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + reason: 'slow_consumer', + }); + await waitFor(() => Boolean(response(2))); + assert.deepEqual(response(2)?.result, { stopReason: 'end_turn' }); + assert.equal(opens, 2); + assert.deepEqual(stops, []); + } else { + first!.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: { + ...snapshot(3), + interactions: { + pending: [ + { + schemaVersion: 1, + interactionId: 'question-1', + ...root!, + revision: 1, + status: 'pending', + outcome: null, + request: unsupportedRequests[scenario], + }, + ], + }, + }, + }); + await waitFor(() => Boolean(response(2))); + assert.equal(response(2)?.error?.data?.code, 'unsupported_interaction'); + assert.equal(response(2)?.error?.data?.kind, scenario); + assert.deepEqual(stops, [ + { sessionId: root!.sessionId, turnId: root!.turnId, runId: root!.runId }, + ]); + } + } finally { + stdin.end(); + await run; + } + }); + } + + for (const { queryOutcome, recovery } of [ + { queryOutcome: 'pending', recovery: 'running' }, + { queryOutcome: 'pending', recovery: 'absent' }, + { queryOutcome: 'internal_failure', recovery: 'running' }, + { queryOutcome: 'internal_failure', recovery: 'held_empty' }, + { queryOutcome: 'internal_failure', recovery: 'terminal' }, + { queryOutcome: 'internal_failure', recovery: 'absent' }, + { queryOutcome: 'internal_failure', recovery: 'other_turn' }, + { queryOutcome: 'internal_failure', recovery: 'failed' }, + { queryOutcome: 'not_found', recovery: 'none' }, + ] as const) { + test(`settles outcome-unknown ACP cancellation with ${queryOutcome} query and ${recovery} recovery`, { + timeout: 5_000, + }, async (t) => { + // Drive admission retries explicitly; unrelated test load must not let + // a backoff timer expose query facts before this case releases recovery. + t.mock.timers.enable({ apis: ['setTimeout'] }); + const stdin = new PassThrough(); + let sessionId: string | undefined; + let session: SessionCatalogProjection | undefined; + let admitted: + | { + sessionId: string; + turnId: string; + runId: string; + status: 'running'; + } + | undefined; + let rejectStart!: (error: Error) => void; + const start = new Promise((_resolve, reject) => { + rejectStart = reject; + }); + let releaseRecovery!: (messages: StoredMessage[]) => void; + const recoveryTranscript = new Promise((resolve) => { + releaseRecovery = resolve; + }); + let releaseFailedRecoveryQuery!: () => void; + const failedRecoveryQuery = new Promise((resolve) => { + releaseFailedRecoveryQuery = resolve; + }); + let first: FakeSubscription | undefined; + let opens = 0; + let turnQueries = 0; + let queryTimeoutMs: number | undefined; + let rejectPendingQuery: ((error: Error) => void) | undefined; + const stops: unknown[] = []; + const snapshot = ( + projectionRevision: number, + rootTurn: SessionContinuitySnapshot['rootTurn'], + ): SessionContinuitySnapshot => + continuitySnapshot({ sessionId: sessionId!, projectionRevision, rootTurn }); + const connection = { + request: async ( + operation: string, + input: { sessionId: string; turnId: string }, + timeoutMs?: number, + ) => { + if (operation === 'session.create') { + sessionId = input.sessionId; + return (session = sessionProjection({ id: sessionId })); + } + if (operation === 'connection.catalog.query') return connectionCatalogPage(); + if (operation === 'session.catalog.query') return { kind: 'session', session }; + if (operation === 'turn.start') { + admitted = { + sessionId: input.sessionId, + turnId: input.turnId, + runId: 'run-unknown-start', + status: 'running', + }; + return start; + } + if (operation === 'turn.query') { + turnQueries += 1; + if (turnQueries > 1) { + if (recovery === 'held_empty') return admitted; + assert.ok( + recovery === 'absent' || recovery === 'other_turn' || recovery === 'failed', + ); + if (recovery === 'failed') await failedRecoveryQuery; + throw new RuntimeHostOperationError( + 'turn.query', + 'not_found', + 'Turn was not admitted', + ); + } + if (queryOutcome !== 'pending') { + throw new RuntimeHostOperationError('turn.query', queryOutcome, 'Query failed'); + } + assert.ok(timeoutMs !== undefined && timeoutMs > 0, 'admission query needs a deadline'); + queryTimeoutMs = timeoutMs; + return new Promise((_resolve, reject) => { + const timer = setTimeout(() => { + rejectPendingQuery?.( + new RuntimeHostRequestInterruptedError( + 'turn.query', + 'query', + 'dispatched', + 'timeout', + ), + ); + }, timeoutMs); + rejectPendingQuery = (error) => { + clearTimeout(timer); + rejectPendingQuery = undefined; + reject(error); + }; + }); + } + if (operation === 'turn.stop') { + stops.push(input); + return {}; + } + assert.fail(`Unexpected operation: ${operation}`); + }, + openSessionSubscription: async () => { + opens += 1; + if (opens === 1) { + first = new FakeSubscription(snapshot(1, null), Promise.resolve([])); + return first; + } + assert.ok(admitted); + if (recovery === 'failed') throw new Error('Session attachment permanently failed'); + const root: SessionContinuitySnapshot['rootTurn'] = + recovery === 'absent' || recovery === 'held_empty' + ? null + : recovery === 'terminal' + ? { ...admitted, status: 'completed', terminalEventId: 'terminal-unknown-start' } + : recovery === 'other_turn' + ? { ...admitted, turnId: 'unrelated-turn', runId: 'unrelated-run' } + : admitted; + return new FakeSubscription( + snapshot(2, root), + recovery === 'held_empty' ? recoveryTranscript : Promise.resolve([]), + 'subscription-recovered', + ); + }, + close: async () => { + rejectPendingQuery?.( + new RuntimeHostRequestInterruptedError( + 'turn.query', + 'query', + 'dispatched', + 'connection_lost', + ), + ); + }, + } as unknown as RuntimeHostConnection; + const harness = createHarness([], { stdin, connection }); + const run = harness.run(); + const response = () => + ( + harness.stdoutMessages() as Array<{ + id?: number; + result?: { stopReason?: string }; + }> + ).find(({ id }) => id === 2); + const send = (value: unknown) => stdin.write(`${JSON.stringify(value)}\n`); + const startRecovery = () => + first!.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', + }); + + try { + send({ + jsonrpc: '2.0', + id: 1, + method: 'session/new', + params: { cwd: '/workspace', mcpServers: [] }, + }); + await waitFor(() => + harness.stdoutMessages().some((message) => (message as { id?: number }).id === 1), + ); + send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: sessionId!, prompt: [{ type: 'text', text: 'Hello' }] }, + }); + await waitFor(() => Boolean(admitted && first)); + send({ + jsonrpc: '2.0', + method: 'session/cancel', + params: { sessionId: sessionId! }, + }); + await new Promise((resolve) => setImmediate(resolve)); + if (recovery === 'held_empty') { + // The replacement snapshot precedes the lost start reply; hydration completes later. + startRecovery(); + await waitFor(() => opens === 2); + } + rejectStart( + new RuntimeHostRequestInterruptedError( + 'turn.start', + 'command', + 'dispatched', + 'connection_lost', + ), + ); + await waitFor(() => turnQueries === 1); + if (recovery !== 'none') { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(response(), undefined, 'cancellation must wait for the channel recovery'); + if (recovery === 'held_empty') releaseRecovery([]); + else startRecovery(); + } + + if (queryOutcome === 'pending' && recovery === 'absent') { + await waitFor(() => opens === 2); + assert.ok(queryTimeoutMs); + // The recovered empty snapshot cannot settle a request still in flight. + // Honour the transport deadline, then advance the bounded retry delay. + for (let attempt = 0; attempt < 3 && !response(); attempt += 1) { + t.mock.timers.tick(queryTimeoutMs); + await new Promise((resolve) => setImmediate(resolve)); + } + } + if (recovery === 'failed') { + await waitFor(() => turnQueries === 2); + assert.equal(response(), undefined, 'subscription failure does not establish absence'); + releaseFailedRecoveryQuery(); + } + await waitFor(() => Boolean(response())); + assert.equal(opens, recovery === 'none' ? 1 : 2); + assert.deepEqual( + stops, + recovery === 'running' || recovery === 'held_empty' + ? [{ sessionId: admitted!.sessionId, turnId: admitted!.turnId, runId: admitted!.runId }] + : [], + ); + assert.equal( + turnQueries, + recovery === 'absent' || + recovery === 'other_turn' || + recovery === 'held_empty' || + recovery === 'failed' + ? 2 + : 1, + ); + assert.deepEqual(response()?.result, { stopReason: 'cancelled' }); + } finally { + releaseRecovery([]); + releaseFailedRecoveryQuery(); + stdin.end(); + await run; + } + }); + } + + test('publishes a local configuration commit before a newer external revision', { + timeout: 5_000, + }, async () => { + const stdin = new PassThrough(); + let sessionId: string | undefined; + let initial: SessionCatalogProjection | undefined; + let committed: SessionCatalogProjection | undefined; + let external: SessionCatalogProjection | undefined; + let turn: + | { + sessionId: string; + turnId: string; + runId: string; + status: 'running'; + } + | undefined; + const snapshot = ( + projectionRevision: number, + metadataRevision: number, + rootTurn: SessionContinuitySnapshot['rootTurn'], + ): SessionContinuitySnapshot => + continuitySnapshot({ sessionId: sessionId!, projectionRevision, metadataRevision, rootTurn }); + let releaseLocalProjection!: (catalog: ReturnType) => void; + const localProjection = new Promise>((resolve) => { + releaseLocalProjection = resolve; + }); + let catalogReads = 0; + let sessionReads = 0; + let subscription: FakeSubscription | undefined; + const connection = { + request: async (operation: string, input: unknown) => { + if (operation === 'session.create') { + sessionId = (input as { sessionId: string }).sessionId; + initial = sessionProjection({ id: sessionId }); + committed = sessionProjection({ + id: sessionId, + revision: 2, + permissionMode: 'bypass', + }); + external = sessionProjection({ + id: sessionId, + revision: 3, + permissionMode: 'ask', + }); + return initial; + } + if (operation === 'connection.catalog.query') { + catalogReads += 1; + return catalogReads === 2 ? localProjection : connectionCatalogPage(); + } + if (operation === 'session.catalog.query') { + sessionReads += 1; + return { kind: 'session', session: sessionReads === 1 ? initial! : external! }; + } + if (operation === 'session.configuration.update') { + assert.deepEqual(input, { + sessionId: sessionId!, + expectedRevision: 1, + patch: { permissionMode: 'bypass' }, + }); + return { kind: 'committed', session: committed! }; + } + if (operation === 'turn.start') { + const request = input as { sessionId: string; turnId: string }; + turn = { + sessionId: request.sessionId, + turnId: request.turnId, + runId: 'run-configuration-order', + status: 'running', + }; + return { kind: 'started', turn }; + } + assert.fail(`Unexpected operation: ${operation}`); + }, + openSessionSubscription: async () => { + subscription = new FakeSubscription(snapshot(1, 1, null), Promise.resolve([])); + return subscription; + }, + close: async () => undefined, + } as unknown as RuntimeHostConnection; + const harness = createHarness([], { stdin, connection }); + const run = harness.run(); + const send = (id: number, method: string, params: unknown) => + stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + const response = (id: number) => + ( + harness.stdoutMessages() as Array<{ + id?: number; + result?: { configOptions?: Array<{ id?: string; currentValue?: string }> }; + }> + ).find((message) => message.id === id); + const configurationUpdates = () => + ( + harness.stdoutMessages() as Array<{ + method?: string; + params?: { + update?: { + sessionUpdate?: string; + configOptions?: Array<{ id?: string; currentValue?: string }>; + }; + }; + }> + ).filter( + (message) => + message.method === 'session/update' && + message.params?.update?.sessionUpdate === 'config_option_update', + ); + let sequence = 0; + let terminalPushed = false; + const pushSnapshot = (next: SessionContinuitySnapshot) => { + subscription!.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: ++sequence, + snapshot: next, + }); + }; + + try { + send(1, 'session/new', { cwd: '/workspace', mcpServers: [] }); + await waitFor(() => Boolean(response(1))); + send(2, 'session/prompt', { + sessionId: sessionId!, + prompt: [{ type: 'text', text: 'Attach this Session' }], + }); + await waitFor(() => Boolean(turn && subscription)); + + send(3, 'session/set_config_option', { + sessionId: sessionId!, + configId: 'permission_mode', + value: 'bypass', + }); + await waitFor(() => catalogReads === 2); + + pushSnapshot(snapshot(2, 3, turn!)); + await waitFor(() => subscription!.nextCalls >= 2); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(configurationUpdates(), []); + + releaseLocalProjection(connectionCatalogPage()); + await waitFor(() => configurationUpdates().length === 2 && Boolean(response(3))); + + assert.deepEqual( + configurationUpdates().map( + ({ params }) => + params?.update?.configOptions?.find(({ id }) => id === 'permission_mode')?.currentValue, + ), + ['bypass', 'ask'], + ); + assert.equal( + response(3)?.result?.configOptions?.find(({ id }) => id === 'permission_mode') + ?.currentValue, + 'bypass', + ); + assert.equal(sessionReads, 2); + + terminalPushed = true; + pushSnapshot( + snapshot(3, 3, { + ...turn!, + status: 'completed', + terminalEventId: 'terminal-configuration-order', + }), + ); + await waitFor(() => Boolean(response(2))); + } finally { + releaseLocalProjection(connectionCatalogPage()); + if (subscription && turn && !terminalPushed) { + pushSnapshot( + snapshot(3, 3, { + ...turn, + status: 'completed', + terminalEventId: 'terminal-configuration-order', + }), + ); + } + stdin.end(); + await run; + } + }); + test('answers initialize without connecting a Runtime Host', async () => { const harness = createHarness([ `${JSON.stringify({ @@ -42,7 +640,7 @@ describe('Maka ACP stdio server', () => { id: 1, result: { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {} } }, + agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }, @@ -51,6 +649,96 @@ describe('Maka ACP stdio server', () => { assert.equal(harness.connectCalls(), 0); }); + test('EOF aborts a first attachment waiting for real connection recovery during hydration', async () => { + const stdin = new PassThrough(); + let sessionId: string | undefined; + let subscription: FakeSubscription | undefined; + let disconnect!: () => void; + const closed = new Promise((resolve) => { + disconnect = resolve; + }); + let rejectTranscript!: (error: Error) => void; + const transcript = new Promise((_resolve, reject) => { + rejectTranscript = reject; + }); + let reconnectSignal: AbortSignal | undefined; + let turnStarts = 0; + const initial = { + rootId: 'root-1', + hostEpoch: 'host-1', + connectionId: 'connection-1', + selectedProtocol: 0, + compositionId: 'maka.interactive', + compositionRevision: '1', + closed, + request: async (operation: string, input: { sessionId: string }) => { + if (operation === 'session.create') { + sessionId = input.sessionId; + return sessionProjection({ id: sessionId }); + } + if (operation === 'connection.catalog.query') return connectionCatalogPage(); + if (operation === 'turn.start') turnStarts += 1; + assert.fail(`Unexpected operation ${operation}`); + }, + openSessionSubscription: async () => { + subscription = new FakeSubscription( + continuitySnapshot({ sessionId: sessionId!, projectionRevision: 1, rootTurn: null }), + transcript, + ); + return subscription; + }, + subscribeConfigurationChanges: () => () => undefined, + subscribeConnectionCatalogChanges: () => () => undefined, + subscribeProjectCatalogChanges: () => () => undefined, + subscribeSessionCatalogChanges: () => () => undefined, + subscribeScheduledTaskChanges: () => () => undefined, + close: async () => disconnect(), + } as unknown as RuntimeHostConnection; + const connection = await createRuntimeHostReconnectingConnection({ + initialConnection: initial, + connect: async (signal) => { + reconnectSignal = signal; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + backoff: { wait: async () => undefined }, + }); + const harness = createHarness([], { stdin, connection }); + let finished = false; + const run = harness.run().then((code) => { + finished = true; + return code; + }); + const send = (id: number, method: string, params: unknown) => + stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + try { + send(1, 'session/new', { cwd: '/workspace', mcpServers: [] }); + await waitFor(() => + harness.stdoutMessages().some((message) => (message as { id?: number }).id === 1), + ); + send(2, 'session/prompt', { + sessionId: sessionId!, + prompt: [{ type: 'text', text: 'attach' }], + }); + await waitFor(() => Boolean(subscription && subscription.nextCalls > 0)); + disconnect(); + rejectTranscript(new RuntimeHostSubscriptionError('connection_closed', 'Host disconnected')); + await waitFor(() => Boolean(reconnectSignal) && subscription!.closeCalls > 0); + stdin.end(); + await waitFor(() => finished); + assert.equal(await run, 0); + assert.equal(reconnectSignal?.aborted, true); + assert.equal(turnStarts, 0); + } finally { + stdin.end(); + // Also releases the old implementation on a red test, without masking + // the assertion that EOF itself must complete teardown. + await connection.close(); + await run; + } + }); + test('returns zero after normal EOF without connecting a Runtime Host', async () => { const harness = createHarness([]); @@ -239,12 +927,12 @@ describe('Maka ACP stdio server', () => { const methodFailure = responses.get(3) as { error?: { code?: unknown; data?: unknown }; }; - assert.equal(methodFailure.error?.code, -32601); - assert.deepEqual(methodFailure.error?.data, { method: 'session/close' }); + assert.equal(methodFailure.error?.code, -32602); + assert.deepEqual(methodFailure.error?.data, { reason: 'unknown_session' }); assert.equal(harness.connectCalls(), 1); }); - test('keeps an unimplemented Session method Host-independent', async () => { + test('keeps close for an unknown Session Host-independent', async () => { const harness = createHarness([ `${JSON.stringify({ jsonrpc: '2.0', @@ -266,8 +954,8 @@ describe('Maka ACP stdio server', () => { .find((message) => (message as { id?: unknown }).id === 2) as { error?: { code?: unknown; data?: unknown }; }; - assert.equal(response.error?.code, -32601); - assert.deepEqual(response.error?.data, { method: 'session/close' }); + assert.equal(response.error?.code, -32602); + assert.deepEqual(response.error?.data, { reason: 'unknown_session' }); assert.equal(harness.connectCalls(), 0); }); }); @@ -306,9 +994,26 @@ function createHarness( connects += 1; if (options.connectError) throw options.connectError; return { - connection, + connection: { + ...connection, + request: connection.request.bind(connection), + reconnecting: true, + hostEpoch: connection.hostEpoch ?? 'host-1', + openSessionSubscription: + connection.openSessionSubscription?.bind(connection) ?? + (async () => { + throw new Error('Unexpected Session attachment'); + }), + openSessionSubscriptionOnce: isRuntimeHostReconnectingConnection(connection) + ? connection.openSessionSubscriptionOnce.bind(connection) + : (connection.openSessionSubscription?.bind(connection) ?? + (async () => { + throw new Error('Unexpected Session attachment'); + })), + subscribeConnectionAvailability: () => () => undefined, + }, close: () => connection.close(), - } as Awaited< + } as unknown as Awaited< ReturnType< typeof import('../runtime-host-cli-context.js').connectRuntimeHostCliConnection > @@ -398,6 +1103,30 @@ function sessionProjection( }; } +function continuitySnapshot(input: { + readonly sessionId: string; + readonly projectionRevision: number; + readonly metadataRevision?: number; + readonly rootTurn: SessionContinuitySnapshot['rootTurn']; + readonly status?: 'active' | 'running'; +}): SessionContinuitySnapshot { + return { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: input.sessionId, + metadataRevision: input.metadataRevision ?? 1, + status: input.status ?? (input.rootTurn ? 'running' : 'active'), + createdAt: 1, + isArchived: false, + }, + projectionRevision: input.projectionRevision, + rootTurn: input.rootTurn, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }; +} + async function waitFor(predicate: () => boolean): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (predicate()) return; @@ -405,3 +1134,136 @@ async function waitFor(predicate: () => boolean): Promise { } assert.fail('condition was not reached'); } + +class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator { + subscribePtyData(): () => void { + return () => undefined; + } + readonly #sessionDomainListeners = new Set< + (frame: Extract) => void + >(); + subscribeSessionDomainChanges( + listener: ( + frame: Extract, + ) => void, + ): () => void { + this.#sessionDomainListeners.add(listener); + return () => this.#sessionDomainListeners.delete(listener); + } + readonly hostEpoch = 'host-1'; + readonly activeAssistantStreams = []; + readonly transcriptBootstrap = null; + readonly subscriptionId: string; + readonly #frames: SubscriptionFrame[] = []; + readonly #waiters: Array<{ + resolve(result: IteratorResult): void; + reject(error: Error): void; + }> = []; + nextCalls = 0; + closeCalls = 0; + #closed = false; + #failure: Error | undefined; + + constructor( + readonly snapshot: SessionContinuitySnapshot, + private readonly transcript: Promise, + subscriptionId = 'subscription-1', + ) { + this.subscriptionId = subscriptionId; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + next(): Promise> { + this.nextCalls += 1; + const frame = this.#frames.shift(); + if (frame) return Promise.resolve({ done: false, value: frame }); + if (this.#failure) return Promise.reject(this.#failure); + if (this.#closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject })); + } + + push(frame: SubscriptionFrame): void { + if (frame.kind === 'subscription.session_domain_changed') { + for (const listener of this.#sessionDomainListeners) listener(frame); + } + const waiter = this.#waiters.shift(); + if (waiter) waiter.resolve({ done: false, value: frame }); + else this.#frames.push(frame); + } + + fail(error: Error): void { + this.#failure = error; + for (const waiter of this.#waiters.splice(0)) waiter.reject(error); + } + + async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + return (await this.transcript).map(decodeMessage); + } + + async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { + return []; + } + + async decodeTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async loadTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async close(): Promise { + this.closeCalls += 1; + this.#closed = true; + for (const waiter of this.#waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } +} + +const unsupportedRequests = { + question: { + kind: 'question', + toolUseId: 'tool-1', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + form: { + kind: 'form', + toolUseId: 'tool-1', + message: 'Configure', + requester: { name: 'test', source: 'MCP' }, + fields: [{ kind: 'string', name: 'name', label: 'Name', required: true }], + }, + permission: { + kind: 'permission', + toolUseId: 'tool-1', + prompt: { + kind: 'tool_permission', + toolName: 'Bash', + category: 'shell_unsafe', + reason: 'shell_dangerous', + review: { kind: 'command', command: 'echo test', cwd: '/workspace' }, + rememberForTurnAllowed: true, + }, + }, + sandbox_boundary: { + kind: 'sandbox_boundary', + expansion: { network: { enabled: true } }, + justification: 'Network access', + }, + client_capability: { + kind: 'client_capability', + toolUseId: 'tool-1', + target: { + providerId: 'provider', + contractId: 'contract', + serverId: 'server', + toolName: 'tool', + capability: 'desktop_mcp', + scope: { kind: 'mcp_tool', serverId: 'server', toolName: 'tool' }, + }, + }, +} satisfies Record; diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ae6a7ceb74..a6572f2934 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -52,7 +52,10 @@ describe('Maka CLI args', () => { assert.match(help.text, /^ {2}maka activate /m); assert.match(help.text, /^ {2}maka eval /m); assert.match(help.text, /^ {2}maka update /m); - assert.match(help.text, /^ {2}maka --acp {2,}Serve ACP v1 over stdio /m); + assert.match( + help.text, + /^ {2}maka --acp {2,}Serve ACP v1 over stdio \(sessions, prompts, streaming, cancellation\)$/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); assert.doesNotMatch(help.text, /^ {2}maka runtime-host (?:serve|service|access) /m); diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 77b8338f5d..f5e9aaf575 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -907,6 +907,9 @@ function reconnectingConnection(connection: RuntimeHostConnection) { return { ...connection, reconnecting: true as const, + openSessionSubscriptionOnce: ( + input: Parameters[0], + ) => connection.openSessionSubscription(input), subscribeConnectionAvailability: ( listener: (availability: { kind: 'connected'; diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md new file mode 100644 index 0000000000..210e02314a --- /dev/null +++ b/packages/cli/src/acp/README.md @@ -0,0 +1,59 @@ + + +# ACP live Session behavior + +The adapter retains a Runtime Host subscription after the first prompt. Cancellation +and close use the subscription's current root identity, including a Turn started by +another Host client while the ACP attachment was idle. Close releases the subscription +and connection-local ownership; it does not delete or archive the durable Session. + +ACP v1 message chunks are append-only. Matching replay and prefix extensions are +supported. If a completed or recovered message changes text or thinking that was +already streamed (including clearing it), the adapter rejects the prompt with +JSON-RPC error `-32603` and `error.data.code: unsupported_stream_revision` and requests +a stop of that prompt's exact live root. It never represents a replacement by inventing +a new message ID or reports `end_turn` for that failed projection. The client may +still display the already delivered partial text; ACP v1 cannot retract it. The +Session remains owned and can accept another prompt or be closed. + +Local resource links must identify regular files. Filesystem admission rejects +non-regular files, including POSIX FIFOs, before reading their content. +After live attachment succeeds, the adapter uploads each linked file through the +Host's existing Session Artifact protocol and uses its canonical attachment +reference for Turn admission. Cancellation or close during an upload aborts staged +content and prevents that prompt from starting a Turn. + +When a dispatched start loses its response, the adapter retries admission queries +with bounded deadlines instead of replaying the start. Only a matching Turn or +authoritative `not_found` settles admission. Exhausted reads report `outcome_unknown`; +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. diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index 11926dc954..ac91c535f7 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -22,14 +22,17 @@ import type { AcpSessionRegistry } from './session-registry.js'; export interface MakaAcpAgentOptions { readonly version: string; - readonly sessionRegistry: Pick; + readonly sessionRegistry: Pick< + AcpSessionRegistry, + 'create' | 'list' | 'setConfigOption' | 'prompt' | 'cancel' | 'close' + >; } export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { return agent({ name: 'maka' }) .onRequest(methods.agent.initialize, () => ({ protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {} } }, + agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: options.version }, })) @@ -37,5 +40,15 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { .onRequest(methods.agent.session.list, ({ params }) => options.sessionRegistry.list(params)) .onRequest(methods.agent.session.setConfigOption, ({ params }) => options.sessionRegistry.setConfigOption(params), - ); + ) + .onRequest(methods.agent.session.prompt, ({ params, signal, client }) => + options.sessionRegistry.prompt(params, { + signal, + notify: (notification) => client.notify(methods.client.session.update, notification), + }), + ) + .onNotification(methods.agent.session.cancel, ({ params }) => + options.sessionRegistry.cancel(params), + ) + .onRequest(methods.agent.session.close, ({ params }) => options.sessionRegistry.close(params)); } diff --git a/packages/cli/src/acp/prompt-content.ts b/packages/cli/src/acp/prompt-content.ts new file mode 100644 index 0000000000..ee38c21f0a --- /dev/null +++ b/packages/cli/src/acp/prompt-content.ts @@ -0,0 +1,262 @@ +/* + * 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, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { open, realpath } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RequestError, type ContentBlock } from '@agentclientprotocol/sdk'; +import { + attachmentKindFromMimeType, + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_COUNT, + PDF_HEADER_SCAN_BYTES, + resolveAttachmentMimeType, +} from '@maka/core/attachments'; +import type { AttachmentRef, MessageContent } from '@maka/core/events'; +import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { ARTIFACT_INGEST_CHUNK_MAX_BYTES } from '@maka/runtime-host/protocol'; + +interface OpenedPromptFile { + readonly size: number; + readonly isFile: boolean; + readonly prefix: Uint8Array; + readonly canonicalPath: string; +} + +export interface AcpPromptContentDependencies { + readonly openFile?: (path: string) => Promise; +} + +export async function mapAcpPromptContent( + prompt: readonly ContentBlock[], + dependencies: AcpPromptContentDependencies = {}, +): Promise { + const resources = prompt.filter( + (block): block is Extract => + block.type === 'resource_link', + ); + if (resources.length > MAX_ATTACHMENT_COUNT) { + throw invalidPrompt('prompt', 'too_many_attachments'); + } + for (const block of prompt) { + if (block.type !== 'text' && block.type !== 'resource_link') { + throw invalidPrompt('prompt', 'unsupported_content_type'); + } + } + + const modelParts: string[] = []; + const displayParts: string[] = []; + const attachments: AttachmentRef[] = []; + for (const block of prompt) { + if (block.type === 'text') { + modelParts.push(block.text); + displayParts.push(block.text); + continue; + } + if (block.type !== 'resource_link') { + throw invalidPrompt('prompt', 'unsupported_content_type'); + } + const path = localFilePath(block.uri); + const file = await (dependencies.openFile ?? readPromptFile)(path).catch((error: unknown) => { + if (error instanceof RequestError) throw error; + throw invalidPrompt('prompt', 'resource_unreadable'); + }); + if (!file.isFile) throw invalidPrompt('prompt', 'resource_not_file'); + if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_ATTACHMENT_BYTES) { + throw invalidPrompt('prompt', 'resource_too_large'); + } + const name = block.name || basename(file.canonicalPath); + const mimeType = resolveAttachmentMimeType(file.prefix, block.mimeType ?? undefined, name); + modelParts.push(block.uri); + attachments.push({ + kind: attachmentKindFromMimeType(mimeType, name), + name, + mimeType, + bytes: file.size, + ref: { kind: 'external_file', absolutePath: file.canonicalPath }, + }); + } + const text = modelParts.join('\n\n'); + const displayText = displayParts.join('\n\n'); + return { + text, + ...(displayText !== text ? { displayText } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + }; +} + +/** Publish only after live attachment succeeds, before admitting the prompt's Turn. */ +export async function publishAcpPromptAttachments( + content: MessageContent, + options: { + readonly sessionId: string; + readonly connection: Pick; + readonly assertActive: () => void; + }, +): Promise { + const attachments: AttachmentRef[] = []; + for (const attachment of content.attachments ?? []) { + options.assertActive(); + if (attachment.ref.kind !== 'external_file') { + attachments.push(attachment); + continue; + } + const bytes = await readPromptAttachment(attachment.ref.absolutePath, options.assertActive); + options.assertActive(); + const uploadId = randomUUID(); + const identity = { sessionId: options.sessionId, uploadId }; + let opened = false; + try { + const begin = await options.connection.request('artifact.ingest', { + kind: 'begin', + ...identity, + name: attachment.name, + mimeType: resolveAttachmentMimeType(bytes, attachment.mimeType, attachment.name), + totalBytes: bytes.length, + contentSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + }); + if (begin.kind === 'committed') { + options.assertActive(); + attachments.push(begin.attachment); + continue; + } + if (begin.kind !== 'upload_opened') { + throw new Error('Runtime Host did not open the Attachment upload'); + } + opened = true; + let offset = begin.nextOffset; + while (offset < bytes.length) { + options.assertActive(); + const chunk = bytes.subarray(offset, offset + ARTIFACT_INGEST_CHUNK_MAX_BYTES); + const accepted = await options.connection.request('artifact.ingest', { + kind: 'chunk', + ...identity, + offset, + chunkBase64: chunk.toString('base64'), + }); + if (accepted.kind !== 'chunk_accepted' || accepted.nextOffset !== offset + chunk.length) { + throw new Error('Runtime Host did not advance the Attachment upload'); + } + offset = accepted.nextOffset; + } + options.assertActive(); + const committed = await options.connection.request('artifact.ingest', { + kind: 'commit', + ...identity, + }); + if (committed.kind !== 'committed') { + throw new Error('Runtime Host did not commit the Attachment upload'); + } + opened = false; + options.assertActive(); + attachments.push(committed.attachment); + } catch (error) { + if (opened) { + await options.connection + .request('artifact.ingest', { kind: 'abort', ...identity }) + .catch(() => undefined); + } + throw error; + } + } + options.assertActive(); + return content.attachments ? { ...content, attachments } : content; +} + +async function readPromptAttachment(path: string, assertActive: () => void): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK).catch(() => { + throw invalidPrompt('prompt', 'resource_unreadable'); + }); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw invalidPrompt('prompt', 'resource_not_file'); + if (!Number.isSafeInteger(stats.size) || stats.size < 0 || stats.size > MAX_ATTACHMENT_BYTES) { + throw invalidPrompt('prompt', 'resource_too_large'); + } + const bytes = Buffer.alloc(stats.size); + let offset = 0; + while (offset < bytes.length) { + assertActive(); + const { bytesRead } = await handle.read( + bytes, + offset, + Math.min(ARTIFACT_INGEST_CHUNK_MAX_BYTES, bytes.length - offset), + offset, + ); + if (bytesRead === 0) throw invalidPrompt('prompt', 'resource_changed'); + offset += bytesRead; + } + if ((await handle.stat()).size !== bytes.length) { + throw invalidPrompt('prompt', 'resource_changed'); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function readPromptFile(path: string): Promise { + // A FIFO must not wait for a writer before we can reject it. + const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw invalidPrompt('prompt', 'resource_not_file'); + const prefix = Buffer.alloc(Math.min(PDF_HEADER_SCAN_BYTES, stats.size)); + const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0); + return { + size: stats.size, + isFile: stats.isFile(), + prefix: prefix.subarray(0, bytesRead), + canonicalPath: await realpath(path), + }; + } finally { + await handle.close(); + } +} + +function localFilePath(uri: string): string { + let url: URL; + try { + url = new URL(uri); + } catch { + throw invalidPrompt('prompt', 'invalid_resource_uri'); + } + if ( + url.protocol !== 'file:' || + url.hostname !== '' || + url.username !== '' || + url.password !== '' || + url.port !== '' || + url.search !== '' || + url.hash !== '' + ) { + throw invalidPrompt('prompt', 'unsupported_resource_uri'); + } + try { + return fileURLToPath(url); + } catch { + throw invalidPrompt('prompt', 'invalid_resource_uri'); + } +} + +function invalidPrompt(field: string, reason: string): RequestError { + return RequestError.invalidParams({ field, reason }, 'Invalid ACP prompt content'); +} diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts new file mode 100644 index 0000000000..a454235b4c --- /dev/null +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -0,0 +1,139 @@ +/* + * 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 { foldRuntimeHostAssistantDelta } from '@maka/runtime-host/adapter'; +import { + RequestError, + type SessionNotification, + type SessionUpdate, +} from '@agentclientprotocol/sdk'; +import type { SessionEvent } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; + +type StreamKind = 'text' | 'thinking'; + +export interface AcpSessionEventMapperOptions { + readonly sessionId: string; + readonly notify: (notification: SessionNotification) => Promise; +} + +/** Serializes one ACP prompt's live projection delivery. */ +export class AcpSessionEventMapper { + readonly #sessionId: string; + readonly #notify: (notification: SessionNotification) => Promise; + readonly #streams = new Map(); + #tail: Promise = Promise.resolve(); + #failure: RequestError | undefined; + + constructor(options: AcpSessionEventMapperOptions) { + this.#sessionId = options.sessionId; + this.#notify = options.notify; + } + + accept(event: SessionEvent): Promise { + return this.#enqueue(async () => { + if (this.#failure) throw this.#failure; + switch (event.type) { + case 'text_delta': + await this.#acceptText( + 'text', + event.messageId, + deltaText(event, this.#streams.get(streamKey('text', event.messageId))), + ); + break; + case 'text_complete': + await this.#acceptText('text', event.messageId, event.text); + break; + case 'thinking_delta': + await this.#acceptText( + 'thinking', + event.messageId, + deltaText(event, this.#streams.get(streamKey('thinking', event.messageId))), + ); + break; + case 'thinking_complete': + await this.#acceptText('thinking', event.messageId, event.text); + break; + default: + break; + } + }); + } + + replaceTranscript(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); + } + }); + } + + /** Waits until every notification already accepted by this mapper has settled. */ + flush(): Promise { + return this.#tail.then(() => undefined); + } + + async #acceptText(kind: StreamKind, hostMessageId: string, nextText: string): Promise { + const key = streamKey(kind, hostMessageId); + const current = this.#streams.get(key) ?? ''; + if (!nextText.startsWith(current)) { + // ACP v1 chunks only append. A new message ID cannot retract prior output. + this.#failure = RequestError.internalError( + { source: 'adapter', code: 'unsupported_stream_revision' }, + 'Runtime Host revised streamed output that ACP v1 cannot replace; the prompt failed', + ); + throw this.#failure; + } + const chunk = nextText.slice(current.length); + this.#streams.set(key, nextText); + if (chunk.length === 0) return; + const update: SessionUpdate = { + sessionUpdate: kind === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk', + content: { type: 'text', text: chunk }, + messageId: hostMessageId, + }; + await this.#notify({ sessionId: this.#sessionId, update }); + } + + #enqueue(operation: () => Promise): Promise { + const result = this.#tail.then(operation, operation); + this.#tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function deltaText( + event: Extract, + current = '', +): string { + return foldRuntimeHostAssistantDelta(current, { + startOffset: event.startOffset ?? current.length, + text: event.text, + }).text; +} + +function streamKey(kind: StreamKind, messageId: string): string { + return `${kind}:${messageId}`; +} diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 7d8f28e109..dfaf3ff5f3 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -22,31 +22,45 @@ import { realpath } from 'node:fs/promises'; import { isAbsolute, normalize } from 'node:path'; import { RequestError, + type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type ListSessionsRequest, type ListSessionsResponse, type NewSessionRequest, type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type SessionNotification, type SessionConfigOption, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, + type StopReason, } from '@agentclientprotocol/sdk'; +import type { SessionEvent } from '@maka/core/events'; +import { isRuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { readRuntimeHostConnectionCatalog, readRuntimeHostSessionCatalogPage, RuntimeHostCatalogReadError, RuntimeHostOperationError, RuntimeHostRequestInterruptedError, + RuntimeHostSubscriptionError, RuntimeHostSessionCatalogRevisionChangedError, - type RuntimeHostConnection, + type RuntimeHostReconnectingConnection, type RuntimeHostSessionCatalogPageCursor, } from '@maka/runtime-host/client'; import { SESSION_CATALOG_CURSOR_MAX_BYTES, SESSION_CATALOG_CWD_MAX_BYTES, + HOST_OPERATION_SPECS, type SessionCatalogProjection, + type TurnSnapshot, } from '@maka/runtime-host/protocol'; +import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { RuntimeHostSessionUpdateError, + getRuntimeHostSession, requireRuntimeHostSessionProjection, updateRuntimeHostSession, } from '../runtime-host-session-update.js'; @@ -56,32 +70,83 @@ import { projectAcpSessionConfigOptions, validateAcpSessionConfigOptionRequest, } from './session-configuration.js'; +import { AcpSessionEventMapper } from './session-event-mapper.js'; +import { mapAcpPromptContent, publishAcpPromptAttachments } from './prompt-content.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; +const ADMISSION_QUERY_MAX_ATTEMPTS = 5; +const ADMISSION_QUERY_TIMEOUT_MS = 1_000; +const ADMISSION_QUERY_RETRY_MS = 25; type AcpSessionRegistryOperation = | 'connection.catalog.query' | 'session.create' | 'session.catalog.query' - | 'session.configuration.update'; -type AcpSessionRegistryLifecycleOperation = 'connect' | AcpSessionRegistryOperation; + | 'session.configuration.update' + | 'artifact.ingest' + | 'subscription.open' + | 'turn.start' + | 'turn.stop'; +type AcpSessionRegistryLifecycleOperation = + | 'connect' + | 'session.close' + | AcpSessionRegistryOperation; -export interface AcpSessionRegistryConnection { - readonly request: RuntimeHostConnection['request']; - close(): Promise; +export interface AcpSessionRegistryConnection + extends Pick< + RuntimeHostReconnectingConnection, + 'reconnecting' | 'request' | 'openSessionSubscription' | 'openSessionSubscriptionOnce' | 'close' + > {} + +export interface AcpPromptContext { + readonly signal: AbortSignal; + readonly notify: (notification: SessionNotification) => Promise; } export interface AcpSessionRegistryOptions { readonly connect: (signal: AbortSignal) => Promise; readonly newSessionId?: () => string; + readonly newTurnId?: () => string; +} + +interface AcpAttachmentConfiguration { + readonly notify: AcpPromptContext['notify']; + tail: Promise; + metadataRevision?: number; + options?: string; + delivery?: Promise; +} + +interface ActiveAcpPrompt { + readonly sessionId: string; + readonly turnId: string; + readonly mapper: AcpSessionEventMapper; + readonly waiters: Set<() => void>; + attachment?: RuntimeHostSessionChannel; + dispatchStarted: boolean; + startRequestSettled: boolean; + admissionSettled: boolean; + admissionQuery?: Promise; + admissionFailure?: RequestError; + startedTurn?: TurnSnapshot; + cancelled: boolean; + finished: boolean; + stopTask?: Promise; } /** Owns all Runtime Host resources associated with one ACP connection. */ export class AcpSessionRegistry { readonly #connect: (signal: AbortSignal) => Promise; readonly #newSessionId: () => string; + readonly #newTurnId: () => string; readonly #inFlightOperations = new Set>(); readonly #ownedSessionIds = new Set(); + readonly #attachments = new Map>(); + readonly #attachmentOpenControllers = new Map(); + readonly #attachmentConfigurations = new Map(); + readonly #pendingConfigSets = new Map>>(); + readonly #activePrompts = new Map>(); + readonly #sessionCloseTasks = new Map>(); #connection: AcpSessionRegistryConnection | undefined; #connectTask: Promise | undefined; #connectAbortController: AbortController | undefined; @@ -92,6 +157,7 @@ export class AcpSessionRegistry { constructor(options: AcpSessionRegistryOptions) { this.#connect = options.connect; this.#newSessionId = options.newSessionId ?? randomUUID; + this.#newTurnId = options.newTurnId ?? randomUUID; } async create(params: NewSessionRequest): Promise { @@ -120,7 +186,57 @@ export class AcpSessionRegistry { } catch (error) { throw requestErrorFromConfigInput(error); } - return this.#track(this.#setConfigOption(params)); + const configuration = this.#attachmentConfigurations.get(params.sessionId); + const operation = this.#track( + configuration + ? this.#queueConfiguration(configuration, () => + this.#setConfigOption(params, configuration), + ) + : this.#setConfigOption(params), + ); + let pending = this.#pendingConfigSets.get(params.sessionId); + if (!pending) { + pending = new Set(); + this.#pendingConfigSets.set(params.sessionId, pending); + } + pending.add(operation); + try { + return await operation; + } finally { + pending.delete(operation); + if (pending.size === 0) this.#pendingConfigSets.delete(params.sessionId); + } + } + + async prompt(params: PromptRequest, context: AcpPromptContext): Promise { + this.#assertOpen('turn.start'); + this.#assertOwned(params.sessionId); + return this.#track(this.#prompt(params, context)); + } + + async cancel(params: CancelNotification): Promise { + if (this.#closing) return; + await this.#cancelSession(params.sessionId); + } + + async close(params: CloseSessionRequest): Promise { + this.#assertOpen('session.close'); + const existing = this.#sessionCloseTasks.get(params.sessionId); + if (existing) return existing; + this.#assertOwned(params.sessionId); + this.#ownedSessionIds.delete(params.sessionId); + const configuration = this.#attachmentConfigurations.get(params.sessionId); + const delivery = configuration?.delivery; + this.#attachmentConfigurations.delete(params.sessionId); + const task = this.#track(this.#closeSession(params.sessionId, delivery)); + this.#sessionCloseTasks.set(params.sessionId, task); + const forget = () => { + if (this.#sessionCloseTasks.get(params.sessionId) === task) { + this.#sessionCloseTasks.delete(params.sessionId); + } + }; + void task.then(forget, forget); + return task; } dispose(): Promise { @@ -130,6 +246,522 @@ export class AcpSessionRegistry { return this.#disposeTask; } + async #prompt(params: PromptRequest, context: AcpPromptContext): Promise { + const turnId = this.#newTurnId(); + const active: ActiveAcpPrompt = { + sessionId: params.sessionId, + turnId, + mapper: new AcpSessionEventMapper({ + sessionId: params.sessionId, + notify: async (notification) => { + if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { + await context.notify(notification); + } + }, + }), + waiters: new Set(), + dispatchStarted: false, + startRequestSettled: false, + admissionSettled: false, + cancelled: false, + finished: false, + }; + this.#addActivePrompt(active); + const onAbort = () => { + void this.#cancelPrompt(active).catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) onAbort(); + try { + const content = await mapAcpPromptContent(params.prompt); + let startInput; + try { + startInput = HOST_OPERATION_SPECS['turn.start'].decodeInput({ + sessionId: params.sessionId, + turnId, + content, + }); + } catch { + throw RequestError.invalidParams( + { field: 'prompt', reason: 'runtime_host_admission_rejected' }, + 'Prompt cannot be admitted by Runtime Host', + ); + } + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + + const connection = await this.#getConnection('subscription.open'); + let attachment: RuntimeHostSessionChannel; + try { + attachment = await this.#ensureAttachment(params.sessionId, connection, context.notify); + } catch (error) { + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + throw error; + } + active.attachment = attachment; + this.#wake(active); + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + + try { + startInput = { + ...startInput, + content: await publishAcpPromptAttachments(content, { + sessionId: params.sessionId, + connection, + assertActive: () => { + if (active.cancelled) throw new Error('ACP prompt cancelled before Turn admission'); + this.#assertOpen('turn.start'); + this.#assertOwned(params.sessionId); + }, + }), + }; + } catch (error) { + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'artifact.ingest'); + } + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + + 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. + void observation.catch(() => undefined); + active.dispatchStarted = true; + this.#wake(active); + try { + const result = await connection.request('turn.start', startInput); + active.startRequestSettled = true; + active.admissionSettled = true; + if (result.kind === 'started') active.startedTurn = result.turn; + this.#wake(active); + if (result.kind === 'blocked') { + const error = new Error('Runtime Host blocked the requested Turn'); + attachment.failTurn(turnId, error); + throw error; + } + } catch (error) { + // A lost dispatched response does not establish whether Host admitted + // this Turn. Retain this attempt until subscription or query facts do. + active.startRequestSettled = true; + active.admissionSettled ||= !( + error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched' + ); + if (!active.admissionSettled) { + this.#queryPromptAdmission(active, connection); + } + this.#wake(active); + attachment.failTurn(turnId, error); + if (!active.cancelled) throw requestErrorFromRuntimeHost(error, 'turn.start'); + } + + if (active.cancelled) { + await active.stopTask?.catch(() => undefined); + return { stopReason: await this.#cancelledStopReason(active) }; + } + const stopReason = await observation; + return { stopReason }; + } catch (error) { + // A failed projection must not leave the corresponding Host Turn running. + active.stopTask ??= this.#stopPromptWhenObservable(active); + await active.stopTask.catch(() => undefined); + if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; + if (active.admissionFailure) throw active.admissionFailure; + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'subscription.open'); + } finally { + context.signal.removeEventListener('abort', onAbort); + active.finished = true; + this.#wake(active); + this.#removeActivePrompt(active); + } + } + + async #consumePromptEvents( + active: ActiveAcpPrompt, + events: AsyncIterable, + ): Promise { + try { + for await (const event of events) { + if (!active.cancelled) await active.mapper.accept(event); + } + return active.cancelled ? this.#cancelledStopReason(active) : 'end_turn'; + } catch (error) { + if (active.cancelled) return this.#cancelledStopReason(active); + throw error; + } + } + + async #cancelledStopReason(active: ActiveAcpPrompt): Promise<'cancelled'> { + await active.mapper.flush(); + return 'cancelled'; + } + + #cancelSession(sessionId: string): Promise[]> { + const active = [...(this.#activePrompts.get(sessionId) ?? [])]; + const cancellations = active.map((prompt) => this.#cancelPrompt(prompt)); + this.#attachmentOpenControllers.get(sessionId)?.abort(); + const attachment = this.#attachments.get(sessionId); + if (attachment) { + cancellations.push( + attachment.then( + async (opened) => { + const root = opened.snapshot.rootTurn; + // Local prompts already latch cancellation across pending turn.start. + // An idle attachment may also observe a Turn started by another client. + if ( + root && + !isRuntimeHostTerminalTurn(root) && + !active.some((prompt) => prompt.turnId === root.turnId) + ) { + await this.#connection?.request('turn.stop', { + sessionId: root.sessionId, + turnId: root.turnId, + runId: root.runId, + }); + } + }, + () => undefined, + ), + ); + } + return Promise.allSettled(cancellations); + } + + async #cancelPrompt(active: ActiveAcpPrompt): Promise { + active.cancelled = true; + this.#wake(active); + if ( + [...(this.#activePrompts.get(active.sessionId) ?? [])].every( + (prompt) => prompt.cancelled && !prompt.dispatchStarted, + ) + ) { + this.#attachmentOpenControllers.get(active.sessionId)?.abort(); + } + active.stopTask ??= this.#stopPromptWhenObservable(active); + await Promise.all([ + active.mapper.flush(), + active.stopTask.catch((error: unknown) => { + // End only this prompt's observation. Failed delivery does not establish + // a terminal Host Turn, and teardown still receives the original error. + active.attachment?.failTurn(active.turnId, error); + throw error; + }), + ]); + } + + async #stopPromptWhenObservable(active: ActiveAcpPrompt): Promise { + if (!active.dispatchStarted) return; + while (!active.finished) { + const observed = active.attachment?.snapshot.rootTurn; + // Subscription teardown can precede the start response. Keep the admitted + // identity until exact Stop completes, even when observation has ended. + const root = observed?.turnId === active.turnId ? observed : active.startedTurn; + if (root) { + if (isRuntimeHostTerminalTurn(root)) return; + const connection = this.#connection; + if (!connection) return; + try { + await connection.request('turn.stop', { + sessionId: root.sessionId, + turnId: root.turnId, + runId: root.runId, + }); + } catch (error) { + console.error('[acp] Host Stop delivery failed:', error); + throw error; + } + return; + } + if (active.admissionSettled && active.startRequestSettled) return; + if (active.admissionFailure) { + console.error('[acp] Host Turn admission remains unknown:', active.admissionFailure); + throw active.admissionFailure; + } + await this.#waitForPromptChange(active); + } + } + + #queryPromptAdmission(active: ActiveAcpPrompt, connection: AcpSessionRegistryConnection): void { + // Recovery and the lost start response can both request this read. Keep one + // bounded retry task; neither a healthy subscription nor a failed query + // establishes whether a dispatched start was admitted. + active.admissionQuery ??= this.#readPromptAdmission(active, connection); + } + + async #readPromptAdmission( + active: ActiveAcpPrompt, + connection: AcpSessionRegistryConnection, + ): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < ADMISSION_QUERY_MAX_ATTEMPTS; attempt += 1) { + if (active.finished || active.admissionSettled || (this.#closing && attempt > 0)) return; + const observed = active.attachment?.snapshot.rootTurn; + if (observed?.turnId === active.turnId) { + active.startedTurn = observed; + active.admissionSettled = true; + this.#wake(active); + return; + } + try { + const turn = await connection.request( + 'turn.query', + { sessionId: active.sessionId, turnId: active.turnId }, + ADMISSION_QUERY_TIMEOUT_MS, + ); + if (!active.finished && !active.admissionSettled) { + active.startedTurn = turn; + active.admissionSettled = true; + this.#wake(active); + } + return; + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === 'not_found') { + active.admissionSettled = true; + this.#wake(active); + return; + } + lastError = error; + } + if (active.finished || active.admissionSettled || this.#closing) return; + if (attempt + 1 < ADMISSION_QUERY_MAX_ATTEMPTS) { + await this.#waitForPromptChange(active, ADMISSION_QUERY_RETRY_MS * 2 ** attempt); + } + } + if (active.attachment?.snapshot.rootTurn?.turnId === active.turnId) { + this.#wake(active); + return; + } + active.admissionFailure = RequestError.internalError( + { + source: 'runtime_host', + operation: 'turn.query', + code: 'outcome_unknown', + reason: 'admission_query_failed', + attempts: ADMISSION_QUERY_MAX_ATTEMPTS, + cause: runtimeHostErrorData(lastError, 'turn.query'), + }, + 'Runtime Host Turn admission could not be established; Stop could not be confirmed', + ); + this.#wake(active); + } + + async #ensureAttachment( + sessionId: string, + connection: AcpSessionRegistryConnection, + notify: AcpPromptContext['notify'], + ): Promise { + const existing = this.#attachments.get(sessionId); + if (existing) return existing; + const openingController = new AbortController(); + this.#attachmentOpenControllers.set(sessionId, openingController); + const configuration: AcpAttachmentConfiguration = { + 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) ?? [])]), + }; + this.#attachmentConfigurations.set(sessionId, configuration); + let task!: Promise; + let attachment: RuntimeHostSessionChannel | undefined; + let earlyFailure: Error | undefined; + const failAttachment = (error: Error) => { + if (!attachment) { + earlyFailure = error; + return; + } + this.#retireFailedAttachment(sessionId, task, attachment, error); + }; + task = RuntimeHostSessionChannel.open({ + connection, + signal: openingController.signal, + openInitialSessionSubscription: connection.openSessionSubscriptionOnce.bind(connection), + sessionId, + now: Date.now, + onTurnStarted: () => undefined, + onRuntimeResourceChanged: () => undefined, + onSnapshotChanged: (snapshot) => { + this.#wakeSession(sessionId); + if (configuration.metadataRevision === undefined) { + configuration.metadataRevision = snapshot.session.metadataRevision; + return; + } + if (configuration.metadataRevision === snapshot.session.metadataRevision) return; + configuration.metadataRevision = snapshot.session.metadataRevision; + void this.#queueConfiguration(configuration, async () => { + if (!this.#configurationIsLive(sessionId, configuration)) return; + const session = await getRuntimeHostSession(connection, sessionId); + if (!session) throw unknownSessionError(); + const configOptions = await this.#projectConfigOptions(connection, session); + await this.#notifyConfiguration(sessionId, configuration, configOptions); + }).catch((error: unknown) => { + // Closing or replacing the attachment intentionally invalidates any + // in-flight presentation refresh; its interrupted read is no longer actionable. + if (this.#configurationIsLive(sessionId, configuration)) { + console.error('[acp] Session configuration refresh failed:', error); + } + }); + }, + onTranscriptReplaced: (turnId, messages) => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.turnId === turnId && !active.cancelled) { + void active.mapper.replaceTranscript(turnId, messages).catch((error: unknown) => { + active.attachment?.failTurn(turnId, error); + }); + } + } + }, + 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; + } + // 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: () => { + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if ( + active.attachment !== attachment || + !active.startRequestSettled || + active.admissionSettled + ) { + continue; + } + // Recovery may hydrate a snapshot taken before start admission. + // An absent root needs a fresh query; a matching root can be stopped + // directly by the existing cancellation task. + if (attachment?.snapshot.rootTurn?.turnId !== active.turnId) { + this.#queryPromptAdmission(active, connection); + } + this.#wake(active); + } + }, + }) + .then(({ channel }) => { + channel.activate(); + attachment = channel; + if (earlyFailure) { + this.#retireFailedAttachment(sessionId, task, channel, earlyFailure); + throw earlyFailure; + } + if (this.#closing || !this.#ownedSessionIds.has(sessionId)) { + return channel.close().then(() => { + throw this.#closing ? registryClosedError('subscription.open') : unknownSessionError(); + }); + } + return channel; + }) + .catch((error: unknown) => { + if (this.#attachments.get(sessionId) === task) { + this.#attachments.delete(sessionId); + this.#attachmentConfigurations.delete(sessionId); + } + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'subscription.open'); + }) + .finally(() => { + if (this.#attachmentOpenControllers.get(sessionId) === openingController) { + this.#attachmentOpenControllers.delete(sessionId); + } + }); + this.#attachments.set(sessionId, task); + return task; + } + + #retireFailedAttachment( + sessionId: string, + task: Promise, + attachment: RuntimeHostSessionChannel, + error: Error, + ): void { + if (this.#attachments.get(sessionId) === task) { + this.#attachments.delete(sessionId); + this.#attachmentConfigurations.delete(sessionId); + } + for (const active of this.#activePrompts.get(sessionId) ?? []) { + if (active.attachment !== attachment) continue; + // Losing observation cannot settle a dispatched start. Its pending + // response or bounded admission query still owns the exact Stop identity. + attachment.failTurn(active.turnId, error); + this.#wake(active); + } + void attachment.close().catch(() => undefined); + } + + async #closeSession(sessionId: string, delivery?: Promise): Promise { + const cancellation = await this.#cancelSession(sessionId); + const attachmentTask = this.#attachments.get(sessionId); + this.#attachments.delete(sessionId); + let closeError: unknown; + if (attachmentTask) { + try { + // A rejected open has no retained resource; close still releases ownership. + const attachment = await attachmentTask.catch(() => undefined); + await attachment?.close(); + } catch (error) { + closeError = error; + } + } + await delivery; + const failedCancellation = cancellation.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failedCancellation) throw failedCancellation.reason; + if (closeError) throw closeError; + return {}; + } + + #addActivePrompt(active: ActiveAcpPrompt): void { + const prompts = this.#activePrompts.get(active.sessionId); + if (prompts) prompts.add(active); + else this.#activePrompts.set(active.sessionId, new Set([active])); + } + + #removeActivePrompt(active: ActiveAcpPrompt): void { + const prompts = this.#activePrompts.get(active.sessionId); + prompts?.delete(active); + if (prompts?.size === 0) this.#activePrompts.delete(active.sessionId); + } + + #wakeSession(sessionId: string): void { + for (const active of this.#activePrompts.get(sessionId) ?? []) this.#wake(active); + } + + #wake(active: ActiveAcpPrompt): void { + for (const resolve of active.waiters) resolve(); + active.waiters.clear(); + } + + #waitForPromptChange(active: ActiveAcpPrompt, timeoutMs?: number): Promise { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const wake = () => { + if (timer !== undefined) clearTimeout(timer); + active.waiters.delete(wake); + resolve(); + }; + active.waiters.add(wake); + if (timeoutMs !== undefined) timer = setTimeout(wake, timeoutMs); + }); + } + + #assertOwned(sessionId: string): void { + if (!this.#ownedSessionIds.has(sessionId)) throw unknownSessionError(); + } + async #create(params: NewSessionRequest): Promise { const connection = await this.#getConnection('session.create'); const sessionId = this.#newSessionId(); @@ -143,19 +775,24 @@ export class AcpSessionRegistry { } catch (error) { throw requestErrorFromRuntimeHost(error, 'session.create', { sessionId }); } - let created: SessionCatalogProjection; + // Session creation has committed. Optional presentation failures must not + // turn that success into an unreachable durable Session. + let configOptions: SessionConfigOption[] | undefined; try { - created = requireRuntimeHostSessionProjection(result, 'session.create'); - } catch (error) { - throw requestErrorFromSessionUpdate(error, 'session.create', { sessionId }); + const created = requireRuntimeHostSessionProjection(result, 'session.create'); + configOptions = await this.#projectConfigOptions(connection, created); + } catch { + // The client can still prompt, configure, list, or close the returned ID. } - const configOptions = await this.#projectConfigOptions(connection, created); - this.#ownedSessionIds.add(sessionId); - return { sessionId, configOptions }; + // Do not admit mutations while projection is pending, or resurrect ownership + // if connection shutdown raced the successful Host creation. + if (!this.#closing) this.#ownedSessionIds.add(sessionId); + return { sessionId, ...(configOptions ? { configOptions } : {}) }; } async #setConfigOption( params: SetSessionConfigOptionRequest & { readonly value: string }, + configuration?: AcpAttachmentConfiguration, ): Promise { const connection = await this.#getConnection('session.configuration.update'); let committed: SessionCatalogProjection; @@ -171,13 +808,56 @@ export class AcpSessionRegistry { }), { operation: 'session.configuration.update', - assertRequestAllowed: () => this.#assertOpen('session.configuration.update'), + assertRequestAllowed: () => { + this.#assertOpen('session.configuration.update'); + this.#assertOwned(params.sessionId); + }, }, ); } catch (error) { throw requestErrorFromSessionUpdate(error, 'session.configuration.update'); } - return { configOptions: await this.#projectConfigOptions(connection, committed) }; + const configOptions = await this.#projectConfigOptions(connection, committed); + if (configuration) + await this.#notifyConfiguration(params.sessionId, configuration, configOptions); + return { configOptions }; + } + + #configurationIsLive(sessionId: string, configuration: AcpAttachmentConfiguration): boolean { + return ( + !this.#closing && + this.#ownedSessionIds.has(sessionId) && + this.#attachmentConfigurations.get(sessionId) === configuration + ); + } + + #queueConfiguration( + configuration: AcpAttachmentConfiguration, + operation: () => Promise, + ): Promise { + // Serialize asynchronous catalog projection and delivery, not Host frames: + // session-channel/projector remain the only subscription ordering authority. + // A local set emits its committed options before its response; subscription + // refreshes observed during that set follow its notification in this queue. + const result = configuration.tail.then(operation, operation); + configuration.tail = result.catch(() => undefined); + return result; + } + + async #notifyConfiguration( + sessionId: string, + configuration: AcpAttachmentConfiguration, + configOptions: SessionConfigOption[], + ): Promise { + if (!this.#configurationIsLive(sessionId, configuration)) return; + const options = JSON.stringify(configOptions); + if (configuration.options === options) return; + configuration.delivery = configuration.notify({ + sessionId, + update: { sessionUpdate: 'config_option_update', configOptions }, + }); + await configuration.delivery; + configuration.options = options; } async #projectConfigOptions( @@ -245,9 +925,45 @@ export class AcpSessionRegistry { } async #dispose(): Promise { - const connectionClose = this.#closeOwnedConnection(); - await Promise.allSettled([connectionClose]); - await Promise.allSettled([...this.#inFlightOperations]); + const sessionIds = new Set([...this.#activePrompts.keys(), ...this.#attachments.keys()]); + const activePrompts = [...sessionIds].flatMap((sessionId) => [ + ...(this.#activePrompts.get(sessionId) ?? []), + ]); + const cancellations = [...sessionIds].map((sessionId) => this.#cancelSession(sessionId)); + const attachments = [...this.#attachments.values()]; + this.#attachments.clear(); + const configurations = [...this.#attachmentConfigurations.values()]; + this.#attachmentConfigurations.clear(); + await Promise.allSettled(attachments.map(async (attachment) => (await attachment).close())); + await Promise.allSettled( + activePrompts.map(async (active) => { + while (active.dispatchStarted && !active.startRequestSettled && !active.finished) { + await this.#waitForPromptChange(active); + } + }), + ); + const unknownAdmissions = activePrompts.filter((active) => { + const observed = active.attachment?.snapshot.rootTurn; + const hasStopIdentity = + observed?.turnId === active.turnId || active.startedTurn !== undefined; + return active.dispatchStarted && !active.admissionSettled && !hasStopIdentity; + }); + if (unknownAdmissions.length > 0) { + // At shutdown the attachment is already closed and each start request has + // settled, leaving recovery/query as the only remaining fact source. + // Close the owned connection so those reads cannot deadlock EOF cleanup. + await Promise.allSettled([this.#closeOwnedConnection()]); + for (const active of unknownAdmissions) { + active.admissionSettled = true; + this.#wake(active); + } + } + await Promise.allSettled(cancellations); + await Promise.allSettled([this.#closeOwnedConnection()]); + await Promise.allSettled([ + ...this.#inFlightOperations, + ...configurations.map(({ tail }) => tail), + ]); this.#ownedSessionIds.clear(); } @@ -318,12 +1034,19 @@ export class AcpSessionRegistry { } } - #assertOpen(operation: AcpSessionRegistryOperation): void { + #assertOpen(operation: AcpSessionRegistryLifecycleOperation): void { if (!this.#closing) return; throw registryClosedError(operation); } } +function unknownSessionError(): RequestError { + return RequestError.invalidParams( + { reason: 'unknown_session' }, + 'Session is not owned by this ACP connection', + ); +} + function registryClosedError(operation: AcpSessionRegistryLifecycleOperation): RequestError { return RequestError.internalError( { source: 'runtime_host', operation, code: 'registry_closed' }, @@ -430,6 +1153,14 @@ function runtimeHostErrorData(error: unknown, operation: string): Record undefined); + throw new Error('ACP requires a reconnecting Runtime Host connection'); + } return { - request: context.connection.request.bind( - context.connection, - ) as RuntimeHostConnection['request'], + reconnecting: true, + request: connection.request.bind(connection) as RuntimeHostConnection['request'], + openSessionSubscription: connection.openSessionSubscription.bind(connection), + openSessionSubscriptionOnce: connection.openSessionSubscriptionOnce.bind(connection), close: () => context.close(), }; }, diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index d1e0365651..071f83ef17 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 (initialize, session/new, session/list)'], + ['--acp', 'Serve ACP v1 over stdio (sessions, prompts, streaming, cancellation)'], ['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/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 03b57b0dd0..6460b83697 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -68,6 +68,10 @@ export interface RuntimeHostSessionChannelOpenResult { export interface RuntimeHostSessionChannelOptions { connection: Pick; + /** Optional opener pinned to the concrete Host connection used for first attachment. */ + openInitialSessionSubscription?: RuntimeHostConnection['openSessionSubscription']; + /** Cancels initial attachment, including transcript hydration and recovery. */ + signal?: AbortSignal; sessionId: string; now: () => number; onTurnStarted: (turn: MakaPreparedSessionTurn) => void; @@ -120,6 +124,8 @@ export class RuntimeHostSessionChannel { #activated = false; #startedTurnBarrier: string | undefined; #closing = false; + readonly #closeController = new AbortController(); + #closeTask: Promise | undefined; #failure: Error | undefined; #recoveryTask: Promise | undefined; #recoveryAttemptsWithoutLiveFrame = 0; @@ -163,18 +169,32 @@ export class RuntimeHostSessionChannel { static async open( options: RuntimeHostSessionChannelOptions, ): Promise { - const subscription = await options.connection.openSessionSubscription({ - sessionId: options.sessionId, - transcript: { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }, - }); + const openInitial = + options.openInitialSessionSubscription ?? + options.connection.openSessionSubscription.bind(options.connection); + const subscription = await runChannelOperation( + () => + openInitial({ + sessionId: options.sessionId, + transcript: { + kind: 'tail', + maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + }, + }), + options.signal, + (lateSubscription) => lateSubscription.close(), + ); const initialRoot = structuredClone(subscription.snapshot.rootTurn); const channel = new RuntimeHostSessionChannel(subscription, [], options, options.connection); + const onAbort = () => { + void channel.close().catch(() => undefined); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); void channel.#pump(subscription); try { const recovered = await channel.#hydrateInitial(subscription); + options.signal?.throwIfAborted(); const root = recovered ? structuredClone(channel.snapshot.rootTurn) : initialRoot; return { channel, @@ -185,13 +205,19 @@ export class RuntimeHostSessionChannel { } catch (error) { await channel.close().catch(() => undefined); throw error; + } finally { + options.signal?.removeEventListener('abort', onAbort); } } async #hydrateInitial(subscription: RuntimeHostSessionSubscription): Promise { let messages: StoredMessage[] | undefined; try { - messages = await subscription.loadTranscript(decodeStoredMessage); + messages = await runChannelOperation( + () => subscription.loadTranscript(decodeStoredMessage), + this.#closeController.signal, + ); + this.#closeController.signal.throwIfAborted(); } catch (error) { if (!this.#canRecover(error)) throw error; this.#failedSubscriptions.add(subscription); @@ -315,9 +341,14 @@ export class RuntimeHostSessionChannel { } } - async close(): Promise { - if (this.#closing) return; + close(): Promise { + this.#closeTask ??= this.#close(); + return this.#closeTask; + } + + async #close(): Promise { this.#closing = true; + this.#closeController.abort(new Error('Runtime Host Session channel is closed')); this.#clearRecoveryStableTimer(); this.#recoveryAwaitingLiveFrame = undefined; this.#pendingStartedTurns.clear(); @@ -406,13 +437,18 @@ export class RuntimeHostSessionChannel { if (this.#closing || this.#failure || this.#subscription !== previous) return; let replacement: RuntimeHostSessionSubscription; try { - replacement = await this.#connection.openSessionSubscription({ - sessionId: this.sessionId, - transcript: { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }, - }); + replacement = await runChannelOperation( + () => + this.#connection.openSessionSubscription({ + sessionId: this.sessionId, + transcript: { + kind: 'tail', + maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + }, + }), + this.#closeController.signal, + (lateSubscription) => lateSubscription.close(), + ); } catch (error) { if (this.#canRecover(error)) continue; throw error; @@ -427,7 +463,10 @@ export class RuntimeHostSessionChannel { this.#pendingFrames.length = 0; void this.#pump(replacement); try { - const messages = await replacement.loadTranscript(decodeStoredMessage); + const messages = await runChannelOperation( + () => replacement.loadTranscript(decodeStoredMessage), + this.#closeController.signal, + ); if (this.#failedSubscriptions.has(replacement)) { throw new RuntimeHostSubscriptionError( 'connection_closed', @@ -850,6 +889,45 @@ function isTurnTerminalOutcome(event: SessionEvent): boolean { return event.type === 'complete' || event.type === 'abort' || event.type === 'error'; } +function runChannelOperation( + operation: () => Promise, + signal?: AbortSignal, + discard?: (value: T) => Promise, +): Promise { + if (!signal) return operation(); + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + let aborted = false; + const onAbort = () => { + aborted = true; + signal.removeEventListener('abort', onAbort); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + let running: Promise; + try { + running = operation(); + } catch (error) { + signal.removeEventListener('abort', onAbort); + reject(error); + return; + } + void running.then( + (value) => { + signal.removeEventListener('abort', onAbort); + // The connection may finish opening after this channel has gone away. + // A late subscription still belongs to the operation and must be closed. + if (aborted) void discard?.(value).catch(() => undefined); + else resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + /** * Goal identity + revision: GoalManager.commit bumps the revision on every * accepted transition, so this pair detects every set/settle/pause/resume/ diff --git a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts index 7aed4c5582..337cd01a71 100644 --- a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts +++ b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts @@ -264,6 +264,47 @@ test('a Session observation reopens safely after its first connection starts dra await connection.close(); }); +test('an initial Session observation stays pinned to the concrete connection it started on', async () => { + const first = connectionHarness( + 'first', + () => undefined, + async () => { + first.disconnect(); + throw new RuntimeHostOperationError( + 'subscription.open', + 'host_draining', + 'Runtime Host is draining', + ); + }, + ); + const replacement = connectionHarness( + 'replacement', + () => undefined, + async () => ({ subscriptionId: 'replacement-subscription' }), + ); + const reconnected = deferred(); + const connection = await createRuntimeHostReconnectingConnection({ + initialConnection: first.connection, + connect: async () => { + reconnected.resolve(); + return replacement.connection; + }, + }); + + await assert.rejects( + connection.openSessionSubscriptionOnce({ + sessionId: 'session-1', + transcript: { kind: 'none' }, + }), + (error: unknown) => + error instanceof RuntimeHostOperationError && error.code === 'host_draining', + ); + await reconnected.promise; + assert.equal(first.openedSubscriptions, 1); + assert.equal(replacement.openedSubscriptions, 0); + await connection.close(); +}); + test('a reconnecting Client rejects a different Host composition permanently', async () => { const first = connectionHarness('first', () => undefined); const replacement = connectionHarness('replacement', () => undefined, undefined, { diff --git a/packages/runtime-host/src/client/reconnecting-connection.ts b/packages/runtime-host/src/client/reconnecting-connection.ts index 46f1aa14cb..27e579d13e 100644 --- a/packages/runtime-host/src/client/reconnecting-connection.ts +++ b/packages/runtime-host/src/client/reconnecting-connection.ts @@ -46,6 +46,10 @@ import type { RuntimeHostSessionSubscription } from './session-subscription.js'; export interface RuntimeHostReconnectingConnection extends RuntimeHostConnection { readonly reconnecting: true; + openSessionSubscriptionOnce( + input: SubscriptionOpenInput, + timeoutMs?: number, + ): Promise; subscribeConnectionAvailability( listener: (availability: RuntimeHostConnectionAvailability) => void, ): () => void; @@ -212,6 +216,13 @@ class RuntimeHostReconnectingConnectionImpl implements RuntimeHostReconnectingCo } } + openSessionSubscriptionOnce( + input: SubscriptionOpenInput, + timeoutMs?: number, + ): Promise { + return this.#requireCurrent('subscription.open').openSessionSubscription(input, timeoutMs); + } + async replaceClientCapabilities( provider: ClientCapabilityProvider, timeoutMs?: number,