diff --git a/packages/cli/src/__tests__/acp-agent.test.ts b/packages/cli/src/__tests__/acp-agent.test.ts index ae5c53f293..2cee82858c 100644 --- a/packages/cli/src/__tests__/acp-agent.test.ts +++ b/packages/cli/src/__tests__/acp-agent.test.ts @@ -29,7 +29,10 @@ describe('Maka ACP agent', () => { async (agent) => { assert.deepEqual(await agent.request(methods.agent.initialize, { protocolVersion: 1 }), { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }); @@ -70,6 +73,92 @@ describe('Maka ACP agent', () => { assert.deepEqual(lists, [{ cwd: '/workspace' }]); }); + test('routes the concrete Turn resume extension through the SDK', async () => { + await client({ name: 'test-client' }).connectWith( + createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), + async (agent) => { + assert.deepEqual( + await agent.request('_maka/turn/resume', { + sessionId: 'session-1', + }), + { + kind: 'parked', + plan: { + sessionId: 'session-1', + disposition: 'parked', + reason: 'resume_candidate_missing', + }, + }, + ); + await assert.rejects( + agent.request('_maka/turn/resume', { sessionId: '' }), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + }, + ); + }); + + test('routes bounded copy-source queries and rejects invalid input through the SDK', async () => { + await client({ name: 'test-client' }).connectWith( + createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), + async (agent) => { + const query = { + sessionId: 'session-1', + throughSequence: null, + position: 0, + maxContributions: 1, + }; + assert.deepEqual(await agent.request('_maka/session/copy-source/query', query), { + sessionId: 'session-1', + expectedSourceRevision: 1, + throughSequence: 8, + contributions: [], + nextPosition: null, + }); + for (const invalid of [ + { ...query, sessionId: '' }, + { ...query, maxContributions: 129 }, + { ...query, position: -1 }, + ]) { + await assert.rejects( + agent.request('_maka/session/copy-source/query', invalid), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + } + }, + ); + }); + + test('routes branch, revision, and abandon extensions through the SDK', async () => { + await client({ name: 'test-client' }).connectWith( + createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), + async (agent) => { + const copy = { + sourceSessionId: 'session-1', + targetSessionId: 'session-2', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + }; + assert.deepEqual(await agent.request('_maka/session/branch/create', copy), { + kind: 'source_revision_conflict', + expectedRevision: 1, + actualRevision: 2, + }); + assert.deepEqual(await agent.request('_maka/session/revision/create', copy), { + kind: 'source_revision_conflict', + expectedRevision: 1, + actualRevision: 2, + }); + assert.deepEqual( + await agent.request('_maka/session/revision/abandon', { + targetSessionId: 'session-1', + }), + { kind: 'retained', sessionId: 'session-1' }, + ); + }, + ); + }); + test('routes official SDK set-config requests through the Session registry', async () => { const configurationRequests: unknown[] = []; await client({ name: 'test-client' }).connectWith( @@ -201,6 +290,34 @@ function fakeSessionRegistry( observations.creates?.push(params); return { sessionId: 'session-1' }; }, + load: async () => ({}), + resume: async () => ({}), + resumeTurn: async () => ({ + kind: 'parked' as const, + plan: { + sessionId: 'session-1', + disposition: 'parked' as const, + reason: 'resume_candidate_missing' as const, + }, + }), + queryCopySource: async () => ({ + sessionId: 'session-1', + expectedSourceRevision: 1, + throughSequence: 8, + contributions: [], + nextPosition: null, + }), + branch: async () => ({ + kind: 'source_revision_conflict' as const, + expectedRevision: 1, + actualRevision: 2, + }), + createRevision: async () => ({ + kind: 'source_revision_conflict' as const, + expectedRevision: 1, + actualRevision: 2, + }), + abandonRevision: async () => ({ kind: 'retained' as const, sessionId: 'session-1' }), list: async (params: unknown) => { observations.lists?.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 93cab58036..4bf1168c60 100644 --- a/packages/cli/src/__tests__/acp-child-process-harness.ts +++ b/packages/cli/src/__tests__/acp-child-process-harness.ts @@ -45,6 +45,11 @@ const DEFAULT_TIMEOUT_MS = 15_000; export interface AcpChildProcessHarnessOptions { readonly timeoutMs?: number; readonly startRuntimeHost?: boolean; + readonly safeBoundaryResume?: boolean; + readonly beforeHostStart?: (input: { + workspaceRoot: string; + modelConnectionId?: string; + }) => Promise; readonly model?: { readonly id: string; readonly thinkingLevels: readonly ThinkingLevel[]; @@ -78,6 +83,8 @@ export class AcpChildProcessHarness { readonly #exit: Promise; readonly #spawn: Promise; readonly #timeoutMs: number; + readonly #env: NodeJS.ProcessEnv; + readonly #ownsResources: boolean; #connection: ClientConnection | undefined; #clientOpened = false; #stdinClosed = false; @@ -90,6 +97,8 @@ export class AcpChildProcessHarness { host?: RuntimeHostKernel; stdoutTap: PassThrough; timeoutMs: number; + env: NodeJS.ProcessEnv; + ownsResources?: boolean; }) { this.#root = input.root; this.#workspaceRoot = input.workspaceRoot; @@ -97,6 +106,8 @@ export class AcpChildProcessHarness { this.#host = input.host; this.#stdout = new StdoutCaptureBridge(input.stdoutTap); this.#timeoutMs = input.timeoutMs; + this.#env = input.env; + this.#ownsResources = input.ownsResources ?? true; this.#child.stderr.on('data', (chunk: Buffer) => this.#stderr.push(Buffer.from(chunk))); this.#spawn = waitForChildSpawn(this.#child); this.#exit = new Promise((resolve, reject) => { @@ -193,6 +204,28 @@ export class AcpChildProcessHarness { } } + /** Spawn another ACP process against this harness's existing Runtime Host root. */ + async spawnSibling(): Promise { + const child = spawn( + process.execPath, + [fileURLToPath(new URL('../dev-cli.js', import.meta.url)), '--acp'], + { cwd: this.#workspaceRoot, env: this.#env, stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const stdoutTap = new PassThrough(); + pipeCapturedStdout(child.stdout, stdoutTap); + const sibling = new AcpChildProcessHarness({ + root: this.#root, + workspaceRoot: this.#workspaceRoot, + child, + stdoutTap, + timeoutMs: this.#timeoutMs, + env: this.#env, + ownsResources: false, + }); + await sibling.waitForSpawn(); + return sibling; + } + close(): Promise { this.#closePromise ??= this.closeOnce(); return this.#closePromise; @@ -203,7 +236,7 @@ export class AcpChildProcessHarness { for (const cleanup of [ () => this.closeConnection(), () => this.stopChild(), - () => this.#host?.close(), + ...(this.#ownsResources ? [() => this.#host?.close()] : []), ]) { try { await cleanup(); @@ -211,7 +244,7 @@ export class AcpChildProcessHarness { failure ??= error; } } - await rm(this.#root, { recursive: true, force: true }); + if (this.#ownsResources) await rm(this.#root, { recursive: true, force: true }); if (failure !== undefined) throw failure; } @@ -297,10 +330,15 @@ export async function startAcpChildProcessHarness( let rootCleanupFollowsHostStartup = false; try { await mkdir(workspaceRoot, { recursive: true }); - if (options.model) await seedModelConnection(workspaceRoot, options.model); + const modelConnectionId = options.model + ? await seedModelConnection(workspaceRoot, options.model) + : undefined; + await options.beforeHostStart?.({ workspaceRoot, modelConnectionId }); if (options.startRuntimeHost) { - hostStartup = startExecutionRuntimeHostService({ rootPath: workspaceRoot }); + const previousSafeBoundaryResume = process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; + if (options.safeBoundaryResume) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; try { + hostStartup = startExecutionRuntimeHostService({ rootPath: workspaceRoot }); host = await withStartupTimeout( hostStartup, timeoutMs, @@ -308,7 +346,7 @@ export async function startAcpChildProcessHarness( workspaceRoot, ); } catch (error) { - if (error instanceof StartupTimeoutError) { + if (error instanceof StartupTimeoutError && hostStartup) { rootCleanupFollowsHostStartup = true; void hostStartup .then( @@ -321,6 +359,10 @@ export async function startAcpChildProcessHarness( .catch(() => undefined); } throw error; + } finally { + if (previousSafeBoundaryResume === undefined) + delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; + else process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = previousSafeBoundaryResume; } } const child = spawn( @@ -337,6 +379,7 @@ export async function startAcpChildProcessHarness( ...(host ? { host } : {}), stdoutTap, timeoutMs, + env, }); await harness.waitForSpawn(); return harness; @@ -353,7 +396,7 @@ export async function startAcpChildProcessHarness( async function seedModelConnection( rootPath: string, model: NonNullable, -): Promise { +): Promise { const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); if (!owner) throw new Error('Unable to acquire ACP model fixture root'); @@ -396,6 +439,7 @@ async function seedModelConnection( target: { connectionId: connection.connectionId, modelId: model.id }, }); if (defaulted.kind !== 'committed') throw new Error('ACP model fixture was not selected'); + return connection.connectionId; } finally { await owner.close(); } diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 616ba06cc0..7016d31ecc 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -18,21 +18,34 @@ */ import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; 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 { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; -import { methods, type SessionNotification } from '@agentclientprotocol/sdk'; +import { methods, RequestError, type SessionNotification } from '@agentclientprotocol/sdk'; import { waitFor } from '@maka/core/test-only/async-primitives'; +import { mcpProxyToolName } from '@maka/runtime/mcp-tools'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; +import { + buildRecoveredTerminalRuntimeEvent, + commitTerminalRunWithRuntimeFact, +} from '@maka/runtime/terminal-run-commit'; import { connectRuntimeHost } from '@maka/runtime-host/client'; import { + type SessionTurnsQueryResult, ARTIFACT_INGEST_CHUNK_MAX_BYTES, RUNTIME_HOST_PROTOCOL_VERSION, + SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, } from '@maka/runtime-host/protocol'; import { getRuntimeHostSession } from '../runtime-host-session-update.js'; +import { AcpSessionMcp, createAcpMcpConfig } from '../acp/session-mcp.js'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { pipeCapturedStdout, StdoutCaptureBridge, @@ -129,7 +142,10 @@ 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: {}, close: {} } }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }); @@ -348,50 +364,78 @@ describe('Maka ACP child process', () => { try { await withAcpChildProcessHarness( async (harness) => { - const path = join(harness.workspaceRoot, 'notes.txt'); + const path = join(harness.workspaceRoot, 'COMPLETE_ME-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', + const history: SessionNotification[] = []; + 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: '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(); + } + history.length = 0; + await context.request(methods.agent.session.load, { sessionId, + cwd: harness.workspaceRoot, + mcpServers: [], }); - 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 }); - }); + const users = history.filter( + ({ update }) => update.sessionUpdate === 'user_message_chunk', + ); + assert.equal(users.length, 1); + const row = users[0]!.update; + if (row.sessionUpdate !== 'user_message_chunk' || row.content.type !== 'text') + assert.fail('Expected a visible attachment-only user message'); + assert.equal( + row.content.text, + `[Attachment: notes.txt (text/plain, ${contents.length} bytes)]`, + ); + assert.equal((row._meta?.['_maka/attachments'] as unknown[])?.length, 1); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => { + return app.onNotification(methods.client.session.update, ({ params }) => { + history.push(params); + }); + }, + ); }, { startRuntimeHost: true, @@ -591,17 +635,784 @@ describe('Maka ACP child process', () => { await model.close(); } }); + + test('loads a durable Session from a second ACP process and continues prompting', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + try { + await withAcpChildProcessHarness( + async (harness) => { + const mcpFixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); + const sessionId = await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'previous-provider', + command: process.execPath, + args: [mcpFixture], + env: [], + }, + ], + }); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }), + { stopReason: 'end_turn' }, + ); + return created.sessionId; + }); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + const sibling = await harness.spawnSibling(); + try { + const updates: SessionNotification[] = []; + await sibling.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const loaded = await context.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'next-provider', + command: process.execPath, + args: [mcpFixture], + env: [], + }, + ], + }); + assert.ok((loaded.configOptions ?? []).length > 0); + assert.ok( + updates.some( + ({ update }) => + update.sessionUpdate === 'user_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('COMPLETE_ME'), + ), + ); + assert.ok( + updates.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('ACP fixture completed'), + ), + ); + const beforeResume = updates.length; + const resumed = await context.request(methods.agent.session.resume, { + sessionId, + cwd: harness.workspaceRoot, + }); + assert.ok((resumed.configOptions ?? []).length > 0); + assert.equal(updates.length, beforeResume); + const parked = (await context.request('_maka/turn/resume', { sessionId })) as { + kind: string; + plan?: { disposition: string }; + }; + assert.equal(parked.kind, 'parked'); + assert.equal(parked.plan?.disposition, 'parked'); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }), + { stopReason: 'end_turn' }, + ); + // All copy parameters come from ACP, including historical Turn IDs + // for plain-text prompts with no tool metadata or private Host access. + let position = 0; + let throughSequence: number | null = null; + let expectedSourceRevision = 0; + const sourceTurns: string[] = []; + do { + const page = (await context.request('_maka/session/copy-source/query', { + sessionId, + throughSequence, + position, + maxContributions: 1, + })) as SessionTurnsQueryResult & { expectedSourceRevision: number }; + assert.equal(page.sessionId, sessionId); + assert.equal(page.contributions.length, 1); + expectedSourceRevision = page.expectedSourceRevision; + throughSequence = page.throughSequence; + for (const contribution of page.contributions) { + if (contribution.latestState?.message.status === 'completed') + sourceTurns.push(contribution.turnId); + } + if (page.nextPosition === null) break; + assert.ok(page.nextPosition > position); + position = page.nextPosition; + } while (true); + assert.ok(sourceTurns.length >= 2); + const sourceTurnId = sourceTurns[0]!; + await context.request(methods.agent.session.setConfigOption, { + sessionId, + configId: 'permission_mode', + value: + loaded.configOptions?.find((option) => option.id === 'permission_mode') + ?.currentValue === 'bypass' + ? 'ask' + : 'bypass', + }); + const staleCopy = (await context.request('_maka/session/branch/create', { + sourceSessionId: sessionId, + targetSessionId: randomUUID(), + sourceTurnId, + expectedSourceRevision, + })) as { kind: string }; + assert.equal(staleCopy.kind, 'source_revision_conflict'); + const refreshed = (await context.request('_maka/session/copy-source/query', { + sessionId, + throughSequence: null, + position: 0, + maxContributions: 1, + })) as SessionTurnsQueryResult & { expectedSourceRevision: number }; + assert.ok(refreshed.expectedSourceRevision > expectedSourceRevision); + expectedSourceRevision = refreshed.expectedSourceRevision; + const targetSessionId = randomUUID(); + const branched = (await context.request('_maka/session/branch/create', { + sourceSessionId: sessionId, + targetSessionId, + sourceTurnId, + expectedSourceRevision, + })) as { kind: string }; + assert.equal(branched.kind, 'committed'); + const revisionTargetId = randomUUID(); + const revision = (await context.request('_maka/session/revision/create', { + sourceSessionId: sessionId, + targetSessionId: revisionTargetId, + sourceTurnId, + expectedSourceRevision, + })) as { kind: string }; + assert.equal(revision.kind, 'committed'); + const unusedRevisionTargetId = randomUUID(); + const unusedRevision = (await context.request('_maka/session/revision/create', { + sourceSessionId: sessionId, + targetSessionId: unusedRevisionTargetId, + sourceTurnId, + expectedSourceRevision, + })) as { kind: string }; + assert.equal(unusedRevision.kind, 'committed'); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: revisionTargetId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }), + { stopReason: 'end_turn' }, + ); + assert.deepEqual( + await context.request('_maka/session/revision/abandon', { + targetSessionId: revisionTargetId, + }), + { kind: 'retained', sessionId: revisionTargetId }, + ); + assert.deepEqual( + await context.request('_maka/session/revision/abandon', { + targetSessionId: unusedRevisionTargetId, + }), + { kind: 'abandoned', sessionId: unusedRevisionTargetId }, + ); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId: targetSessionId, + prompt: [{ type: 'text', text: 'COMPLETE_ME' }], + }), + { stopReason: 'end_turn' }, + ); + await context.request(methods.agent.session.close, { sessionId: targetSessionId }); + await context.request(methods.agent.session.close, { sessionId: revisionTargetId }); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app.onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }), + ); + } finally { + await sibling.close(); + } + }, + { + startRuntimeHost: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + timeoutMs: 35_000, + }, + ); + } finally { + await model.close(); + } + }); + + test('loads an already-live Turn and hands replay to later output without duplication', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + try { + await withAcpChildProcessHarness( + async (harness) => { + const firstUpdates: 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: [], + }); + const prompt = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'LIVE_ME' }], + }); + await model.liveStarted; + await waitFor( + () => + firstUpdates.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('before'), + ), + { timeoutMs: 5_000, pollMs: 10, message: 'first live ACP chunk' }, + ); + const sibling = await harness.spawnSibling(); + try { + const restored: SessionNotification[] = []; + await sibling.withClient( + async ({ context: second }) => { + await second.request(methods.agent.initialize, { protocolVersion: 1 }); + await second.request(methods.agent.session.load, { + sessionId: created.sessionId, + cwd: harness.workspaceRoot, + mcpServers: [], + }); + assert.ok( + restored.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('before'), + ), + ); + model.releaseLive(); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + await waitFor( + () => + restored.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('after'), + ), + { timeoutMs: 5_000, pollMs: 10, message: 'restored live ACP chunk' }, + ); + assert.equal( + restored + .flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' + ? [update.content.text] + : [], + ) + .join(''), + 'before after', + ); + await second.request(methods.agent.session.close, { + sessionId: created.sessionId, + }); + }, + (app) => + app.onNotification(methods.client.session.update, ({ params }) => { + restored.push(params); + }), + ); + } finally { + model.releaseLive(); + await sibling.close(); + await prompt.catch(() => undefined); + } + }, + (app) => + app.onNotification(methods.client.session.update, ({ params }) => { + firstUpdates.push(params); + }), + ); + }, + { + startRuntimeHost: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + timeoutMs: 35_000, + }, + ); + } finally { + model.releaseLive(); + await model.close(); + } + }); + + test('a second Host client admitting a Turn after the idle read blocks MCP replacement', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + const mcpFixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + 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'); + const host = connected.connection; + const first = createAcpMcpConfig({ + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [mcpFixture], env: [] }, + ], + }); + const changed = createAcpMcpConfig({ + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'fixture', + command: process.execPath, + args: [mcpFixture], + env: [{ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'changed' }], + }, + ], + }); + const mcp = new AcpSessionMcp(created.sessionId, first, { + replaceClientCapabilities: host.replaceClientCapabilities.bind(host), + unregisterClientCapabilities: host.unregisterClientCapabilities.bind(host), + subscribeConnectionAvailability: (listener) => { + listener({ + kind: 'connected', + hostEpoch: host.hostEpoch, + connectionId: host.connectionId, + }); + return () => undefined; + }, + }); + let prompt: Promise | undefined; + try { + await mcp.prepare(); + await assert.rejects( + mcp.reconfigure(changed, undefined, async () => { + const idle = await getRuntimeHostSession(host, created.sessionId); + assert.ok( + idle && idle.status !== 'running' && idle.status !== 'waiting_for_user', + ); + prompt = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'LIVE_ME' }], + }); + await model.liveStarted; + }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string })?.code === 'session_busy', + ); + assert.deepEqual(mcp.config, first); + await mcp.ready(); + model.releaseLive(); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + } finally { + model.releaseLive(); + await Promise.allSettled([mcp.close(), prompt]); + await host.close(); + await context.request(methods.agent.session.close, { sessionId: created.sessionId }); + } + }); + }, + { + startRuntimeHost: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + timeoutMs: 35_000, + }, + ); + } finally { + model.releaseLive(); + await model.close(); + } + }); + + test('a committed MCP replacement survives cancellation after another Host client starts a Turn', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + const mcpFixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); + let markCommitted!: () => void; + const committed = new Promise((resolve) => { + markCommitted = resolve; + }); + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + 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'); + const host = connected.connection; + const first = createAcpMcpConfig({ + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [mcpFixture], env: [] }, + ], + }); + const changed = createAcpMcpConfig({ + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'fixture', + command: process.execPath, + args: [mcpFixture], + env: [{ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'changed' }], + }, + ], + }); + const publications: Array<{ requireIdleSession?: boolean }> = []; + const mcp = new AcpSessionMcp(created.sessionId, first, { + replaceClientCapabilities: async (provider, options) => { + publications.push(typeof options === 'object' ? options : {}); + const result = await host.replaceClientCapabilities(provider, options); + if (typeof options === 'object' && options.requireIdleSession) { + markCommitted(); + await responseGate; + } + return result; + }, + unregisterClientCapabilities: host.unregisterClientCapabilities.bind(host), + subscribeConnectionAvailability: (listener) => { + listener({ + kind: 'connected', + hostEpoch: host.hostEpoch, + connectionId: host.connectionId, + }); + return () => undefined; + }, + }); + const controller = new AbortController(); + let prompt: Promise | undefined; + try { + await mcp.prepare(); + const replacing = mcp.reconfigure(changed, controller.signal); + await committed; + prompt = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'LIVE_ME' }], + }); + await model.liveStarted; + const running = await getRuntimeHostSession(host, created.sessionId); + assert.ok(running?.status === 'running' || running?.status === 'waiting_for_user'); + controller.abort(); + releaseResponse(); + await assert.rejects(replacing, RequestError); + assert.deepEqual(mcp.config, changed); + assert.ok(publications.slice(1).every((options) => options.requireIdleSession)); + await mcp.ready(); + model.releaseLive(); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + } finally { + releaseResponse(); + model.releaseLive(); + await Promise.allSettled([mcp.close(), prompt]); + await host.close(); + await context.request(methods.agent.session.close, { sessionId: created.sessionId }); + } + }); + }, + { + startRuntimeHost: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + timeoutMs: 35_000, + }, + ); + } finally { + releaseResponse(); + model.releaseLive(); + await model.close(); + } + }); + + test('explicitly resumes a ready interrupted Turn through ACP and the real Host', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + let sessionId = ''; + try { + await withAcpChildProcessHarness( + async (harness) => { + const updates: SessionNotification[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + await context.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: [], + }); + const resumed = (await context.request('_maka/turn/resume', { sessionId })) as { + kind: string; + turn?: { turnId: string }; + }; + assert.equal(resumed.kind, 'started', JSON.stringify(resumed)); + assert.ok(resumed.turn?.turnId); + await waitFor( + () => + updates.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text.includes('ACP fixture session'), + ), + { timeoutMs: 5_000, pollMs: 10, message: 'continued ACP output' }, + ); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app.onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }), + ); + }, + { + startRuntimeHost: true, + safeBoundaryResume: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + beforeHostStart: async ({ workspaceRoot, modelConnectionId }) => { + assert.ok(modelConnectionId); + sessionId = await seedReadyAcpContinuation(workspaceRoot, modelConnectionId); + }, + timeoutMs: 35_000, + }, + ); + } finally { + await model.close(); + } + }); + + test('keeps an interrupted Turn parked until the required MCP contract is restored', { + timeout: 45_000, + }, async () => { + const model = await startAcpModelFixture(); + const mcpFixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); + let sessionId = ''; + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + await context.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'wrong', command: process.execPath, args: [mcpFixture], env: [] }, + ], + }); + const incompatible = (await context.request('_maka/turn/resume', { sessionId })) as { + kind: string; + plan?: { disposition: string; reason?: string }; + }; + assert.equal(incompatible.kind, 'parked'); + assert.equal(incompatible.plan?.disposition, 'parked'); + assert.equal(incompatible.plan.reason, 'safety_check_failed'); + await context.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [mcpFixture], env: [] }, + ], + }); + const compatible = (await context.request('_maka/turn/resume', { sessionId })) as { + kind: string; + turn?: { turnId: string }; + }; + assert.equal(compatible.kind, 'started', JSON.stringify(compatible)); + assert.ok(compatible.turn?.turnId); + await context.request(methods.agent.session.close, { sessionId }); + }); + }, + { + startRuntimeHost: true, + safeBoundaryResume: true, + model: { id: 'acp-stream-fixture', thinkingLevels: ['low'], baseUrl: model.baseUrl }, + beforeHostStart: async ({ workspaceRoot, modelConnectionId }) => { + assert.ok(modelConnectionId); + sessionId = await seedReadyAcpContinuation( + workspaceRoot, + modelConnectionId, + mcpProxyToolName('fixture', 'echo'), + ); + }, + timeoutMs: 35_000, + }, + ); + } finally { + await model.close(); + } + }); }); +async function seedReadyAcpContinuation( + workspaceRoot: string, + modelConnectionId: string, + requiredToolName?: string, +): Promise { + const cwd = await realpath(workspaceRoot); + const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + const session = await stores.sessionStore.create({ + cwd, + llmConnectionId: modelConnectionId, + llmConnectionSlug: 'acp-fixture-model', + model: 'acp-stream-fixture', + permissionMode: 'ask', + }); + const invocationId = randomUUID(); + const runId = randomUUID(); + const turnId = randomUUID(); + const openedAt = Date.now(); + const workspace = await resolveWorkspaceIdentity({ path: cwd }); + const run = await seedInvocation(stores.runtimeEventStore, { + sessionId: session.id, + invocationId, + runId, + turnId, + openedAt, + opening: { + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: modelConnectionId, + llmConnectionSlug: 'acp-fixture-model', + modelId: 'acp-stream-fixture', + }, + configuration: { + cwd, + workspaceIdentity: workspace.workspaceIdentity, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, + }); + await stores.runtimeEventStore.appendRuntimeEvent(session.id, runId, { + id: randomUUID(), + sessionId: session.id, + invocationId, + runId, + turnId, + ts: openedAt, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Continue this interrupted request.' }, + }); + if (requiredToolName) { + const toolCallId = randomUUID(); + await stores.runtimeEventStore.appendRuntimeEvent(session.id, runId, { + id: randomUUID(), + sessionId: session.id, + invocationId, + runId, + turnId, + ts: openedAt + 1, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: toolCallId, name: requiredToolName, args: {} }, + refs: { toolCallId }, + }); + await stores.runtimeEventStore.appendRuntimeEvent(session.id, runId, { + id: randomUUID(), + sessionId: session.id, + invocationId, + runId, + turnId, + ts: openedAt + 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: toolCallId, + name: requiredToolName, + result: { kind: 'json', value: { ok: true } }, + isError: false, + }, + refs: { toolCallId }, + }); + } + const terminalAt = openedAt + (requiredToolName ? 3 : 1); + await commitTerminalRunWithRuntimeFact({ + runtimeEventStore: stores.runtimeEventStore, + newId: randomUUID, + sessionId: session.id, + runId, + turnId, + status: 'failed', + ts: terminalAt, + terminalEvent: buildRecoveredTerminalRuntimeEvent({ + id: randomUUID(), + run, + status: 'failed', + ts: terminalAt, + failureClass: 'app_restarted', + recoveryReason: 'test_safe_boundary_source', + }), + failureClass: 'app_restarted', + }); + return session.id; + } finally { + await stores.sessionStore.close?.(); + await owner.close(); + } +} + async function startAcpModelFixture(): Promise<{ readonly baseUrl: string; readonly cancelStarted: Promise; + readonly liveStarted: Promise; + releaseLive(): void; close(): Promise; }> { let markCancelStarted!: () => void; const cancelStarted = new Promise((resolve) => { markCancelStarted = resolve; }); + let markLiveStarted!: () => void; + const liveStarted = new Promise((resolve) => { + markLiveStarted = resolve; + }); + let releaseLive!: () => void; + const liveGate = new Promise((resolve) => { + releaseLive = resolve; + }); const server = createServer((request, response) => { void readBody(request) .then((body) => { @@ -612,6 +1423,17 @@ async function startAcpModelFixture(): Promise<{ request.once('close', () => response.end()); return; } + if (body.includes('LIVE_ME')) { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write(`data: ${JSON.stringify(modelChunk('before ', null))}\n\n`); + markLiveStarted(); + void liveGate.then(() => { + response.write(`data: ${JSON.stringify(modelChunk('after', null))}\n\n`); + response.write(`data: ${JSON.stringify(modelChunk('', 'stop'))}\n\n`); + response.end('data: [DONE]\n\n'); + }); + return; + } if (body.includes('COMPLETE_ME')) { respondModelText(response, 'ACP fixture completed.'); return; @@ -629,6 +1451,8 @@ async function startAcpModelFixture(): Promise<{ return { baseUrl: `http://127.0.0.1:${address.port}/v1`, cancelStarted, + liveStarted, + releaseLive, close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), diff --git a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts index 380d08edc4..846f732001 100644 --- a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -25,6 +25,85 @@ import type { InteractionPendingSnapshot } from '@maka/runtime-host/protocol'; import { AcpSessionEventMapper } from '../acp/session-event-mapper.js'; describe('ACP Session event mapper', () => { + for (const text of ['', 'Read this report']) { + test(`replays attachments with user text ${JSON.stringify(text)}`, async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + const attachments = [ + { + kind: 'other' as const, + name: 'report.txt', + mimeType: 'text/plain', + bytes: 12, + ref: { + kind: 'session_file' as const, + sessionId: 'session-1', + relativePath: 'artifacts/report.txt', + }, + }, + ]; + await mapper.acceptHistoricalMessage({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: `${text} file:///workspace/report.txt`, + displayText: text, + attachments, + }); + await mapper.flush(); + assert.deepEqual( + notifications.map(({ update }) => update), + [ + { + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { + type: 'text', + text: [text, '[Attachment: report.txt (text/plain, 12 bytes)]'] + .filter(Boolean) + .join('\n\n'), + }, + _meta: { '_maka/attachments': attachments }, + }, + ], + ); + }); + } + + test('replay preserves non-user origin and omits unsupported steering rows', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + await mapper.acceptHistoricalMessage({ + type: 'user', + id: 'scheduled', + turnId: 'turn-1', + ts: 1, + text: 'Run the report', + origin: { kind: 'scheduled_task', scheduledTaskId: 'schedule-1' }, + }); + await mapper.acceptHistoricalMessage({ + type: 'user', + id: 'steering', + turnId: 'turn-1', + ts: 2, + text: 'Continue', + steeringEventId: 'steering-event', + }); + await mapper.flush(); + assert.deepEqual( + notifications.map(({ update }) => update), + [ + { + sessionUpdate: 'user_message_chunk', + messageId: 'scheduled', + content: { type: 'text', text: 'Run the report' }, + _meta: { '_maka/origin': { kind: 'scheduled_task', scheduledTaskId: 'schedule-1' } }, + }, + ], + ); + }); + test('streams text and thinking while deduplicating matching completion events', async () => { const notifications: SessionNotification[] = []; const mapper = eventMapper(notifications); diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 8072ba00ec..aa5b182e55 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -18,6 +18,8 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { stableJsonStringify } from '@maka/core/canonical-json'; import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -74,6 +76,423 @@ test('ACP stdio configuration copies cwd, arguments and explicit environment int assert.equal(normalized.env?.MAKA_MCP_STDIO_EVENT_LOG, '/workspace/fixture.jsonl'); }); +test('Session MCP reconfiguration reuses equivalent processes and applies replacement and empty scope', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const first = createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }); + const mcp = new AcpSessionMcp(sessionId, first, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const starts = (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start'); + assert.ok(starts.length > 0); + await mcp.reconfigure( + createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'fixture')], + }), + ); + assert.equal( + (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start').length, + starts.length, + ); + const replacement = stdioServer(root, 'fixture'); + replacement.env.push({ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'replacement' }); + await mcp.reconfigure(createAcpMcpConfig({ cwd: root, mcpServers: [replacement] })); + assert.ok( + (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start').length > + starts.length, + ); + await mcp.reconfigure(createAcpMcpConfig({ cwd: root, mcpServers: [] })); + assert.deepEqual(host.replacements.at(-1)?.provider.offers(), []); + assert.deepEqual(host.replacements.at(-1)?.options, { + sessionId, + requireIdleSession: true, + sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(mcp.config)).digest('hex')}`, + }); + await assertFixtureExited(root, 'fixture'); +}); + +test('reconfiguring one Session keeps another Session MCP provider callable', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const otherSessionId = 'other-session'; + const first = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'first', '--environment')] }), + host.connection, + ); + const other = new AcpSessionMcp( + otherSessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'other', '--environment')] }), + host.connection, + ); + t.after(async () => { + await Promise.allSettled([first.close(), other.close()]); + await rm(root, { recursive: true, force: true }); + }); + await Promise.all([first.prepare(), other.prepare()]); + const otherProvider = host.replacements.find( + (entry) => typeof entry.options === 'object' && entry.options?.sessionId === otherSessionId, + )?.provider; + assert.ok(otherProvider); + const changed = stdioServer(root, 'first', '--environment'); + changed.env.push({ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'changed' }); + await first.reconfigure(createAcpMcpConfig({ cwd: root, mcpServers: [changed] })); + await first.close(); + assert.deepEqual(await invokeEnvironment(otherProvider, otherSessionId), { + MAKA_MCP_STDIO_FIXTURE_VALUE: null, + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); + await other.close(); + await assertFixtureExited(root, 'first'); + await assertFixtureExited(root, 'other'); +}); + +test('failed Session MCP replacement restores the previous published configuration', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const first = createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }); + const mcp = new AcpSessionMcp(sessionId, first, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + await assert.rejects( + mcp.reconfigure( + createAcpMcpConfig({ + cwd: root, + mcpServers: [ + { name: 'broken', command: '/definitely/missing/mcp-server', args: [], env: [] }, + ], + }), + ), + isMcpError('mcp_not_ready', 'mcp.prepare'), + ); + assert.deepEqual(mcp.config, first); + await mcp.ready(); + assert.ok(host.replacements.at(-1)?.provider.offers().length); +}); + +test('an active Turn conflict leaves the existing MCP process and publication intact', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const first = createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }); + const mcp = new AcpSessionMcp(sessionId, first, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const starts = (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start'); + const publications = host.replacements.length; + const changed = stdioServer(root, 'fixture'); + changed.env.push({ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'replacement' }); + await assert.rejects( + mcp.reconfigure( + createAcpMcpConfig({ cwd: root, mcpServers: [changed] }), + undefined, + async () => { + throw RequestError.internalError({ code: 'session_busy' }, 'Session has an active Turn'); + }, + ), + (error: unknown) => + error instanceof RequestError && (error.data as { code?: string })?.code === 'session_busy', + ); + assert.deepEqual(mcp.config, first); + assert.equal(host.replacements.length, publications); + assert.equal( + (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start').length, + starts.length, + ); + assert.ok(host.replacements[0]!.provider.offers().length > 0); + await mcp.ready(); +}); + +test('a Turn admitted after the idle query cannot lose its previous MCP provider', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const first = createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'fixture', '--environment')], + }); + const mcp = new AcpSessionMcp(sessionId, first, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const previousProvider = host.replacements.at(-1)!.provider; + const idleQueried = deferred(); + const turnAdmitted = deferred(); + const changed = stdioServer(root, 'fixture', '--environment'); + changed.env.push({ name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'replacement' }); + let active = false; + host.replace = async () => { + const options = host.replacements.at(-1)!.options; + if (active && typeof options === 'object' && options?.requireIdleSession) { + throw new RuntimeHostOperationError( + 'client.capability.replace', + 'session_busy', + 'Active Turn', + ); + } + }; + const replacing = mcp.reconfigure( + createAcpMcpConfig({ cwd: root, mcpServers: [changed] }), + undefined, + async () => { + idleQueried.resolve(); + await turnAdmitted.promise; + }, + ); + await idleQueried.promise; + active = true; + turnAdmitted.resolve(); + await assert.rejects(replacing, isMcpError('session_busy', 'mcp.prepare')); + assert.deepEqual(mcp.config, first); + assert.ok(host.replacements.length > 1); + assert.ok( + host.replacements + .slice(1) + .every( + (entry) => typeof entry.options === 'object' && entry.options?.requireIdleSession === true, + ), + ); + assert.deepEqual(await invokeEnvironment(previousProvider), { + MAKA_MCP_STDIO_FIXTURE_VALUE: null, + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); +}); + +test('close during a staged replacement closes both MCP processes', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'original')] }), + host.connection, + ); + const accepted = deferred(); + t.after(async () => { + accepted.resolve(); + await mcp.close(); + for (const name of ['original', 'candidate']) { + for (const event of await fixtureEvents(root, name)) { + if (event.event === 'start' && processExists(event.pid)) process.kill(event.pid, 'SIGKILL'); + } + } + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const before = host.replacements.length; + host.replace = () => accepted.promise; + const outcome = mcp + .reconfigure(createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'candidate')] })) + .catch(() => undefined); + await waitFor(() => host.replacements.length > before, { timeoutMs: 5_000, pollMs: 10 }); + const closing = mcp.close(); + accepted.resolve(); + await Promise.all([closing, outcome]); + await assertFixtureExited(root, 'original'); + await assertFixtureExited(root, 'candidate'); +}); + +test('authoritative retirement during a staged replacement closes both MCP processes', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'original')] }), + host.connection, + ); + const accepted = deferred(); + t.after(async () => { + accepted.resolve(); + await mcp.close(); + for (const name of ['original', 'candidate']) { + for (const event of await fixtureEvents(root, name)) { + if (event.event === 'start' && processExists(event.pid)) process.kill(event.pid, 'SIGKILL'); + } + } + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const oldProvider = host.replacements[0]!.provider; + assert.ok(oldProvider.currentRegistrationRetired); + host.replace = () => accepted.promise; + const replacing = mcp + .reconfigure(createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'candidate')] })) + .catch(() => undefined); + await waitFor(() => host.replacements.length === 2, { timeoutMs: 5_000, pollMs: 10 }); + const retirement = oldProvider.currentRegistrationRetired(); + accepted.resolve(); + await Promise.all([retirement, replacing]); + assert.deepEqual(host.unregisters, []); + await assertFixtureExited(root, 'original'); + await assertFixtureExited(root, 'candidate'); +}); + +test('a cancelled replacement retains a committed provider when another Turn becomes active', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'original', '--environment')] }), + host.connection, + ); + const accepted = deferred(); + const controller = new AbortController(); + const unguarded: unknown[] = []; + let busy = false; + t.after(async () => { + accepted.resolve(); + await mcp.close(); + for (const name of ['original', 'candidate']) { + for (const event of await fixtureEvents(root, name)) { + if (event.event === 'start' && processExists(event.pid)) process.kill(event.pid, 'SIGKILL'); + } + } + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + host.replace = async () => { + const current = host.replacements.at(-1)!; + if (host.replacements.length === 2) { + await accepted.promise; + return; + } + if (busy && typeof current.options === 'object' && current.options?.requireIdleSession) { + throw new RuntimeHostOperationError( + 'client.capability.replace', + 'session_busy', + 'Active Turn', + ); + } + if (busy) unguarded.push(current.options); + }; + const changed = createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'candidate', '--environment')], + }); + const outcome = mcp.reconfigure(changed, controller.signal).catch(() => undefined); + await waitFor(() => host.replacements.length === 2, { timeoutMs: 5_000, pollMs: 10 }); + const candidateProvider = host.replacements[1]!.provider; + busy = true; + controller.abort(); + accepted.resolve(); + await outcome; + assert.deepEqual(unguarded, []); + assert.deepEqual(mcp.config, changed); + assert.deepEqual(await invokeEnvironment(candidateProvider), { + MAKA_MCP_STDIO_FIXTURE_VALUE: null, + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); +}); + +test('an unknown replacement outcome keeps both processes until reconnect confirms the new provider', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const first = createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'original', '--environment')], + }); + const changed = createAcpMcpConfig({ + cwd: root, + mcpServers: [stdioServer(root, 'candidate', '--environment')], + }); + const mcp = new AcpSessionMcp(sessionId, first, host.connection); + t.after(async () => { + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const oldProvider = host.replacements[0]!.provider; + host.replace = async () => { + throw new RuntimeHostRequestInterruptedError( + 'client.capability.replace', + 'command', + 'dispatched', + 'connection_lost', + ); + }; + await assert.rejects(mcp.reconfigure(changed), RequestError); + assert.deepEqual(mcp.config, changed); + assert.deepEqual(await invokeEnvironment(oldProvider), { + MAKA_MCP_STDIO_FIXTURE_VALUE: null, + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); + assert.ok( + (await fixtureEvents(root, 'candidate')).some( + (event) => event.event === 'start' && processExists(event.pid), + ), + ); + host.replace = async () => undefined; + host.emit({ kind: 'connected', hostEpoch: 'host-2', connectionId: 'connection-2' }); + await mcp.ready(); + await assertFixtureExited(root, 'original'); + assert.deepEqual(await invokeEnvironment(host.replacements.at(-1)!.provider), { + MAKA_MCP_STDIO_FIXTURE_VALUE: null, + MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL: null, + }); +}); + +test('Session MCP readiness waits for an in-flight replacement publication', { + timeout: 20_000, +}, async (t) => { + const root = await temporaryRoot(); + const host = fakeHost(); + const mcp = new AcpSessionMcp( + sessionId, + createAcpMcpConfig({ cwd: root, mcpServers: [] }), + host.connection, + ); + const accepted = deferred(); + t.after(async () => { + accepted.resolve(); + await mcp.close(); + await rm(root, { recursive: true, force: true }); + }); + await mcp.prepare(); + const before = host.replacements.length; + host.replace = () => accepted.promise; + const replacing = mcp.reconfigure( + createAcpMcpConfig({ cwd: root, mcpServers: [stdioServer(root, 'fixture')] }), + ); + await waitFor(() => host.replacements.length > before, { timeoutMs: 5_000, pollMs: 10 }); + let ready = false; + const waiting = mcp.ready().then(() => { + ready = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(ready, false); + accepted.resolve(); + await replacing; + await waiting; + assert.equal(ready, true); +}); + test('invalid ACP stdio configurations fail as invalid params before resource preparation', () => { const valid = stdioServer('/workspace', 'fixture'); const badServers: unknown[] = [ @@ -154,7 +573,10 @@ test('Session preparation waits for scoped publication and reconnect reuses its }); await waitFor(() => host.replacements.length === 1, { timeoutMs: 5_000, pollMs: 10 }); assert.equal(prepared, false); - assert.deepEqual(host.replacements[0]?.options, { sessionId }); + assert.deepEqual(host.replacements[0]?.options, { + sessionId, + sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(mcp.config)).digest('hex')}`, + }); const provider = host.replacements[0]!.provider; assert.ok(provider.offers().length > 0); for (const offer of provider.offers()) { @@ -184,7 +606,7 @@ test('Session preparation waits for scoped publication and reconnect reuses its assert.equal(host.replacements.length, 2); assert.deepEqual( host.replacements.map((replacement) => replacement.options), - [{ sessionId }, { sessionId }], + [host.replacements[0]?.options, host.replacements[0]?.options], ); assert.deepEqual( (await fixtureEvents(root, 'fixture')).filter((event) => event.event === 'start'), @@ -717,7 +1139,10 @@ function registryConnection( }; } -async function invokeEnvironment(provider: ClientCapabilityProvider): Promise { +async function invokeEnvironment( + provider: ClientCapabilityProvider, + targetSessionId = sessionId, +): Promise { const offer = provider .offers() .find((candidate) => candidate.tools.some((tool) => tool.name === 'environment')); @@ -732,7 +1157,7 @@ async function invokeEnvironment(provider: ClientCapabilityProvider): Promise> = [ + { + type: 'select', + id: 'permission_mode', + name: 'Permission mode', + category: '_maka/permission_mode', + currentValue: 'ask', + options: [ + { value: 'ask', name: 'Ask' }, + { value: 'bypass', name: 'Bypass' }, + ], + }, + { + type: 'select', + id: 'thinking_level', + name: 'Thinking level', + category: 'thought_level', + currentValue: 'default', + options: [ + { value: 'default', name: 'Default' }, + { value: 'off', name: 'Off' }, + { value: 'minimal', name: 'Minimal' }, + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'xhigh', name: 'Extra high' }, + { value: 'max', name: 'Max' }, + ], + }, + { + type: 'select', + id: 'collaboration_mode', + name: 'Collaboration mode', + category: 'mode', + currentValue: 'agent', + options: [ + { value: 'agent', name: 'Agent' }, + { value: 'plan', name: 'Plan' }, + ], + }, + { + type: 'select', + id: 'orchestration_mode', + name: 'Orchestration mode', + category: '_maka/orchestration_mode', + currentValue: 'default', + options: [ + { value: 'default', name: 'Default' }, + { value: 'swarm', name: 'Swarm' }, + { value: 'graph', name: 'Graph' }, + ], + }, +]; + +describe('ACP Session registry', () => { + for (const method of ['load', 'resume'] as const) { + test(`${method} failure before client replacement preserves a live prompt interaction`, async () => { + const sessionId = `interaction-rollback-${method}`; + const turnId = 'local-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question', + sessionId, + turnId, + runId: `run-${turnId}`, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + let started = false; + let dialogs = 0; + let stops = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.start') { + started = true; + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'interaction.query') return pending; + if (operation === 'turn.stop') { + stops += 1; + subscription.setRoot(completedTurn(sessionId, turnId)); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const context = { + ...promptContext([]), + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs += 1; + return new Promise(() => undefined); + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }, + }; + const prompt = registry + .prompt({ sessionId, prompt: [{ type: 'text', text: 'run' }] }, context) + .catch(() => undefined); + try { + await waitFor(() => started); + await assert.rejects( + registry[method]({ sessionId, cwd: '/other', mcpServers: [] }, context), + ); + subscription.project({ interactions: { pending: [pending] } }); + await waitFor(() => dialogs > 0 || stops > 0); + assert.equal(dialogs, 1); + assert.equal(stops, 0); + } finally { + await registry.cancel({ sessionId }); + await prompt; + await registry.dispose(); + } + }); + } + + for (const repeatRestore of [false, true]) { + test(`retained restore preserves notification invocation order across a successor (repeat=${repeatRestore})`, async () => { + const sessionId = `restore-order-${repeatRestore}`; + const first = runningTurn(sessionId, 'first'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId, { rootTurn: first })); + const blocked = deferred(); + const release = deferred(); + const calls: string[] = []; + let dialogs = 0; + const successor = runningTurn(sessionId, 'second'); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'second-question', + sessionId, + turnId: successor.turnId, + runId: successor.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') return pending; + if (operation === 'turn.stop') return {}; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + const context = { + ...promptContext([]), + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs += 1; + return new Promise(() => undefined); + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }, + notify: async ({ update }: SessionNotification) => { + if (update.sessionUpdate !== 'agent_message_chunk' || update.content.type !== 'text') + return; + calls.push(update.content.text); + if (update.content.text === 'A1') { + blocked.resolve(); + await release.promise; + } + }, + }; + try { + await registry.resume({ sessionId, cwd: TEST_CWD }, context); + subscription.appendText(first.turnId, first.runId, 'A1'); + await blocked.promise; + subscription.appendText(first.turnId, first.runId, 'A1A2', true); + subscription.setRoot(completedTurn(sessionId, first.turnId)); + const reads = subscription.nextCalls; + subscription.setRoot(successor); + await waitFor(() => subscription.nextCalls > reads); + if (repeatRestore) await registry.resume({ sessionId, cwd: TEST_CWD }, context); + subscription.project({ interactions: { pending: [pending] } }); + subscription.appendText('second', 'run-second', 'B', true); + await new Promise((resolve) => setImmediate(resolve)); + release.resolve(); + await waitFor(() => calls.length === 3); + assert.deepEqual(calls, ['A1', 'A2', 'B']); + await waitFor(() => dialogs === 1); + } finally { + release.resolve(); + subscription.setRoot(null); + await registry.dispose(); + } + }); + } + + test('aborted history replay restores the interaction client it replaced', async () => { + const sessionId = 'interaction-rollback-after-replacement'; + const turnId = 'local-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + subscription.seedBootstrap([ + { type: 'user', id: 'history', turnId: 'old', ts: 1, text: 'old' }, + ]); + let pageRead = false; + const pageGate = deferred(); + const abort = new AbortController(); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question', + sessionId, + turnId, + runId: `run-${turnId}`, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + let started = false; + let oldDialogs = 0; + let newDialogs = 0; + let stops = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.start') { + started = true; + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'interaction.query') return pending; + if (operation === 'turn.stop') { + stops += 1; + subscription.setRoot(completedTurn(sessionId, turnId)); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + const client = (onDialog: () => void) => ({ + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + onDialog(); + return new Promise(() => undefined); + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const prompt = registry + .prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + { ...promptContext([]), interactions: client(() => (oldDialogs += 1)) }, + ) + .catch(() => undefined); + try { + await waitFor(() => started); + subscription.transcriptPageGate = pageGate.promise; + subscription.onTranscriptPageRead = () => { + pageRead = true; + }; + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { + ...promptContext([]), + signal: abort.signal, + interactions: client(() => (newDialogs += 1)), + }, + ); + await waitFor(() => pageRead); + abort.abort(); + pageGate.resolve(); + await assert.rejects(loading); + subscription.project({ interactions: { pending: [pending] } }); + await waitFor(() => oldDialogs + newDialogs + stops > 0); + assert.deepEqual([oldDialogs, newDialogs, stops], [1, 0, 0]); + } finally { + pageGate.resolve(); + await registry.cancel({ sessionId }); + await prompt; + await registry.dispose(); + } + }); + + test('a failed explicit resume cannot roll back a newer restore using the same context', async () => { + const sessionId = 'resume-context-epoch'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const startEntered = deferred(); + const start = deferred(); + const oldNotifications: SessionNotification[] = []; + const newNotifications: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => 'resumed-turn', + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') { + startEntered.resolve(); + return start.promise; + } + if (operation === 'turn.stop') return {}; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + const newerContext = promptContext(newNotifications); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + try { + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext(oldNotifications)); + const resuming = registry.resumeTurn({ sessionId }, newerContext); + void resuming.catch(() => undefined); + await startEntered.promise; + await registry.resume({ sessionId, cwd: TEST_CWD }, newerContext); + start.reject(new Error('Resume start rejected')); + await assert.rejects(resuming); + const external = runningTurn(sessionId, 'external-turn'); + subscription.setRoot(external); + subscription.appendText(external.turnId, external.runId, 'new client output', true); + await waitFor(() => + newNotifications.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'new client output', + ), + ); + assert.equal(oldNotifications.length, 0); + } finally { + start.reject(new Error('Resume start rejected')); + subscription.setRoot(null); + await registry.dispose(); + } + }); + + test('overlapping failed restores return to the original prompt client', async () => { + const sessionId = 'nested-interaction-rollback'; + const turnId = 'local-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + subscription.seedBootstrap([ + { type: 'user', id: 'history', turnId: 'old', ts: 1, text: 'old' }, + ]); + let pageRead = false; + const pageGate = deferred(); + let startEntered = false; + const start = deferred(); + const abort = new AbortController(); + const dialogs: string[] = []; + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question', + sessionId, + turnId, + runId: `run-${turnId}`, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + let turnIds = 0; + let started = false; + let stops = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => (++turnIds === 1 ? turnId : 'resumed-turn'), + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.start') { + started = true; + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') { + startEntered = true; + return start.promise; + } + if (operation === 'interaction.query') return pending; + if (operation === 'turn.stop') { + stops += 1; + subscription.setRoot(completedTurn(sessionId, turnId)); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + const client = (name: string) => ({ + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs.push(name); + return new Promise(() => undefined); + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const prompt = registry + .prompt( + { sessionId, prompt: [{ type: 'text', text: 'run' }] }, + { ...promptContext([]), interactions: client('prompt') }, + ) + .catch(() => undefined); + try { + await waitFor(() => started); + subscription.transcriptPageGate = pageGate.promise; + subscription.onTranscriptPageRead = () => { + pageRead = true; + }; + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { ...promptContext([]), signal: abort.signal, interactions: client('load') }, + ); + void loading.catch(() => undefined); + await waitFor(() => pageRead); + const resuming = registry.resumeTurn( + { sessionId }, + { ...promptContext([]), interactions: client('resumeTurn') }, + ); + void resuming.catch(() => undefined); + await waitFor(() => startEntered); + abort.abort(); + pageGate.resolve(); + await assert.rejects(loading); + start.reject(new Error('Resume start rejected')); + await assert.rejects(resuming); + subscription.project({ interactions: { pending: [pending] } }); + await waitFor(() => dialogs.length > 0 || stops > 0); + assert.deepEqual(dialogs, ['prompt']); + assert.equal(stops, 0); + } finally { + pageGate.resolve(); + start.reject(new Error('Resume start rejected')); + await registry.cancel({ sessionId }); + await prompt; + await registry.dispose(); + } + }); + + for (const method of ['load', 'resume'] as const) { + test(`${method} reuses an attachment and adopts a Turn started before restore`, async () => { + const sessionId = `retained-${method}`; + const turnId = 'external-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const notifications: SessionNotification[] = []; + let opens = 0; + let pendingPresented = 0; + const external = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'external-question', + sessionId, + turnId, + runId: external.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => 'local-turn', + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') return pending; + if (operation === 'turn.start') { + const local = runningTurn(sessionId, 'local-turn'); + subscription.setRoot(local); + subscription.setRoot(completedTurn(sessionId, 'local-turn')); + return { + kind: 'started', + turn: local, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + if (operation === 'turn.stop') return {}; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + return subscription; + }, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'first' }] }, + promptContext([]), + ); + const before = subscription.nextCalls; + subscription.setRoot(external); + subscription.project({ interactions: { pending: [pending] } }); + await waitFor(() => subscription.nextCalls > before); + const restoreContext = { + ...promptContext(notifications), + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + pendingPresented += 1; + return new Promise(() => undefined); + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }, + }; + await registry[method]({ sessionId, cwd: TEST_CWD, mcpServers: [] }, restoreContext); + assert.equal(opens, 1); + await waitFor(() => pendingPresented === 1); + subscription.appendText(turnId, external.runId, 'external live output', true); + await waitFor(() => + notifications.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'external live output', + ), + ); + await registry.resume({ sessionId, cwd: TEST_CWD }, restoreContext); + assert.equal(opens, 1); + assert.equal(pendingPresented, 1); + } finally { + subscription.setRoot(null); + await registry.dispose(); + } + }); + } + + test('loads durable history, returns configuration, and retains attachment for prompt', async () => { + const sessionId = 'session-loaded'; + const history: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId: 'old-turn', ts: 1, text: 'earlier' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'old-turn', + ts: 2, + text: 'answer', + modelId: 'default', + }, + { + type: 'tool_call', + id: 'tool-1', + turnId: 'old-turn', + ts: 3, + toolName: 'Read', + args: { path: 'notes.txt' }, + }, + { + type: 'tool_result', + id: 'result-1', + turnId: 'old-turn', + ts: 4, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'historical tool result' }, + }, + { type: 'turn_state', id: 'state-1', turnId: 'old-turn', ts: 5, status: 'completed' }, + ]; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId), + Promise.resolve(history), + ); + subscription.seedBootstrap(history); + const notifications: SessionNotification[] = []; + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.catalog.query') { + return { kind: 'session', session: catalogSession(sessionId) }; + } + if (operation === 'turn.start') { + const turnId = (input as { turnId: string }).turnId; + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + subscription.appendText(turnId, turn.runId, 'new answer', true); + subscription.setRoot(completedTurn(sessionId, turnId)); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + return subscription; + }, + }), + newTurnId: () => 'new-turn', + }); + try { + const loaded = await registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext(notifications), + ); + assert.deepEqual(loaded.configOptions, DEFAULT_CONFIG_OPTIONS); + assert.deepEqual( + notifications.flatMap(({ update }) => + (update.sessionUpdate === 'user_message_chunk' || + update.sessionUpdate === 'agent_message_chunk') && + update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['earlier', 'answer'], + ); + assert.ok( + notifications.some( + ({ update }) => update.sessionUpdate === 'tool_call' && update.toolCallId === 'tool-1', + ), + ); + assert.ok( + notifications.some( + ({ update }) => + update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'tool-1', + ), + ); + assert.deepEqual( + await registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'again' }] }, + promptContext(notifications), + ), + { stopReason: 'end_turn' }, + ); + const beforeSecondLoad = notifications.length; + await registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext(notifications), + ); + assert.deepEqual( + notifications + .slice(beforeSecondLoad) + .flatMap(({ update }) => + (update.sessionUpdate === 'user_message_chunk' || + update.sessionUpdate === 'agent_message_chunk') && + update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['earlier', 'answer'], + ); + const beforeResume = notifications.length; + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext(notifications)); + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext(notifications)); + assert.equal(notifications.length, beforeResume); + assert.equal(opens, 1); + await registry.close({ sessionId }); + assert.equal(subscription.closeCalls, 1); + } finally { + await registry.dispose(); + } + }); + + test('close wins a held load catalog read without opening an attachment', async () => { + const sessionId = 'session-held-load'; + const readStarted = deferred(); + const catalog = deferred<{ kind: 'session'; session: SessionCatalogProjection }>(); + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') { + readStarted.resolve(); + return catalog.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + throw new Error('Attachment must not open after close'); + }, + }), + }); + try { + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext([]), + ); + await readStarted.promise; + await registry.close({ sessionId }); + catalog.resolve({ kind: 'session', session: catalogSession(sessionId) }); + await assert.rejects(loading); + assert.equal(opens, 0); + } finally { + catalog.resolve({ kind: 'session', session: catalogSession(sessionId) }); + await registry.dispose(); + } + }); + + test('load waits for an in-flight close before restoring the Session', async () => { + const sessionId = 'session-close-then-load'; + const turnId = 'external-turn'; + const first = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, turnId) }), + ); + const second = new FakeSubscription(continuitySnapshot(sessionId)); + const stopStarted = deferred(); + const stopRelease = deferred(); + let catalogReads = 0; + let opens = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') { + catalogReads += 1; + return { kind: 'session', session: catalogSession(sessionId) }; + } + if (operation === 'turn.stop') { + stopStarted.resolve(); + await stopRelease.promise; + first.setRoot(completedTurn(sessionId, turnId)); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => (++opens === 1 ? first : second), + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext([])); + const closing = registry.close({ sessionId }); + await stopStarted.promise; + const readsBeforeLoad = catalogReads; + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext([]), + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(catalogReads, readsBeforeLoad); + stopRelease.resolve(); + await closing; + await loading; + assert.equal(opens, 2); + assert.equal(second.closeCalls, 0); + } finally { + stopRelease.resolve(); + await registry.dispose(); + } + }); + + test('load scans every correlated transcript page in order', async () => { + const sessionId = 'session-paged-load'; + const history: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId: 'old-turn', ts: 1, text: 'first' }, + { type: 'user', id: 'user-2', turnId: 'old-turn', ts: 2, text: 'second' }, + { type: 'user', id: 'user-3', turnId: 'old-turn', ts: 3, text: 'third' }, + ]; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId), + Promise.resolve(history), + ); + subscription.seedBootstrap(history); + subscription.transcriptPageSize = 1; + subscription.transcriptEmptyFirstPage = true; + const notifications: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext(notifications), + ); + assert.equal(subscription.transcriptPageReads, 4); + assert.deepEqual( + notifications.flatMap(({ update }) => + update.sessionUpdate === 'user_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['first', 'second', 'third'], + ); + } finally { + await registry.dispose(); + } + }); + + test('retry after a partial history delivery resumes without repeating accepted chunks', async () => { + const sessionId = 'session-partial-history'; + const history: StoredMessage[] = [ + { type: 'user', id: 'first', turnId: 'old-turn', ts: 1, text: 'first' }, + { type: 'user', id: 'second', turnId: 'old-turn', ts: 2, text: 'second' }, + ]; + const subscriptions = [ + new FakeSubscription(continuitySnapshot(sessionId), Promise.resolve(history)), + new FakeSubscription(continuitySnapshot(sessionId), Promise.resolve(history)), + ]; + for (const subscription of subscriptions) subscription.seedBootstrap(history); + const delivered: string[] = []; + let failSecond = true; + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscriptions[opens++]!, + }), + }); + const context = { + ...promptContext([]), + notify: async (notification: SessionNotification) => { + const update = notification.update; + if (update.sessionUpdate !== 'user_message_chunk' || update.content.type !== 'text') return; + if (update.content.text === 'second' && failSecond) { + failSecond = false; + throw new Error('Client delivery interrupted'); + } + delivered.push(update.content.text); + }, + }; + try { + await assert.rejects(registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, context)); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, context); + assert.deepEqual(delivered, ['first', 'second']); + assert.equal(opens, 2); + } finally { + await registry.dispose(); + } + }); + + for (const firstChunkBeforeLoad of [false, true]) + test(`history replay omits live output when streaming starts ${firstChunkBeforeLoad ? 'before' : 'during'} load`, async () => { + const sessionId = 'session-pre-gate-replay'; + const turn = runningTurn(sessionId, 'live-turn'); + const subscription = new FakeSubscription(continuitySnapshot(sessionId, { rootTurn: turn })); + const catalogHeld = deferred(); + const releaseCatalog = deferred(); + const live: SessionNotification[] = []; + const replay: SessionNotification[] = []; + let catalogReads = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') { + if (++catalogReads === 2) { + catalogHeld.resolve(); + await releaseCatalog.promise; + } + return { kind: 'session', session: catalogSession(sessionId) }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext(live)); + if (firstChunkBeforeLoad) { + subscription.appendText(turn.turnId, turn.runId, 'hel'); + await waitFor(() => + live.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'hel', + ), + ); + } + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext(replay), + ); + await catalogHeld.promise; + if (!firstChunkBeforeLoad) subscription.appendText(turn.turnId, turn.runId, 'hel'); + await waitFor(() => + live.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'hel', + ), + ); + subscription.publishTranscript([ + { + type: 'assistant', + id: `message-${turn.turnId}`, + turnId: turn.turnId, + ts: 2, + text: 'hello', + modelId: 'test-model', + }, + ]); + await waitFor( + () => + live + .flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ) + .join('') === 'hello', + ); + releaseCatalog.resolve(); + await loading; + assert.deepEqual( + replay.filter(({ update }) => update.sessionUpdate === 'agent_message_chunk'), + [], + ); + } finally { + releaseCatalog.resolve(); + subscription.setRoot(completedTurn(sessionId, turn.turnId)); + await registry.dispose(); + } + }); + + test('load replays a prefix that resume seeded without delivering', async () => { + const sessionId = 'session-silent-resume-prefix'; + const turn = runningTurn(sessionId, 'live-turn'); + const initial: StoredMessage = { + type: 'assistant', + id: `message-${turn.turnId}`, + turnId: turn.turnId, + ts: 2, + text: 'hel', + modelId: 'test-model', + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn }), + Promise.resolve([initial]), + ); + subscription.seedBootstrap([initial]); + const live: SessionNotification[] = []; + const replay: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume({ sessionId, cwd: TEST_CWD }, promptContext(live)); + subscription.appendText(turn.turnId, turn.runId, 'hello'); + await waitFor(() => + live.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'lo', + ), + ); + subscription.publishTranscript([{ ...initial, text: 'hello' }]); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(replay)); + assert.deepEqual( + replay.flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['hello'], + ); + } finally { + subscription.setRoot(completedTurn(sessionId, turn.turnId)); + await registry.dispose(); + } + }); + + test('load rejects missing, archived, and mismatched Sessions before ownership or attachment', async () => { + for (const scenario of ['missing', 'archived', 'cwd'] as const) { + const sessionId = `session-invalid-${scenario}`; + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return scenario === 'missing' + ? { kind: 'session', session: null } + : { + kind: 'session', + session: catalogSession(sessionId, TEST_CWD, { + isArchived: scenario === 'archived', + }), + }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + throw new Error('Invalid Session must not attach'); + }, + }), + }); + try { + await assert.rejects( + registry.load( + { + sessionId, + cwd: scenario === 'cwd' ? '/other' : TEST_CWD, + mcpServers: [], + }, + promptContext([]), + ), + ); + await assert.rejects( + registry.prompt( + { + sessionId, + prompt: [{ type: 'text', text: 'must fail' }], + }, + promptContext([]), + ), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + assert.equal(opens, 0); + } finally { + await registry.dispose(); + } + } + }); + + test('resume attaches to an existing live Turn without replaying history or starting it', async () => { + const sessionId = 'session-live-resume'; + const turnId = 'turn-existing'; + const turn = runningTurn(sessionId, turnId); + const history: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId, ts: 1, text: 'earlier' }, + { + type: 'assistant', + id: `message-${turnId}`, + turnId, + ts: 2, + text: 'partial', + modelId: 'default', + }, + ]; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn }), + Promise.resolve(history), + ); + subscription.seedBootstrap(history); + const notifications: SessionNotification[] = []; + const operations: string[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + operations.push(operation); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + const result = await registry.resume( + { sessionId, cwd: TEST_CWD }, + promptContext(notifications), + ); + assert.deepEqual(result.configOptions, DEFAULT_CONFIG_OPTIONS); + assert.equal(notifications.length, 0); + subscription.appendText(turnId, turn.runId, 'partial done', true); + await waitFor(() => + notifications.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === ' done', + ), + ); + subscription.setRoot(completedTurn(sessionId, turnId)); + assert.deepEqual(operations, ['session.catalog.query']); + } finally { + await registry.dispose(); + } + }); + + for (const status of ['completed', 'failed', 'cancelled'] as const) { + for (const nextTurn of ['none', 'running', 'completed'] as const) { + test(`restore reports ${status} during readiness with next Turn ${nextTurn}`, { + timeout: 10_000, + }, async () => { + const sessionId = 'initial-terminal'; + const turnId = 'initial-turn'; + const turn = runningTurn(sessionId, turnId); + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn }), + ); + subscription.seedBootstrap([]); + const ready = subscription.ready.bind(subscription); + subscription.ready = async () => { + await ready(); + subscription.appendText(turnId, turn.runId, 'final output', true); + subscription.setRoot( + status === 'completed' + ? completedTurn(sessionId, turnId) + : { + ...turn, + terminalEventId: 'terminal', + ...(status === 'failed' + ? { status: 'failed' as const, failureClass: 'provider_failure' } + : { status: 'cancelled' as const, abortSource: 'user' }), + }, + ); + if (nextTurn !== 'none') { + const next = runningTurn(sessionId, 'next-turn'); + subscription.setRoot(next); + if (nextTurn === 'completed') { + subscription.appendText(next.turnId, next.runId, 'next output', true); + subscription.setRoot(completedTurn(sessionId, next.turnId)); + } + } + await new Promise((resolve) => setImmediate(resolve)); + }; + const delivered: string[] = []; + const terminal = deferred(); + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.stop') return {}; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { + signal: new AbortController().signal, + notify: async ({ update }) => { + if ( + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' + ) + delivered.push(update.content.text); + }, + notifyTurnStatus: async (value) => { + if (value.turnId === 'next-turn') { + assert.deepEqual(value, { + sessionId, + turnId: 'next-turn', + runId: runningTurn(sessionId, 'next-turn').runId, + status: 'completed', + }); + delivered.push('next completed'); + terminal.resolve(); + return; + } + assert.deepEqual(value, { + sessionId, + turnId, + runId: turn.runId, + status, + ...(status === 'failed' ? { failureClass: 'provider_failure' } : {}), + }); + delivered.push(status); + if (nextTurn !== 'completed') terminal.resolve(); + }, + }, + ); + await terminal.promise; + assert.deepEqual( + delivered.filter((item) => !item.startsWith('next ')), + ['final output', status], + ); + if (nextTurn === 'completed') + assert.deepEqual( + delivered.filter((item) => item.startsWith('next ')), + ['next output', 'next completed'], + ); + } finally { + await registry.dispose(); + } + }); + } + } + + test('resume restores a pending interaction on the attached Turn', async () => { + const sessionId = 'session-resumed-interaction'; + const turnId = 'turn-resumed-interaction'; + const turn = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'question-resumed', + sessionId, + turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool-resumed', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { + rootTurn: turn, + interactions: { pending: [pending] }, + }), + ); + const notifications: SessionNotification[] = []; + let dialogs = 0; + let answers = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') return pending; + if (operation === 'interaction.answer') { + answers += 1; + return { + ...pending, + revision: 2, + status: 'answered', + outcome: { + kind: 'question_answer', + answers: ['Yes'], + committedAt: 1, + }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext(notifications), + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + dialogs += 1; + return { action: 'accept' as const, content: { q0: 'Yes' } }; + }, + requestPermission: async () => assert.fail('Unexpected permission request'), + }, + }, + ); + await waitFor(() => answers === 1); + assert.equal(dialogs, 1); + assert.ok( + notifications.some( + ({ update }) => + update.sessionUpdate === 'tool_call' && update.toolCallId === 'tool-resumed', + ), + ); + } finally { + await registry.dispose(); + } + }); + + test('load finishes historical delivery before releasing an existing live Turn', async () => { + const sessionId = 'session-live-load-order'; + const turnId = 'turn-live-load-order'; + const turn = runningTurn(sessionId, turnId); + const history: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId, ts: 1, text: 'earlier' }, + { + type: 'assistant', + id: `message-${turnId}`, + turnId, + ts: 2, + text: 'partial', + modelId: 'default', + }, + ]; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn }), + Promise.resolve(history), + ); + subscription.seedBootstrap(history); + const delivered: string[] = []; + const userSent = deferred(); + const releaseUser = deferred(); + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { + signal: new AbortController().signal, + notify: async ({ update }) => { + if ( + (update.sessionUpdate === 'user_message_chunk' || + update.sessionUpdate === 'agent_message_chunk') && + update.content.type === 'text' + ) { + delivered.push(update.content.text); + if (update.sessionUpdate === 'user_message_chunk') { + userSent.resolve(); + await releaseUser.promise; + } + } + }, + }, + ); + await userSent.promise; + subscription.appendText(turnId, turn.runId, 'partial live', true); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(delivered, ['earlier']); + releaseUser.resolve(); + await loading; + await waitFor(() => delivered.includes(' live')); + assert.deepEqual(delivered, ['earlier', 'partial', ' live']); + } finally { + releaseUser.resolve(); + await registry.dispose(); + } + }); + + test('load does not redeliver a live chunk already included in historical replay', async () => { + const sessionId = 'session-live-replay-overlap'; + const turnId = 'turn-live-replay-overlap'; + const turn = runningTurn(sessionId, turnId); + const initial: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId, ts: 1, text: 'earlier' }, + { + type: 'assistant', + id: `message-${turnId}`, + turnId, + ts: 2, + text: 'partial', + modelId: 'default', + }, + ]; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn }), + Promise.resolve(initial), + ); + subscription.seedBootstrap(initial); + const pageGate = deferred(); + const pageRead = deferred(); + subscription.transcriptPageGate = pageGate.promise; + subscription.onTranscriptPageRead = () => pageRead.resolve(); + const notifications: SessionNotification[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + promptContext(notifications), + ); + await pageRead.promise; + assert.equal(subscription.transcriptPageReads, 1); + subscription.publishTranscript([ + initial[0], + { + type: 'assistant', + id: `message-${turnId}`, + turnId, + ts: 2, + text: 'partial live', + modelId: 'default', + }, + ]); + subscription.appendText(turnId, turn.runId, 'partial live', true); + pageGate.resolve(); + await loading; + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual( + notifications.flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['partial live'], + ); + } finally { + pageGate.resolve(); + await registry.dispose(); + } + }); + + test('failed historical hydration releases the newly opened attachment', async () => { + const sessionId = 'session-load-page-failure'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + subscription.seedBootstrap([ + { + type: 'user', + id: 'user-before-failure', + turnId: 'turn-before-failure', + ts: 1, + text: 'hello', + }, + ]); + subscription.onTranscriptPageRead = () => { + throw new Error('transcript page unavailable'); + }; + const retrySubscription = new FakeSubscription(continuitySnapshot(sessionId)); + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => + ++opens === 1 ? subscription : retrySubscription, + }), + }); + try { + await assert.rejects( + registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])), + (error: unknown) => error instanceof RequestError, + ); + assert.equal(subscription.closeCalls, 1); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + assert.equal(retrySubscription.closeCalls, 0); + } finally { + await registry.dispose(); + } + }); + + test('failed load of an owned unattached Session releases only its new subscription', async () => { + const sessionId = 'owned-load-failure'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + subscription.seedBootstrap([{ type: 'user', id: 'u', turnId: 't', ts: 1, text: 'hello' }]); + subscription.onTranscriptPageRead = () => { + throw new Error('transcript page unavailable'); + }; + const retrySubscription = new FakeSubscription(continuitySnapshot(sessionId)); + let opens = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => + ++opens === 1 ? subscription : retrySubscription, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await assert.rejects( + registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])), + ); + assert.equal(subscription.closeCalls, 1); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + assert.equal(retrySubscription.closeCalls, 0); + } finally { + await registry.dispose(); + } + }); + + test('failed live history load releases its observer without stopping the existing Host Turn', async () => { + const sessionId = 'live-load-failure'; + const turnId = 'existing-turn'; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, turnId) }), + ); + subscription.seedBootstrap([{ type: 'user', id: 'u', turnId, ts: 1, text: 'hello' }]); + subscription.onTranscriptPageRead = () => { + throw new Error('history unavailable'); + }; + const retrySubscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, turnId) }), + ); + const stops: Array<{ sessionId: string; turnId: string; runId: string }> = []; + let opens = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.stop') { + stops.push(input as (typeof stops)[number]); + return completedTurn(sessionId, turnId); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => + ++opens === 1 ? subscription : retrySubscription, + }), + }); + try { + await assert.rejects( + registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])), + ); + for (let attempt = 0; attempt < 10; attempt += 1) await new Promise(setImmediate); + assert.deepEqual(stops, []); + assert.equal(subscription.closeCalls, 1); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + assert.equal(retrySubscription.closeCalls, 0); + assert.deepEqual(stops, []); + } finally { + await registry.dispose(); + } + }); + + test('unsupported restored interaction leaves its Host Turn pending', async (t) => { + t.mock.method(console, 'error', () => undefined); + const sessionId = 'unsupported-restored', + turnId = 'restored-turn'; + const turn = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'restored-question', + sessionId, + turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn, interactions: { pending: [pending] } }), + ); + const statuses: string[] = []; + const stops: Array<{ sessionId: string; turnId: string; runId: string }> = []; + let queries = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') { + queries += 1; + return pending; + } + if (operation === 'turn.stop') { + stops.push(input as (typeof stops)[number]); + subscription.setRoot(completedTurn(sessionId, turnId)); + return completedTurn(sessionId, turnId); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext([]), + notifyTurnStatus: async (status) => { + statuses.push(status.status); + }, + }, + ); + await waitFor(() => queries >= 1); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(stops, []); + assert.deepEqual(statuses, []); + } finally { + subscription.setRoot(completedTurn(sessionId, turnId)); + await registry.dispose(); + } + }); + + for (const failure of ['unsupported-method', 'invalid-answer', 'output-failure'] as const) { + test(`restored ${failure} leaves the Host Turn pending without answering`, async (t) => { + t.mock.method(console, 'error', () => undefined); + const sessionId = `restored-${failure}`; + const turnId = `turn-${failure}`; + const turn = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: `question-${failure}`, + sessionId, + turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn, interactions: { pending: [pending] } }), + ); + let stops = 0, + answers = 0; + let presentations = 0; + const statuses: string[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') return pending; + if (operation === 'interaction.answer') { + answers += 1; + return pending; + } + if (operation === 'turn.stop') { + stops += 1; + subscription.setRoot(completedTurn(sessionId, turnId)); + return completedTurn(sessionId, turnId); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext([]), + notify: async () => { + presentations += 1; + if (failure === 'output-failure') throw new Error('Output failed'); + }, + notifyTurnStatus: async (status) => { + statuses.push(status.status); + }, + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + presentations += 1; + if (failure === 'unsupported-method') + throw RequestError.methodNotFound('elicitation/create'); + return { action: 'accept' as const, content: { unexpected: 'answer' } }; + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }, + }, + ); + await waitFor(() => presentations >= (failure === 'output-failure' ? 1 : 2)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(stops, 0); + assert.deepEqual(statuses, []); + assert.equal(answers, 0); + } finally { + subscription.setRoot(completedTurn(sessionId, turnId)); + await registry.dispose(); + } + }); + } + + test('cancelling a restored Turn fences a late answer after Stop fails', async () => { + const sessionId = 'cancel-restored', + turnId = 'cancel-restored-turn'; + const turn = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'cancel-question', + sessionId, + turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'question', + toolUseId: 'tool', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn, interactions: { pending: [pending] } }), + ); + const dialog = deferred<{ action: 'accept'; content: { q0: string } }>(); + const opened = deferred(); + let stops = 0, + answers = 0; + const queriedInteractions: string[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') { + queriedInteractions.push((input as { interactionId: string }).interactionId); + return pending; + } + if (operation === 'interaction.answer') { + answers += 1; + return pending; + } + if (operation === 'turn.stop') { + stops += 1; + throw new Error('Stop failed'); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext([]), + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + opened.resolve(); + return dialog.promise; + }, + requestPermission: async () => assert.fail('Unexpected permission'), + }, + }, + ); + await opened.promise; + await registry.cancel({ sessionId }); + assert.equal(stops, 1); + dialog.resolve({ action: 'accept', content: { q0: 'Yes' } }); + subscription.project({ + interactions: { pending: [{ ...pending, interactionId: 'later-question' }] }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(answers, 0); + assert.equal(queriedInteractions.includes('later-question'), false); + } finally { + dialog.resolve({ action: 'accept', content: { q0: 'Yes' } }); + await registry.dispose(); + } + }); + + test('cancelling a restored permission fences its late selection', async () => { + const sessionId = 'cancel-restored-permission'; + const turnId = 'permission-turn'; + const turn = runningTurn(sessionId, turnId); + const pending: InteractionPendingSnapshot = { + schemaVersion: 1, + interactionId: 'permission', + sessionId, + turnId, + runId: turn.runId, + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'permission', + toolUseId: 'tool', + prompt: { + kind: 'tool_permission', + toolName: 'fixture', + category: 'read', + reason: 'custom', + review: { kind: 'path', operation: 'read', path: '/workspace/file' }, + rememberForTurnAllowed: false, + }, + }, + }; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: turn, interactions: { pending: [pending] } }), + ); + const selection = deferred<{ outcome: { outcome: 'selected'; optionId: string } }>(); + const opened = deferred(); + let optionId = '', + answers = 0, + stops = 0; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'interaction.query') return pending; + if (operation === 'interaction.answer') { + answers += 1; + return pending; + } + if (operation === 'turn.stop') { + stops += 1; + throw new Error('Stop failed'); + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext([]), + interactions: { + capabilities: {}, + createElicitation: async () => assert.fail('Unexpected elicitation'), + requestPermission: async (request) => { + optionId = request.options[0]!.optionId; + opened.resolve(); + return selection.promise; + }, + }, + }, + ); + await opened.promise; + await registry.cancel({ sessionId }); + assert.equal(stops, 1); + selection.resolve({ outcome: { outcome: 'selected', optionId } }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(answers, 0); + } finally { + selection.resolve({ outcome: { outcome: 'selected', optionId } }); + await registry.dispose(); + } + }); + + test('load refuses an MCP change while the Host reports an active Turn', { + timeout: 20_000, + }, async () => { + const sessionId = 'session-mcp-active-turn'; + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-acp-mcp-active-'))); + const fixture = fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server')); + const original = { + name: 'fixture', + command: process.execPath, + args: [fixture], + env: [{ name: 'MAKA_MCP_STDIO_EVENT_LOG', value: join(root, 'fixture.jsonl') }], + }; + const changed = { + ...original, + env: [...original.env, { name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'changed' }], + }; + let status: 'active' | 'running' = 'active'; + let publications = 0; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const base = fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId, root); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId, root, { status }) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }); + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => ({ + ...base, + replaceClientCapabilities: async (provider, options) => { + publications += 1; + return base.replaceClientCapabilities(provider, options); + }, + }), + }); + try { + await registry.create({ cwd: root, mcpServers: [original] }); + assert.equal(publications, 1); + status = 'running'; + await assert.rejects( + registry.load({ sessionId, cwd: root, mcpServers: [changed] }, promptContext([])), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string })?.code === 'session_busy', + ); + assert.equal(publications, 1); + await registry.load({ sessionId, cwd: root, mcpServers: [original] }, promptContext([])); + assert.equal(publications, 1); + } finally { + await registry.dispose(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('explicit Turn resume observes before Host admission and reports terminal status', async () => { + const sessionId = 'session-explicit-resume'; + const turnId = 'continuation-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const notifications: SessionNotification[] = []; + const statuses: unknown[] = []; + const operations: string[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + operations.push(operation); + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') { + assert.deepEqual(input, { + sessionId, + turnId, + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 42, + }); + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + subscription.appendText(turnId, turn.runId, 'continued', true); + subscription.setRoot(completedTurn(sessionId, turnId)); + return { kind: 'started', turn }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const result = await registry.resumeTurn( + { sessionId }, + { + ...promptContext(notifications), + notifyTurnStatus: async (status) => { + statuses.push(status); + }, + }, + ); + assert.equal(result.kind, 'started'); + await waitFor(() => statuses.length === 1); + assert.deepEqual(statuses, [ + { + sessionId, + turnId, + runId: `run-${turnId}`, + status: 'completed', + }, + ]); + assert.ok( + notifications.some( + ({ update }) => + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' && + update.content.text === 'continued', + ), + ); + assert.deepEqual(operations, ['session.create', 'turn.resume.query', 'turn.resume.start']); + } finally { + await registry.dispose(); + } + }); + + for (const action of ['close', 'dispose'] as const) { + test(`${action} waits for an explicit resume failure Stop`, async () => { + const sessionId = 'resume-stop-lifetime'; + const turnId = 'resumed-turn'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const stop = deferred(); + let stopRequested = false; + let connectionClosed = false; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') { + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + return { kind: 'started', turn }; + } + if (operation === 'turn.stop') { + stopRequested = true; + subscription.setRoot(completedTurn(sessionId, turnId)); + return stop.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + close: async () => { + connectionClosed = true; + }, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await registry.resumeTurn( + { sessionId }, + { + ...promptContext([]), + notify: async () => { + throw new Error('Client output failed'); + }, + }, + ); + subscription.appendText(turnId, `run-${turnId}`, 'output', true); + await waitFor(() => stopRequested); + let cleanupSettled = false; + const cleaning = ( + action === 'close' ? registry.close({ sessionId }) : registry.dispose() + ).then(() => { + cleanupSettled = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(connectionClosed, false); + assert.equal(cleanupSettled, false); + } finally { + stop.resolve(); + await cleaning; + } + assert.equal(connectionClosed, action === 'dispose'); + if (action === 'close') { + assert.equal(subscription.closeCalls, 1); + await registry.dispose(); + } + }); + } + + test('explicit Turn resume returns a Host parked plan without opening a subscription', async () => { + const sessionId = 'session-resume-parked'; + let opens = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'parked', + reason: 'resume_candidate_missing', + }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + throw new Error('unexpected open'); + }, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + assert.deepEqual(await registry.resumeTurn({ sessionId }, promptContext([])), { + kind: 'parked', + plan: { sessionId, disposition: 'parked', reason: 'resume_candidate_missing' }, + }); + assert.equal(opens, 0); + } finally { + await registry.dispose(); + } + }); + + test('a lost dispatched Turn resume response retains its exact target identity', async () => { + const sessionId = 'session-resume-unknown'; + const turnId = 'turn-resume-unknown'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') + throw new RuntimeHostRequestInterruptedError( + 'turn.resume.start', + 'command', + 'dispatched', + 'connection_lost', + ); + if (operation === 'turn.query') + throw new RuntimeHostOperationError('turn.query', 'not_found', 'not observed'); + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await assert.rejects( + registry.resumeTurn({ sessionId }, promptContext([])), + (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.deepEqual(error.data, { + source: 'runtime_host', + operation: 'turn.resume.start', + code: 'outcome_unknown', + sessionId, + turnId, + sourceRunId: 'source-run', + }); + return true; + }, + ); + } finally { + await registry.dispose(); + } + }); + + test('a rejected Turn resume releases its idle Session for a changed MCP load', async () => { + const sessionId = 'session-resume-rejected'; + const turnId = 'turn-resume-rejected'; + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-acp-resume-rejected-'))); + const original = { + name: 'fixture', + command: process.execPath, + args: [fileURLToPath(import.meta.resolve('@maka/mcp/test-only/stdio-server'))], + env: [{ name: 'MAKA_MCP_STDIO_EVENT_LOG', value: join(root, 'fixture.jsonl') }], + }; + const changed = { + ...original, + env: [...original.env, { name: 'MAKA_MCP_STDIO_FIXTURE_VALUE', value: 'changed' }], + }; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId, root); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId, root) }; + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') + throw new RuntimeHostRequestInterruptedError( + 'turn.resume.start', + 'command', + 'dispatched', + 'connection_lost', + ); + if (operation === 'turn.query') + throw new RuntimeHostOperationError('turn.query', 'not_found', 'not admitted'); + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.create({ cwd: root, mcpServers: [original] }); + await registry.resume({ sessionId, cwd: root, mcpServers: [original] }, promptContext([])); + await assert.rejects(registry.resumeTurn({ sessionId }, promptContext([]))); + await registry.resume( + { + sessionId, + cwd: root, + mcpServers: [changed], + }, + promptContext([]), + ); + } finally { + await registry.dispose(); + await rm(root, { recursive: true, force: true }); + } + }); + + for (const terminalStatus of ['completed', 'failed', 'cancelled'] as const) { + test(`attached Turn reports ${terminalStatus} after output when a next Turn starts`, async () => { + const sessionId = 'session-status-next-root'; + const turnId = 'turn-first'; + const turn = runningTurn(sessionId, turnId); + const subscription = new FakeSubscription(continuitySnapshot(sessionId, { rootTurn: turn })); + const outputEntered = deferred(); + const releaseOutput = deferred(); + const statuses: unknown[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + ...promptContext([]), + notify: async (notification) => { + if (notification.update.sessionUpdate === 'agent_message_chunk') { + outputEntered.resolve(); + await releaseOutput.promise; + } + }, + notifyTurnStatus: async (status) => { + statuses.push(status); + }, + }, + ); + subscription.appendText(turnId, turn.runId, 'hello', true); + await outputEntered.promise; + subscription.setRoot( + terminalStatus === 'completed' + ? completedTurn(sessionId, turnId) + : terminalStatus === 'failed' + ? { + sessionId, + turnId, + runId: turn.runId, + status: 'failed', + terminalEventId: `terminal-${turnId}`, + failureClass: 'provider_failure', + } + : { + sessionId, + turnId, + runId: turn.runId, + status: 'cancelled', + terminalEventId: `terminal-${turnId}`, + abortSource: 'user', + }, + ); + subscription.setRoot(runningTurn(sessionId, 'turn-next')); + await waitFor(() => subscription.snapshot.rootTurn?.turnId === 'turn-next'); + releaseOutput.resolve(); + await waitFor(() => statuses.length === 1); + assert.deepEqual(statuses, [ + { + sessionId, + turnId, + runId: turn.runId, + status: terminalStatus, + ...(terminalStatus === 'failed' ? { failureClass: 'provider_failure' } : {}), + }, + ]); + } finally { + releaseOutput.resolve(); + await registry.dispose(); + } + }); + } + + test('cancel fences an in-flight explicit resume and Stops the admitted Turn', async () => { + const sessionId = 'session-resume-cancel'; + const turnId = 'turn-resume-cancel'; + const subscription = new FakeSubscription(continuitySnapshot(sessionId)); + const started = deferred(); + const startResponse = deferred<{ kind: 'started'; turn: ReturnType }>(); + const stops: unknown[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => turnId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.resume.start') { + started.resolve(); + return startResponse.promise; + } + if (operation === 'turn.stop') { + stops.push(input); + subscription.setRoot({ + sessionId, + turnId, + runId: `run-${turnId}`, + status: 'cancelled', + terminalEventId: 'terminal', + abortSource: 'test', + }); + return {}; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const resuming = registry.resumeTurn({ sessionId }, promptContext([])); + await started.promise; + const cancelling = registry.cancel({ sessionId }); + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + startResponse.resolve({ kind: 'started', turn }); + await cancelling; + assert.equal((await resuming).kind, 'started'); + assert.deepEqual(stops, [{ sessionId, turnId, runId: `run-${turnId}` }]); + } finally { + startResponse.resolve({ kind: 'started', turn: runningTurn(sessionId, turnId) }); + await registry.dispose(); + } + }); + + test('copy-source query requires ownership, forwards bounded paging, and captures revision before reading Turns', async () => { + const sessionId = 'copy-source'; + const queries: unknown[] = []; + let revision = 3; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { + kind: 'session', + session: catalogSession(sessionId, TEST_CWD, { revision }), + }; + if (operation === 'session.turns.query') { + queries.push(input); + revision += 1; + return { sessionId, throughSequence: 20, contributions: [], nextPosition: 2 }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + }), + }); + const query = { sessionId, throughSequence: 20, position: 1, maxContributions: 1 }; + try { + await assert.rejects(registry.queryCopySource(query), RequestError); + assert.deepEqual(queries, []); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + assert.deepEqual(await registry.queryCopySource(query), { + sessionId, + throughSequence: 20, + contributions: [], + nextPosition: 2, + expectedSourceRevision: 3, + }); + assert.deepEqual(queries, [query]); + await registry.close({ sessionId }); + await assert.rejects(registry.queryCopySource(query), RequestError); + assert.equal(queries.length, 1); + } finally { + await registry.dispose(); + } + }); + + test('committed branch and revision targets are immediately owned; retained and abandoned remain distinct', async () => { + const sourceSessionId = 'source-session'; + const targetSessionId = 'branch-target'; + const revisionId = 'revision-target'; + const operations: string[] = []; + const registry = new AcpSessionRegistry({ + newSessionId: () => sourceSessionId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + operations.push(operation); + if (operation === 'session.create') return catalogSession(sourceSessionId); + if (operation === 'session.branch.create') + return { kind: 'committed', session: catalogSession(targetSessionId) }; + if (operation === 'session.revision.create') + return { kind: 'committed', session: catalogSession(revisionId) }; + if (operation === 'session.revision.abandon') { + const id = (input as { targetSessionId: string }).targetSessionId; + return { kind: id === targetSessionId ? 'retained' : 'abandoned', sessionId: id }; + } + if (operation === 'session.catalog.query') { + const id = (input as { sessionId: string }).sessionId; + return { kind: 'session', session: catalogSession(id) }; + } + if (operation === 'session.configuration.update') { + const id = (input as { sessionId: string }).sessionId; + return { kind: 'committed', session: catalogSession(id) }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const copy = { + sourceSessionId, + targetSessionId, + sourceTurnId: 'source-turn', + expectedSourceRevision: 1, + }; + assert.equal((await registry.branch(copy)).kind, 'committed'); + assert.ok( + ( + await registry.setConfigOption({ + sessionId: targetSessionId, + configId: 'collaboration_mode', + value: 'plan', + }) + ).configOptions.length > 0, + ); + assert.equal( + (await registry.createRevision({ ...copy, targetSessionId: revisionId })).kind, + 'committed', + ); + assert.deepEqual(await registry.abandonRevision({ targetSessionId }), { + kind: 'retained', + sessionId: targetSessionId, + }); + assert.deepEqual(await registry.abandonRevision({ targetSessionId: revisionId }), { + kind: 'abandoned', + sessionId: revisionId, + }); + await assert.rejects( + registry.setConfigOption({ + sessionId: revisionId, + configId: 'collaboration_mode', + value: 'plan', + }), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + assert.ok(operations.includes('session.configuration.update')); + } finally { + await registry.dispose(); + } + }); + + test('copy retries an unknown dispatch and does not claim an unverified target', async () => { + const sourceSessionId = 'source-copy-conflict'; + const conflictedId = 'target-copy-conflict'; + const uncertainId = 'target-copy-uncertain'; + const foreignId = 'target-copy-foreign'; + const operations: string[] = []; + let foreignAttempts = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sourceSessionId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + operations.push(operation); + if (operation === 'session.create') return catalogSession(sourceSessionId); + if (operation === 'session.branch.create') { + const target = (input as { targetSessionId: string }).targetSessionId; + if (target === conflictedId) + throw new RuntimeHostOperationError( + operation, + 'operation_conflict', + 'Source revision changed', + ); + if (target === foreignId && ++foreignAttempts === 2) + throw new RuntimeHostOperationError( + operation, + 'operation_conflict', + 'Target belongs to another request', + ); + throw new RuntimeHostRequestInterruptedError( + operation, + 'command', + 'dispatched', + 'connection_lost', + ); + } + throw new Error(`Unexpected operation ${operation}`); + }, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const input = { + sourceSessionId, + targetSessionId: conflictedId, + sourceTurnId: 'source-turn', + expectedSourceRevision: 1, + }; + await assert.rejects( + registry.branch(input), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string })?.code === 'operation_conflict', + ); + await assert.rejects( + registry.branch({ ...input, targetSessionId: uncertainId }), + (error: unknown) => + error instanceof RequestError && + (error.data as { targetSessionId?: string })?.targetSessionId === uncertainId, + ); + await assertInvalidParams( + registry.setConfigOption({ + sessionId: uncertainId, + configId: 'collaboration_mode', + value: 'plan', + }), + { reason: 'unknown_session' }, + ); + await assert.rejects( + registry.branch({ ...input, targetSessionId: foreignId }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string })?.code === 'operation_conflict', + ); + await assertInvalidParams( + registry.setConfigOption({ + sessionId: foreignId, + configId: 'collaboration_mode', + value: 'plan', + }), + { reason: 'unknown_session' }, + ); + assert.equal( + operations.filter((operation) => operation === 'session.branch.create').length, + 5, + ); + } finally { + await registry.dispose(); + } + }); -const DEFAULT_CONFIG_OPTIONS: Array> = [ - { - type: 'select', - id: 'permission_mode', - name: 'Permission mode', - category: '_maka/permission_mode', - currentValue: 'ask', - options: [ - { value: 'ask', name: 'Ask' }, - { value: 'bypass', name: 'Bypass' }, - ], - }, - { - type: 'select', - id: 'thinking_level', - name: 'Thinking level', - category: 'thought_level', - currentValue: 'default', - options: [ - { value: 'default', name: 'Default' }, - { value: 'off', name: 'Off' }, - { value: 'minimal', name: 'Minimal' }, - { value: 'low', name: 'Low' }, - { value: 'medium', name: 'Medium' }, - { value: 'high', name: 'High' }, - { value: 'xhigh', name: 'Extra high' }, - { value: 'max', name: 'Max' }, - ], - }, - { - type: 'select', - id: 'collaboration_mode', - name: 'Collaboration mode', - category: 'mode', - currentValue: 'agent', - options: [ - { value: 'agent', name: 'Agent' }, - { value: 'plan', name: 'Plan' }, - ], - }, - { - type: 'select', - id: 'orchestration_mode', - name: 'Orchestration mode', - category: '_maka/orchestration_mode', - currentValue: 'default', - options: [ - { value: 'default', name: 'Default' }, - { value: 'swarm', name: 'Swarm' }, - { value: 'graph', name: 'Graph' }, - ], - }, -]; + test('copy claims a target only after its exact retry confirms a commit', async () => { + const sourceSessionId = 'source-copy-retry'; + const targetSessionId = 'target-copy-retry'; + let attempts = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sourceSessionId, + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sourceSessionId); + if (operation === 'session.branch.create') { + assert.equal((input as { targetSessionId: string }).targetSessionId, targetSessionId); + if (++attempts === 1) + throw new RuntimeHostRequestInterruptedError( + operation, + 'command', + 'dispatched', + 'connection_lost', + ); + return { kind: 'committed', session: catalogSession(targetSessionId) }; + } + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(targetSessionId) }; + if (operation === 'session.configuration.update') + return { kind: 'committed', session: catalogSession(targetSessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + }), + }); + try { + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + assert.equal( + ( + await registry.branch({ + sourceSessionId, + targetSessionId, + sourceTurnId: 'source-turn', + expectedSourceRevision: 1, + }) + ).kind, + 'committed', + ); + assert.equal(attempts, 2); + assert.ok( + ( + await registry.setConfigOption({ + sessionId: targetSessionId, + configId: 'collaboration_mode', + value: 'plan', + }) + ).configOptions.length > 0, + ); + } finally { + await registry.dispose(); + } + }); -describe('ACP Session registry', () => { test('does not connect when disposed before a Session method is used', async () => { let connectCalls = 0; const registry = new AcpSessionRegistry({ @@ -132,7 +2792,7 @@ describe('ACP Session registry', () => { await registry.dispose(); for (const [operation, request] of [ - ['session.create', () => registry.create({ cwd: '/workspace', mcpServers: [] })], + ['session.create', () => registry.create({ cwd: TEST_CWD, mcpServers: [] })], ['session.catalog.query', () => registry.list({})], [ 'session.configuration.update', @@ -225,7 +2885,7 @@ describe('ACP Session registry', () => { }, newSessionId: () => 'session-concurrent', }); - const create = registry.create({ cwd: '/workspace', mcpServers: [] }); + const create = registry.create({ cwd: TEST_CWD, mcpServers: [] }); const list = registry.list({}); await waitFor(() => connectCalls === 1); @@ -344,7 +3004,7 @@ describe('ACP Session registry', () => { const creates = await Promise.all( Array.from({ length: sessionCount }, () => - registry.create({ cwd: '/workspace', mcpServers: [] }), + registry.create({ cwd: TEST_CWD, mcpServers: [] }), ), ); @@ -371,7 +3031,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-prompt-validation', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); turnRequests.length = 0; await assertInvalidParams( @@ -436,7 +3096,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turnIds.shift()!, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const first = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'one' }] }, @@ -471,8 +3131,10 @@ describe('ACP Session registry', () => { const turn = runningTurn(sessionId, 'turn-tool'); const subscription = new FakeSubscription(continuitySnapshot(sessionId)); const pageGate = deferred(); + const pageRead = deferred(); const deliveryGate = deferred(); subscription.transcriptPageGate = pageGate.promise; + subscription.onTranscriptPageRead = () => pageRead.resolve(); let subscriptionOpens = 0; let stopped = 0; let terminalDeliveryStarted = false; @@ -551,7 +3213,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'use the tool' }] }, { @@ -586,7 +3248,7 @@ describe('ACP Session registry', () => { settled = true; }, ); - await waitFor(() => subscription.transcriptPageReads > 0); + await pageRead.promise; assert.equal(settled, false); if (scenario === 'cancel') { await registry.cancel({ sessionId }); @@ -690,7 +3352,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'use the tool' }] }, promptContext(notifications), @@ -730,6 +3392,58 @@ describe('ACP Session registry', () => { await registry.dispose(); }); + test('reports an attached Turn terminal status across a replacement with a successor root', async () => { + const sessionId = 'session-replaced-terminal'; + const turn = runningTurn(sessionId, 'previous-turn'); + const first = new FakeSubscription(continuitySnapshot(sessionId, { rootTurn: turn })); + const replacement = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, 'next-turn') }), + Promise.resolve([ + { + type: 'turn_state', + id: 'stored-terminal', + turnId: turn.turnId, + ts: 2, + status: 'completed', + }, + ]), + 'subscription-replaced', + ); + const statuses: AcpAttachedTurnStatus[] = []; + const registry = new AcpSessionRegistry({ + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => first, + openSessionSubscription: async () => replacement, + }), + }); + try { + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { ...promptContext([]), notifyTurnStatus: async (status) => void statuses.push(status) }, + ); + first.fail(new RuntimeHostSubscriptionError('connection_closed', 'Connection was lost')); + await waitFor(() => statuses.some((status) => status.turnId === turn.turnId)); + assert.deepEqual( + statuses.find((status) => status.turnId === turn.turnId), + { + sessionId, + turnId: turn.turnId, + runId: turn.runId, + status: 'completed', + }, + ); + } finally { + replacement.setRoot(completedTurn(sessionId, 'next-turn')); + await registry.dispose(); + } + }); + test('explicit cancellation wins after a notification transport failure', async () => { const sessionId = 'session-cancel-failed-delivery'; const turn = runningTurn(sessionId, 'turn-cancel-failed-delivery'); @@ -766,7 +3480,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); let cancellation: Promise | undefined; const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'use the tool' }] }, @@ -807,7 +3521,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn-cancelled', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel me' }] }, promptContext([]), @@ -823,6 +3537,162 @@ describe('ACP Session registry', () => { assert.equal(subscription.closeCalls, 1); }); + for (const method of ['load', 'resume', 'resumeTurn'] as const) { + for (const phase of ['open', 'hydrate'] as const) { + test(`aborting ${method} during ${phase} releases its attachment and allows retry`, { + timeout: 10_000, + }, async () => { + const sessionId = `cancel-${method}-${phase}`; + const transcript = deferred(); + const opening = deferred(); + const entered = deferred(); + const closed = deferred(); + const initial = new FakeSubscription( + continuitySnapshot(sessionId), + phase === 'hydrate' ? transcript.promise : Promise.resolve([]), + 'subscription-1', + () => closed.resolve(), + ); + initial.onTranscriptRead = () => entered.resolve(); + const retry = new FakeSubscription(continuitySnapshot(sessionId)); + const controller = new AbortController(); + let opens = 0; + let starts = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.resume.query') + return { + sessionId, + disposition: 'ready', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 42, + }; + if (operation === 'turn.start' || operation === 'turn.resume.start') starts += 1; + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + if (phase === 'open') entered.resolve(); + return opens > 1 ? retry : phase === 'open' ? opening.promise : initial; + }, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const request = registry[method]( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { ...promptContext([]), signal: controller.signal }, + ); + const rejected = assert.rejects(request); + try { + await entered.promise; + controller.abort(); + await rejected; + if (phase === 'hydrate') assert.equal(initial.closeCalls, 1); + // A late open is closed independently; it cannot occupy the retry slot. + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + opening.resolve(initial); + transcript.resolve([]); + await closed.promise; + assert.equal(initial.closeCalls, 1); + assert.equal(starts, 0); + assert.equal(retry.closeCalls, 0); + } finally { + opening.resolve(initial); + transcript.resolve([]); + await registry.dispose(); + await rejected; + } + }); + } + } + + for (const cancelledMethod of ['load', 'prompt'] as const) { + for (const timing of ['before-wait', 'during-wait'] as const) { + test(`aborting ${cancelledMethod} preserves a concurrent attachment consumer ${timing}`, async () => { + const sessionId = `shared-load-${cancelledMethod}`; + const transcript = deferred(); + const subscription = new FakeSubscription( + continuitySnapshot(sessionId), + transcript.promise, + ); + const abort = new AbortController(); + let opens = 0; + let admitted = 0; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + newTurnId: () => 'shared-turn', + connect: async () => + fakeConnection({ + request: async (operation, input) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.start') { + admitted += 1; + const turnId = (input as { turnId: string }).turnId; + const turn = runningTurn(sessionId, turnId); + subscription.setRoot(turn); + subscription.setRoot(completedTurn(sessionId, turnId)); + return { + kind: 'started', + turn, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => { + opens += 1; + return subscription; + }, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + const loading = registry.load( + { sessionId, cwd: TEST_CWD, mcpServers: [] }, + { + ...promptContext([]), + ...(cancelledMethod === 'load' ? { signal: abort.signal } : {}), + }, + ); + void loading.catch(() => undefined); + await waitFor(() => subscription.nextCalls > 0); + const prompt = registry.prompt( + { sessionId, prompt: [{ type: 'text', text: 'continue' }] }, + { + ...promptContext([]), + ...(cancelledMethod === 'prompt' ? { signal: abort.signal } : {}), + }, + ); + try { + if (timing === 'during-wait') await new Promise((resolve) => setImmediate(resolve)); + abort.abort(); + if (cancelledMethod === 'load') await assert.rejects(loading); + else assert.deepEqual(await prompt, { stopReason: 'cancelled' }); + assert.equal(subscription.closeCalls, 0); + transcript.resolve([]); + if (cancelledMethod === 'load') + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + else await loading; + assert.equal(opens, 1); + assert.equal(admitted, cancelledMethod === 'load' ? 1 : 0); + assert.equal(subscription.closeCalls, 0); + } finally { + transcript.resolve([]); + await registry.dispose(); + await Promise.allSettled([loading, prompt]); + } + }); + } + } test('aborting a prompt signal closes its pending initial transcript hydration', async () => { const sessionId = 'session-aborted-hydration'; const transcript = deferred(); @@ -841,7 +3711,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => sessionId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); let finished = false; const prompt = registry .prompt( @@ -903,7 +3773,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turnIds.shift()!, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const cancelled = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel this one' }] }, { ...promptContext([]), signal: abort.signal }, @@ -1020,7 +3890,7 @@ describe('ACP Session registry', () => { }, requestPermission: async () => assert.fail('Unexpected permission request'), }; - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const cancelled = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel A' }] }, { ...promptContext([]), signal: abortA.signal, interactions }, @@ -1141,7 +4011,7 @@ describe('ACP Session registry', () => { }; }, }; - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel during interaction' }] }, { ...promptContext([]), signal: abort.signal, interactions }, @@ -1256,7 +4126,7 @@ describe('ACP Session registry', () => { }; }, }; - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel while waiting' }] }, { ...promptContext([]), signal: abort.signal, interactions }, @@ -1301,7 +4171,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn-never-admitted', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'cancel me' }] }, promptContext([]), @@ -1351,7 +4221,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, promptContext([]), @@ -1406,7 +4276,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, promptContext([]), @@ -1462,7 +4332,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn-pending-query', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, promptContext([]), @@ -1552,7 +4422,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); let outcome: PromiseSettledResult | undefined; const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, @@ -1635,7 +4505,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => sessionId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext([]), @@ -1696,7 +4566,7 @@ describe('ACP Session registry', () => { const registry = new AcpSessionRegistry({ connect: async () => fakeConnection({ - request: async (operation, input) => { + request: async (operation, input, timeoutMs) => { if (operation === 'session.create') return catalogSession(sessionId); if (operation === 'turn.start') { subscription.setRoot(turn); @@ -1709,6 +4579,7 @@ describe('ACP Session registry', () => { }; } if (operation === 'turn.stop') { + assert.equal(timeoutMs, 30_000); stopInputs.push(input); throw stopFailure; } @@ -1719,7 +4590,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => turn.turnId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, promptContext([]), @@ -1786,7 +4657,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); let promptSettled = false; const prompt = registry .prompt({ sessionId, prompt: [{ type: 'text', text: 'run' }] }, promptContext([])) @@ -1813,6 +4684,124 @@ describe('ACP Session registry', () => { assert.equal(connectionClosed, true); }); + test('close and dispose retain an adopted Turn Stop after terminal observation', async () => { + const sessionId = 'adopted-close-stop'; + const turnId = 'external-turn'; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, turnId) }), + ); + const stop = deferred(); + let stopping = false; + let connectionClosed = false; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.stop') { + stopping = true; + subscription.setRoot(completedTurn(sessionId, turnId)); + return stop.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + close: async () => { + connectionClosed = true; + }, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + signal: new AbortController().signal, + notify: async () => { + throw new Error('Client notification delivery failed'); + }, + }, + ); + subscription.appendText(turnId, `run-${turnId}`, 'output', true); + await waitFor(() => stopping); + await new Promise((resolve) => setImmediate(resolve)); + let closeSettled = false; + const closing = registry.close({ sessionId }).then(() => { + closeSettled = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closeSettled, false, 'close must wait for the existing Stop response'); + const disposal = registry.dispose(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(connectionClosed, false, 'Stop response must settle before transport close'); + void disposal.catch(() => undefined); + } finally { + stop.resolve({}); + await closing; + await registry.dispose(); + } + assert.equal(connectionClosed, true); + }); + + test('an adopted Turn Stop failure settles before close releases the connection', async () => { + const sessionId = 'adopted-stop-failure'; + const turnId = 'external-turn'; + const subscription = new FakeSubscription( + continuitySnapshot(sessionId, { rootTurn: runningTurn(sessionId, turnId) }), + ); + const stop = deferred(); + const stopFailure = new Error('Host connection lost during Stop'); + let stopping = false; + let connectionClosed = false; + const registry = new AcpSessionRegistry({ + newSessionId: () => sessionId, + connect: async () => + fakeConnection({ + request: async (operation) => { + if (operation === 'session.create') return catalogSession(sessionId); + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(sessionId) }; + if (operation === 'turn.stop') { + stopping = true; + subscription.setRoot(completedTurn(sessionId, turnId)); + return stop.promise; + } + throw new Error(`Unexpected operation ${operation}`); + }, + openSessionSubscriptionOnce: async () => subscription, + close: async () => { + connectionClosed = true; + }, + }), + }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); + await registry.resume( + { sessionId, cwd: TEST_CWD }, + { + signal: new AbortController().signal, + notify: async () => { + throw new Error('Client notification delivery failed'); + }, + }, + ); + subscription.appendText(turnId, `run-${turnId}`, 'output', true); + await waitFor(() => stopping); + const closing = registry.close({ sessionId }); + void closing.catch(() => undefined); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(connectionClosed, false); + } finally { + stop.reject(stopFailure); + await assert.rejects(closing, (error) => error === stopFailure); + await registry.dispose(); + } + assert.equal(connectionClosed, true); + }); + 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)); @@ -1857,7 +4846,7 @@ describe('ACP Session registry', () => { return () => ids.shift()!; })(), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( registry.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] }, promptContext([])), @@ -1910,7 +4899,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'local', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext([]), @@ -1973,7 +4962,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'local', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); try { await registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'attach' }] }, @@ -2045,7 +5034,7 @@ describe('ACP Session registry', () => { if (reads === 1) return read.promise; return { kind: 'session', - session: catalogSession(sessionId, '/workspace', { + session: catalogSession(sessionId, TEST_CWD, { revision: 3, permissionMode: 'bypass', }), @@ -2072,7 +5061,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext(notifications), @@ -2134,7 +5123,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext(notifications), @@ -2144,7 +5133,7 @@ describe('ACP Session registry', () => { await registry.close({ sessionId }); read.resolve({ kind: 'session', - session: catalogSession(sessionId, '/workspace', { revision: 2, permissionMode: 'bypass' }), + session: catalogSession(sessionId, TEST_CWD, { revision: 2, permissionMode: 'bypass' }), }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(notifications, []); @@ -2188,7 +5177,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext(notifications), @@ -2206,7 +5195,7 @@ describe('ACP Session registry', () => { } finally { read.resolve({ kind: 'session', - session: catalogSession(sessionId, '/workspace', { revision: 2 }), + session: catalogSession(sessionId, TEST_CWD, { revision: 2 }), }); await closing; await registry.dispose(); @@ -2233,7 +5222,7 @@ describe('ACP Session registry', () => { newSessionId: () => sessionId, newTurnId: () => 'turn', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'hello' }] }, promptContext([]), @@ -2259,7 +5248,7 @@ describe('ACP Session registry', () => { test('returns projected configuration and owns only a representable successful create', async () => { const requests: Array<{ operation: string; input: unknown }> = []; let subscriptionOpens = 0; - const created = catalogSession('session-configured', '/workspace', { + const created = catalogSession('session-configured', TEST_CWD, { thinkingLevel: 'high', permissionMode: 'explore', collaborationMode: 'plan', @@ -2285,7 +5274,7 @@ describe('ACP Session registry', () => { newSessionId: () => 'session-configured', }); - const response = await registry.create({ cwd: '/workspace', mcpServers: [] }); + const response = await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.deepEqual(response, { sessionId: 'session-configured', @@ -2304,7 +5293,7 @@ describe('ACP Session registry', () => { operation: 'session.create', input: { sessionId: 'session-configured', - workspace: { kind: 'host_path', path: '/workspace' }, + workspace: { kind: 'host_path', path: TEST_CWD }, modelTarget: { kind: 'default' }, }, }, @@ -2326,7 +5315,7 @@ describe('ACP Session registry', () => { newSessionId: () => 'session-no-thinking', }); - const response = await registry.create({ cwd: '/workspace', mcpServers: [] }); + const response = await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.deepEqual( response.configOptions?.map(({ id }) => id), @@ -2396,7 +5385,7 @@ describe('ACP Session registry', () => { }); if (!(createOutcome instanceof Error)) { - assert.deepEqual(await registry.create({ cwd: '/workspace', mcpServers: [] }), { + assert.deepEqual(await registry.create({ cwd: TEST_CWD, mcpServers: [] }), { sessionId, }); await registry.close({ sessionId }); @@ -2404,7 +5393,7 @@ describe('ACP Session registry', () => { await registry.dispose(); continue; } - await assert.rejects(registry.create({ cwd: '/workspace', mcpServers: [] })); + await assert.rejects(registry.create({ cwd: TEST_CWD, mcpServers: [] })); await assertInvalidParams( registry.setConfigOption({ sessionId, @@ -2434,7 +5423,7 @@ describe('ACP Session registry', () => { connect: async () => connection, newSessionId: () => 'created', }); - const creation = registry.create({ cwd: '/workspace', mcpServers: [] }); + const creation = registry.create({ cwd: TEST_CWD, mcpServers: [] }); await waitFor(() => projecting); await assertInvalidParams( registry.setConfigOption({ @@ -2473,7 +5462,7 @@ describe('ACP Session registry', () => { ); assert.equal(requests, 0); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.equal(requests, 1); for (const [request, data] of [ [ @@ -2504,11 +5493,11 @@ describe('ACP Session registry', () => { }); test('updates one configuration field with the latest revision and returns committed options', async () => { - const current = catalogSession('session-cas', '/workspace', { + const current = catalogSession('session-cas', TEST_CWD, { revision: 7, thinkingLevel: 'minimal', }); - const committed = catalogSession('session-cas', '/workspace', { + const committed = catalogSession('session-cas', TEST_CWD, { revision: 8, permissionMode: 'bypass', thinkingLevel: 'high', @@ -2528,7 +5517,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-cas', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const response = await registry.setConfigOption({ sessionId: 'session-cas', @@ -2570,7 +5559,7 @@ describe('ACP Session registry', () => { reads += 1; return { kind: 'session', - session: catalogSession('session-retry', '/workspace', { + session: catalogSession('session-retry', TEST_CWD, { revision: reads, collaborationMode: reads === 1 ? 'agent' : 'plan', }), @@ -2581,7 +5570,7 @@ describe('ACP Session registry', () => { ? { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 } : { kind: 'committed', - session: catalogSession('session-retry', '/workspace', { + session: catalogSession('session-retry', TEST_CWD, { revision: 3, permissionMode: 'bypass', collaborationMode: 'plan', @@ -2591,7 +5580,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-retry', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.setConfigOption({ sessionId: 'session-retry', @@ -2639,7 +5628,7 @@ describe('ACP Session registry', () => { } return { kind: 'session', - session: catalogSession('session-converge', '/workspace', { + session: catalogSession('session-converge', TEST_CWD, { revision: 2, permissionMode: 'bypass', }), @@ -2649,7 +5638,7 @@ describe('ACP Session registry', () => { if ('permissionMode' in patch) { return { kind: 'committed', - session: catalogSession('session-converge', '/workspace', { + session: catalogSession('session-converge', TEST_CWD, { revision: 2, permissionMode: 'bypass', }), @@ -2660,7 +5649,7 @@ describe('ACP Session registry', () => { } return { kind: 'committed', - session: catalogSession('session-converge', '/workspace', { + session: catalogSession('session-converge', TEST_CWD, { revision: 3, permissionMode: 'bypass', collaborationMode: 'plan', @@ -2670,7 +5659,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-converge', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const [permission, collaboration] = await Promise.all([ registry.setConfigOption({ @@ -2720,7 +5709,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-conflicts', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( registry.setConfigOption({ @@ -2815,7 +5804,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => sessionId, }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( registry.setConfigOption({ @@ -2907,7 +5896,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-errors', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( registry.setConfigOption({ @@ -2944,7 +5933,7 @@ describe('ACP Session registry', () => { updates += 1; return { kind: 'committed', - session: catalogSession('session-closing', '/workspace', { revision: 2 }), + session: catalogSession('session-closing', TEST_CWD, { revision: 2 }), }; }, close: async () => { @@ -2953,7 +5942,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-closing', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const update = registry.setConfigOption({ sessionId: 'session-closing', configId: 'permission_mode', @@ -3020,7 +6009,7 @@ describe('ACP Session registry', () => { }), newSessionId: () => 'session-conflict-closing', }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const update = registry.setConfigOption({ sessionId: 'session-conflict-closing', configId: 'permission_mode', @@ -3063,14 +6052,14 @@ describe('ACP Session registry', () => { [ 'mcpServers', { - cwd: '/workspace', + cwd: TEST_CWD, mcpServers: [{ name: 'server', command: 'server', args: [], env: [] }], }, ], [ 'additionalDirectories', { - cwd: '/workspace', + cwd: TEST_CWD, mcpServers: [], additionalDirectories: ['/other'], }, @@ -3112,20 +6101,17 @@ describe('ACP Session registry', () => { newSessionId: () => `session-${hostCode}`, }); - await assert.rejects( - registry.create({ cwd: '/workspace', mcpServers: [] }), - (error: unknown) => { - assert.ok(error instanceof RequestError); - assert.equal(error.code, acpCode); - assert.deepEqual(error.data, { - source: 'runtime_host', - operation: 'session.create', - code: hostCode, - sessionId: `session-${hostCode}`, - }); - return true; - }, - ); + await assert.rejects(registry.create({ cwd: TEST_CWD, mcpServers: [] }), (error: unknown) => { + assert.ok(error instanceof RequestError); + assert.equal(error.code, acpCode); + assert.deepEqual(error.data, { + source: 'runtime_host', + operation: 'session.create', + code: hostCode, + sessionId: `session-${hostCode}`, + }); + return true; + }); await registry.dispose(); } }); @@ -3425,7 +6411,7 @@ describe('ACP Session registry', () => { function fakeConnection( overrides: { - request?: (operation: string, input: unknown) => Promise; + request?: (operation: string, input: unknown, timeoutMs?: number) => Promise; close?: () => Promise; thinkingLevels?: readonly ThinkingLevel[]; openSessionSubscription?: AcpSessionRegistryConnection['openSessionSubscription']; @@ -3434,10 +6420,22 @@ function fakeConnection( ): AcpSessionRegistryConnection { return { reconnecting: true, - request: async (operation: string, input: unknown) => + replaceClientCapabilities: async () => ({ registrationId: 'registration-1', revision: 1 }), + unregisterClientCapabilities: async () => ({ registrationId: 'registration-1', revision: 2 }), + subscribeConnectionAvailability: ( + listener: (availability: { + kind: 'connected'; + hostEpoch: string; + connectionId: string; + }) => void, + ) => { + listener({ kind: 'connected', hostEpoch: 'host-1', connectionId: 'connection-1' }); + return () => undefined; + }, + request: async (operation: string, input: unknown, timeoutMs?: number) => operation === 'connection.catalog.query' ? connectionCatalogPage(overrides.thinkingLevels ?? THINKING_LEVELS) - : (overrides.request?.(operation, input) ?? {}), + : (overrides.request?.(operation, input, timeoutMs) ?? {}), openSessionSubscription: overrides.openSessionSubscription ?? (async () => { @@ -3463,7 +6461,7 @@ function promptContext(notifications: SessionNotification[]) { class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator { readonly hostEpoch = 'host-1'; readonly activeAssistantStreams = []; - readonly transcriptBootstrap = null; + transcriptBootstrap: SessionTranscriptBootstrap | null = null; #transcriptWatermark: number | null = null; readonly #frames: SubscriptionFrame[] = []; readonly #waiters: Array<{ @@ -3481,6 +6479,10 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< closeCalls = 0; nextCalls = 0; transcriptPageReads = 0; + onTranscriptPageRead?: () => void; + onTranscriptRead?: () => void; + transcriptPageSize = Number.POSITIVE_INFINITY; + transcriptEmptyFirstPage = false; transcriptPageGate?: Promise; #liveTranscript: StoredMessage[] = []; readonly #decodedPages = new WeakMap(); @@ -3492,6 +6494,22 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< private readonly onClose: () => void = () => undefined, ) {} + seedBootstrap(messages: StoredMessage[]): void { + this.#liveTranscript = messages; + this.transcriptBootstrap = { + durable: { + kind: 'page', + sessionId: this.snapshot.session.sessionId, + direction: 'older', + throughSequence: messages.length * 8 + 7, + rawBytes: 0, + fragments: [], + nextCursor: null, + endsAtTurnBoundary: true, + }, + }; + } + get transcriptWatermark(): number | null { return this.#transcriptWatermark; } @@ -3643,6 +6661,7 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< } async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + this.onTranscriptRead?.(); return (await this.transcript).map(decodeMessage); } @@ -3667,7 +6686,24 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< input: Omit, ): Promise { this.transcriptPageReads += 1; + this.onTranscriptPageRead?.(); await this.transcriptPageGate; + if (this.transcriptEmptyFirstPage && input.cursor === null) { + const empty: SessionTranscriptPage = { + kind: 'page', + sessionId: this.snapshot.session.sessionId, + direction: input.direction, + throughSequence: input.throughSequence, + rawBytes: 0, + fragments: [], + nextCursor: '0', + endsAtTurnBoundary: false, + }; + this.#decodedPages.set(empty, []); + return empty; + } + const start = input.cursor === null ? 0 : Number(input.cursor); + const end = Math.min(start + this.transcriptPageSize, this.#liveTranscript.length); const page: SessionTranscriptPage = { kind: 'page', sessionId: this.snapshot.session.sessionId, @@ -3675,10 +6711,10 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< throughSequence: input.throughSequence, rawBytes: 0, fragments: [], - nextCursor: null, + nextCursor: end < this.#liveTranscript.length ? String(end) : null, endsAtTurnBoundary: true, }; - this.#decodedPages.set(page, this.#liveTranscript); + this.#decodedPages.set(page, this.#liveTranscript.slice(start, end)); return page; } @@ -3775,7 +6811,7 @@ function connectionCatalogPage(thinkingLevels: readonly ThinkingLevel[]) { function catalogSession( id: string, - cwd = '/workspace', + cwd = TEST_CWD, overrides: Partial = {}, ): SessionCatalogProjection { return { @@ -3842,7 +6878,8 @@ async function assertInvalidParams( } async function waitFor(predicate: () => boolean): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { + const deadline = performance.now() + 5_000; + while (performance.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setImmediate(resolve)); } diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index 109b064b10..538b8daf0b 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -656,7 +656,10 @@ describe('Maka ACP stdio server', () => { id: 1, result: { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, }, diff --git a/packages/cli/src/__tests__/acp-tools-child-process.test.ts b/packages/cli/src/__tests__/acp-tools-child-process.test.ts index 75c1c8068f..1d53ebcd1f 100644 --- a/packages/cli/src/__tests__/acp-tools-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-tools-child-process.test.ts @@ -25,12 +25,16 @@ import { methods, RequestError, type CreateElicitationRequest, + type NewSessionRequest, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk'; import { mcpProxyToolName } from '@maka/runtime/mcp-tools'; -import { withAcpChildProcessHarness } from './acp-child-process-harness.js'; +import { + withAcpChildProcessHarness, + type ConfigureAcpClient, +} from './acp-child-process-harness.js'; const MODEL_ID = 'acp-tools-fixture'; const CAPACITY_FILLER = 'ACP_CAPACITY_FILLER'; @@ -43,6 +47,106 @@ const formFixture = fileURLToPath( ); describe('ACP tools through the official SDK, child process and Runtime Host', () => { + test('rejects conflicting live-client MCP takeover and applies it after the original owner closes', { + timeout: 60_000, + }, async () => { + const model = await startToolModel( + ['ACP_FIRST_OWNER', 'ACP_EQUIVALENT_OWNER', 'ACP_NEW_OWNER'].map((marker) => ({ + marker, + tool: 'environment', + args: { names: ['ACP_SESSION_FINGERPRINT'] }, + })), + ); + const configure: ConfigureAcpClient = (app) => + app.onRequest(methods.client.session.requestPermission, ({ params }) => ({ + outcome: { outcome: 'selected', optionId: params.options[0]!.optionId }, + })); + const servers = (value: string): NewSessionRequest['mcpServers'] => [ + { + name: 'fixture', + command: process.execPath, + args: [environmentFixture, '--environment'], + env: [{ name: 'ACP_SESSION_SENTINEL', value }], + }, + ]; + const alpha = '8ed3f6ad685b959ead7022518e1af76cd816f8e8ec7ccdda1ed4018e8f2223f8'; + const beta = 'f44e64e75f3948e9f73f8dfa94721c4ce8cbb4f265c4790c702b2d41cfbf2753'; + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context: first }) => { + await first.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await first.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: servers('alpha'), + }); + const sessionId = created.sessionId; + await first.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'ACP_FIRST_OWNER' }], + }); + const sibling = await harness.spawnSibling(); + try { + await sibling.withClient(async ({ context: second }) => { + await second.request(methods.agent.initialize, { protocolVersion: 1 }); + for (const method of [methods.agent.session.load, methods.agent.session.resume]) { + for (const mcpServers of [servers('beta'), []]) { + await assert.rejects( + second.request(method, { sessionId, cwd: harness.workspaceRoot, mcpServers }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string })?.code === 'session_binding_conflict', + ); + } + } + // Equivalent configurations remain usable for live interaction recovery. + await second.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: servers('alpha'), + }); + await second.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'ACP_EQUIVALENT_OWNER' }], + }); + assert.ok( + model.results('ACP_EQUIVALENT_OWNER').at(-1)?.includes(alpha), + model.diagnostics(), + ); + await first.request(methods.agent.session.close, { sessionId }); + await second.request(methods.agent.session.load, { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: servers('beta'), + }); + assert.deepEqual( + await second.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'ACP_NEW_OWNER' }], + }), + { stopReason: 'end_turn' }, + ); + assert.ok( + model.results('ACP_NEW_OWNER').at(-1)?.includes(beta), + model.diagnostics(), + ); + }, configure); + } finally { + await sibling.close(); + } + }, configure); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); + test('ask authorizes the exact MCP Session scope and settles the authoritative result before end_turn', { timeout: 60_000, }, async () => { @@ -498,6 +602,118 @@ describe('ACP tools through the official SDK, child process and Runtime Host', ( await model.close(); } }); + + test('a second ACP process loads a Turn with a pending permission and answers it', { + timeout: 60_000, + }, async () => { + const marker = 'ACP_RESTORE_PERMISSION'; + const sentinel = 'restored-permission-result'; + const model = await startToolModel([{ marker, tool: 'echo', args: { value: sentinel } }]); + let permissionEntered!: () => void; + const permissionPending = new Promise((resolve) => { + permissionEntered = resolve; + }); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const created = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [ + { name: 'fixture', command: process.execPath, args: [legacyFixture], env: [] }, + ], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId: created.sessionId, + configId: 'permission_mode', + value: 'ask', + }); + const prompt = context.request(methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: marker }], + }); + await permissionPending; + const sibling = await harness.spawnSibling(); + try { + const permissions: RequestPermissionRequest[] = []; + const updates: SessionNotification[] = []; + let restoredPermissionEntered!: () => void; + const restoredPermissionPending = new Promise((resolve) => { + restoredPermissionEntered = resolve; + }); + await sibling.withClient( + async ({ context: restored }) => { + await restored.request(methods.agent.initialize, { protocolVersion: 1 }); + await restored.request(methods.agent.session.load, { + sessionId: created.sessionId, + cwd: harness.workspaceRoot, + mcpServers: [ + { + name: 'fixture', + command: process.execPath, + args: [legacyFixture], + env: [], + }, + ], + }); + await Promise.race([ + restoredPermissionPending, + new Promise((_resolve, reject) => { + setTimeout( + () => reject(new Error('Restored permission was not presented')), + 5_000, + ).unref(); + }), + ]); + assert.equal(permissions.length, 1, sibling.stdout); + assert.equal(permissions[0].sessionId, created.sessionId); + assert.deepEqual(await prompt, { stopReason: 'end_turn' }); + assertToolSettled(updates, created.sessionId, sentinel); + assert.ok(model.results(marker).some((result) => result.includes(sentinel))); + await restored.request(methods.agent.session.close, { + sessionId: created.sessionId, + }); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { + permissions.push(params); + restoredPermissionEntered(); + return { + outcome: { outcome: 'selected', optionId: params.options[0].optionId }, + }; + }), + ); + } finally { + await sibling.close(); + await context.notify(methods.agent.session.cancel, { + sessionId: created.sessionId, + }); + await prompt.catch(() => undefined); + } + }, + (app) => + app.onRequest(methods.client.session.requestPermission, () => { + permissionEntered(); + return new Promise(() => undefined); + }), + ); + }, + { + startRuntimeHost: true, + timeoutMs: 45_000, + model: { id: MODEL_ID, thinkingLevels: [], baseUrl: model.baseUrl }, + }, + ); + } finally { + await model.close(); + } + }); }); function assertToolSettled( diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 5b5cc521b5..90305674ef 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -38,7 +38,10 @@ 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. +content and prevents that prompt from starting a Turn. Loaded history renders each +stored attachment as a text placeholder with its name, media type and byte count, +including attachment-only user messages. The chunk preserves the canonical +references in `_meta["_maka/attachments"]`; it does not load attachment bytes. 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 @@ -58,18 +61,89 @@ or reconnection without waiting for the Host to become available. | Sandbox boundary and client capability approval | Standard `session/request_permission`. The `allow_always` choice explicitly grants only the displayed scope for this Session; `reject_once` denies it. Permission cancellation cancels the Turn. | | MCP | Session-owned stdio servers supplied in `session/new.mcpServers`; discovered tools and MCP form continuation use the existing MCP manager and Host capability path. | | Tool `permission` | Standard `session/request_permission`. One-shot allow/deny choices are preserved; eligible tool permissions also expose an explicit allow-for-this-Turn choice. Permission cancellation cancels the Turn. | -| Load/resume, replacing all MCP configuration, HTTP/SSE/OAuth | Deferred. | +| Load/resume | `session/load` replays durable user, assistant, thinking and tool rows before returning; `session/resume` attaches without replay. Both return current configuration and leave the Session attached for prompt. Neither restarts an interrupted Turn. | +| Explicit interrupted Turn resume | `_maka/turn/resume` queries the Host safety plan and starts only a ready plan. A required MCP tool absent from the current Session binding leaves the plan parked. A parked plan is returned unchanged. A lost dispatched start returns `outcome_unknown` with the exact `turnId`; the adapter never retries that command. | +| Copy source discovery | `_maka/session/copy-source/query` returns a bounded Host Turn page and `expectedSourceRevision` for an owned Session, so branch/revision parameters can be obtained entirely through ACP. | +| Branch and revision | `_maka/session/branch/create`, `_maka/session/revision/create`, and `_maka/session/revision/abandon` map to the corresponding Host commands. The source must be owned by this ACP connection. A committed target becomes immediately usable; `retained` keeps its ownership and `abandoned` releases local resources. | +| Replacing all MCP configuration | Every load/resume applies its complete stdio list through the existing Session MCP manager and publication. An omitted `session/resume.mcpServers` means an empty list. Equivalent normalized configuration reuses the process; changing or clearing it republishes the Session scope. An attached Session rejects a different configuration while the Host reports an active Turn; retry after that Turn settles. | +| HTTP/SSE/OAuth MCP | Deferred. | The adapter saves the capabilities supplied during `initialize`. Missing form -capability, unsupported client methods, or invalid answers explicitly fail the -affected prompt and stop its exact Host Turn. Host owns interaction closure and +capability, unsupported client methods, or invalid answers explicitly fail a +locally admitted prompt and stop its exact Host Turn. For an attached Turn, +presentation failure leaves the Host interaction pending for a capable client. +Host owns interaction closure and the canonical answer, including externally answered or replayed requests. Client requests are fenced by Session, interaction, Turn/run, and attachment lifetime; cancel and EOF release local waits even when the client never responds. After a failed Stop, a cancelled Turn stays fenced even if its ACP prompt has returned; only an authoritative terminal observation or attachment closure -releases the fence. An idle attachment does not present another client's Turn -interactions through this ACP connection. +releases the fence. An idle attachment created only by prompt does not present +another client's Turn interactions through this ACP connection. + +After load/resume, the attachment observes an already running root Turn and its +pending interactions through the same Session channel and Turn mapper used by +prompt. A new Turn observed on that loaded attachment also uses this path. +Clients that advertise `initialize.clientCapabilities._meta["_maka/turnStatus"]: +true` receive `_maka/turn/status` notifications for non-prompt Turns after their +standard output has settled. Each notification names `sessionId`, `turnId`, +`runId`, and a `completed`, `failed`, `cancelled`, or `observation_failed` status. +Terminal snapshots are retained with their exact Turn's event queue until +consumption, including when a successor finishes before an output barrier releases +its observation. +Ordinary ACP clients can load/resume and prompt without this extension. + +The working directory in load/resume must resolve to the Session's Host cwd; +additional directories are not supported. Missing and archived Sessions are +rejected. A repeated successful load replays history again on the same retained +attachment. Historical pages are read through that attachment's subscription, +so they do not consume a second Host subscription slot. + +The Runtime Host composes the model prompt from its current policy, including +workspace instructions when enabled. ACP clients provide user prompt content; +`session/load` and `session/resume` do not replace the Host's system prompt or +workspace instruction policy. The Host records model usage and context window +facts in its runtime data, but this ACP v1 adapter does not emit a separate +usage or context-window notification. An ACP client's own usage display should +not infer those numbers from replayed text chunks. + +## Branch/revision source discovery + +After creating or loading a Session, call `_maka/session/copy-source/query` with: + +```json +{ + "sessionId": "source-session-id", + "throughSequence": null, + "position": 0, + "maxContributions": 64 +} +``` + +The response contains `sessionId`, `expectedSourceRevision`, `throughSequence`, +`contributions`, and `nextPosition`. Each contribution contains a `turnId`, +`firstSequence`, a bounded `userPromptPreview`, and `latestState` when available. +To continue, carry the returned `throughSequence` and use `nextPosition` as the +next request's `position`; `null` ends paging. The Host limits each page to 128 +contributions. A Turn can contribute to more than one page: merge by `turnId`, +retaining the earliest `firstSequence` and the greatest `latestState.sequence`. +Select a settled Turn after reading its state, rather than guessing a boundary +from a text message ID. No additional subscription is opened by this query. + +Pass the selected `turnId` as `sourceTurnId`, the query's `sessionId` as +`sourceSessionId`, and `expectedSourceRevision` unchanged to branch/revision +creation, together with a new `targetSessionId`. The Host still validates the +boundary and revision. If the source changes, `source_revision_conflict` requires +an explicit refresh and a new client decision; the adapter does not retry copy +commands. An empty source can be branched only with the Host's explicit +`intent: "side_conversation"`; it cannot be used for revision creation. + +Cancelling a load/resume or explicit Turn-resume request while its initial +subscription opens or hydrates releases that request's wait immediately. If no +other request is awaiting the same attachment, initialization is aborted and a +late subscription is closed. A concurrent prompt or restore retains its own +wait and prepared Session resources. This does not stop an already attached +Host Turn; use `session/cancel` for that operation. ## Tool output and completion @@ -119,6 +193,16 @@ adapter retains the connection-local reservation and MCP resources. The client c continue with that ID or close it; creation is never silently retried. Different Sessions can use the same server/tool names with different processes. +For the same Session, ACP publishes a SHA-256 identity of the complete normalized +MCP configuration. Host atomically rejects a second provider with a different +identity, including an empty configuration, before changing registration state. +Load/resume reports `error.data.code: session_binding_conflict` for incompatible +configurations; restore with the same configuration or close an active conflicting +attachment. Equivalent configurations remain attachable, including live permission +restoration. A disconnected frozen Session binding can be reclaimed by another +local-owner connection of the same authenticated principal with the same complete configuration +and contract set. This optional wire field and its typed conflict use +Host compatibility epoch 185. Registration replacement, unregister, disconnection and invocation routing respect the target Session and owning connection. A default registration and its target Session registration may not expose the same tool identity. Another Session cannot diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 9b2f12053b..d0a096dba6 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -17,6 +17,409 @@ under the License. --> +# ACP validation record + +## PR6 adjacent restore and teardown review fixes — September 24, 2026 + +Starting from PR #5621 head `d05fbb25f`, formal registry regressions were added +before the implementation changed. Four assertions failed on that head: +`session/load` and `session/resume` each lost a live prompt's interaction client +after a cwd mismatch, a repeated resume invoked successor Turn output before a +queued chunk from the prior Turn, and close followed by dispose released the +Host connection before an adopted Turn's Stop response. The single-resume +ordering control passed before it was extended to assert successor pending +interaction delivery. The final nine focused tests failed against the +`d05fbb25f` production modules and passed after repair. Additional tests cover +cancelled history replay after client replacement, two overlapping failed +restores, an older explicit resume failing after a newer restore reuses the same +context object, a successor's pending interaction behind the output barrier, +and an adopted Turn Stop that fails after its Host terminal fact. + +Every observation's in-flight Stop now belongs to the cancellation and close +wait, including non-admitted Turns. A terminal snapshot cannot make close +discard a pending Stop response; disposal keeps the connection alive until +that response settles, including rejection. Restore rollback tracks the actual +interaction client it replaced and invalidates failed context/client leases, +so later operations cannot restore a stale client. A failed load before MCP +reconfiguration no longer starts a compensating reconfiguration. Repeated +load/resume defers a successor already queued behind the channel's consumer +barrier. When the channel admits that successor, the shared adoption path also +replays its pending interactions through the existing deduplicating owner. + +Validation on macOS and Node 24.19.0; repository scripts were rerun with the +required npm 11.19.0: + +| Check | Result | +| --- | --- | +| Formal ACP registry suite | 136 passed, 0 failed. The final nine focused tests were 9 failed on baseline and 9 passed after repair. | +| Full CLI dist suite, concurrency 4 | 1245 passed, 3 skipped, 0 failed; includes official SDK and real Host child-process tests. | +| Full Runtime Host dist suite, concurrency 2 | 2093 passed, 12 skipped, 0 failed after building both bundled ACP plugins and applying repository dependency patches. | +| Root build and workspace typecheck | Passed after building workspace dependencies. | +| Root lint/format, ASF headers, CLI notices, `git diff --check` | Passed. | + +The first Host run preceded the bundled plugin builds and dependency patches: +two tests failed for those missing setup steps, and one execution recovery +test timed out waiting for a terminal fact. The recovery case passed in the +serial three-file rerun; after the remaining dependency patch was applied, its +affected profile test and the complete Host suite passed. Desktop Electron E2E +and Zed were not rerun for this follow-up; prior evidence below remains tied to +its recorded head. No Host protocol or compatibility epoch changed here. + +## PR6 retained attachment and Stop review fixes — September 24, 2026 + +Starting from PR head `c50987a6b`, two isolated reproductions failed when +`session/load` or `session/resume` reused an attachment that had observed another +client's Turn before restore. A separate reproduction failed when an explicit +`turn.resume.start` observer requested Stop after output delivery failed and +dispose closed the Host connection before the Stop response. These reproductions +were kept outside the source tree; the repaired behavior is covered by formal +registry tests. + +Restore now adopts the current nonterminal root on the retained channel and +replays its pending interactions through the same interaction owner. The owner +uses the restoring client's capabilities and callbacks, while the existing +history/live barrier orders replay ahead of live output. Output-failure Stop is +retained on the Turn observation and awaited before observation release and Host +connection teardown. Admission result transitions used by snapshot and query +callers are owned by the admitted observation. + +Validation on macOS and Node 24.19.0 after repair: + +| Check | Result | +| --- | --- | +| Formal ACP registry suite | 127 passed, 0 failed, including retained attachment load/resume with pending interaction and live output, plus close/dispose during a deferred Stop. | +| Full CLI dist suite, concurrency 4 | 1236 passed, 3 skipped, 0 failed; includes official SDK child-process tests against a real Host. | +| CLI build/typecheck, scoped lint/format, ASF headers, `git diff --check` | Passed. | + +Runtime Host, Desktop, Electron and Zed suites were not rerun for this follow-up; +their earlier results below remain tied to their recorded heads. + +## PR6 CI and remaining review fixes — September 24, 2026 + +This follow-up starts from `876dffea3` and resolves the three remaining P2 +findings plus duplicated admission transitions. Restore-cancellation tests now +wait for subscription-open, transcript-read and close lifecycle events. Other +asynchronous assertions use a real elapsed-time deadline and `setImmediate`, +which continues to work in tests that mock `setTimeout`; a fixed count of event +loop turns did not allow filesystem work to finish reliably on CI. + +Host now compares the complete normalized Session MCP configuration identity +atomically before accepting another scoped provider. A differing or empty list +fails with `session_binding_conflict` while the other provider remains attached. +Equivalent configurations still support live interaction restoration. After the +original owner closes its Session attachment, the new configuration becomes +callable. A frozen disconnected provider can reconnect using its authenticated +identity; another client cannot silently take over that binding. The optional +configuration identity and typed conflict advance the Host compatibility epoch +to 185; both compatible-change declarations were re-pinned and reviewed. + +The existing Session channel event queue retains authoritative terminal facts by +Turn and run until consumption, including when a successor has already completed +behind the initial output barrier. No separate terminal-history map is introduced. Completed, +failed and cancelled status is delivered after the exact Turn's output. History +replay preserves attachment-only user rows, displaying attachment name, media +type and size, with canonical references in `_meta["_maka/attachments"]`. Prompt +and explicit resume now share the admitted observation's dispatch, settlement, +failure and waiter transitions. + +Four focused regressions were run against an isolated copy of `876dffea3`'s +production modules: two attachment-history assertions, the initial-readiness +terminal case, and the official SDK two-live-client MCP conflict case. All four +fail on that baseline and pass with the fix. The repaired suite additionally +covers all three terminal outcomes with and without a successor Turn, both +load/resume with changed/empty MCP lists, equivalent configuration restoration, +replacement after close, frozen-provider reconnect, and real Host replay of a +resource-link-only prompt. A follow-up regression with two Turns completing during +readiness fails on `4efbae5c5` before this queue-based retention fix and passes +after it; all three first-Turn terminal outcomes cover this successor case. + +Validation on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Root build and typecheck | Passed. | +| Full CLI dist suite, concurrency 4 | 1232 passed, 3 skipped, 0 failed. | +| Full Runtime Host dist suite, concurrency 2 | 2093 passed, 12 skipped, 0 failed. | +| Root lint/format, Desktop/UI knip, ASF headers, CLI notices, Windows inventory and `git diff --check` | Passed. | +| Protocol epoch guard and its tests | Passed: epoch 185; 17 guard tests passed. | + +The four baseline regression failures are expected and recorded separately from +the repaired full-suite results. +Desktop Electron E2E and Zed were not rerun; their earlier evidence remains +version-bound as documented below. + +## PR6 review fixes — September 24, 2026 + +These changes start from PR head `561b5a304`. Branch/revision source discovery +now has a concrete `_maka/session/copy-source/query` route backed by the existing +bounded Host Turn query and Session revision. The official SDK child-process +flow obtains historical Turn IDs and revisions entirely over ACP, verifies a +stale revision conflict, then creates and prompts branch/revision targets and +checks abandon/retained outcomes. No internal Host connection supplies the copy +parameters. + +Initial attachment waits now honor request cancellation for load, resume, and +explicit Turn resume. The last consumer aborts initialization; a late subscription +is closed without affecting a replacement. Concurrent prompts retain the shared +attachment even before they reach its wait. Admission state lives in a typed +observation, indexed only once; close, abandon, rollback and failed attachments +share identity-checked resource detachment while retaining their distinct +execution and ownership rules. + +Eight focused regression tests were run against an isolated copy of the original +head's production modules: all eight failed. The six cancellation cases cover +subscription open and transcript hydration for all three restore methods; the +other two cover the absent ACP query and ownership/paging contract. They pass +with the repair. Four shared-consumer cases additionally cover cancelling load +or prompt both before and during the other consumer's attachment wait. The +earlier load-cancellation interleaving first failed during development and passes +after preserving pending admission consumers. + +Validation on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Root build and typecheck; final CLI rebuild and typecheck | Passed. | +| Final full CLI suite (`node --test --test-concurrency=4 'packages/cli/dist/**/*.test.js'`) | 1220 passed, 3 skipped, 0 failed. Includes the pure ACP copy workflow and all cancellation regressions. | +| Full Runtime Host dist suite | 2087 passed, 12 skipped, 3 failures waiting for the execution Host to become ready. CLI, Host and Desktop suites ran concurrently. No Host source was changed by this follow-up. | +| Serial rerun of the three affected Host test files | All 28 passed, including the three startup-timeout cases. Files: `execution-host-continuation`, `execution-host-message`, and `execution-host-queue`. | +| Full Desktop dist suite | 2841 passed, 0 failed. | +| Root lint/format, Desktop/UI knip, ASF headers, CLI notices, `git diff --check` | Passed. | +| Protocol epoch guard against `fb9df6c3d` and its tests | Passed; 17 tests passed. This follow-up does not change the Host wire protocol. | + +The first Host run's failures are retained rather than presenting its result as a +clean full-suite pass. Desktop Electron E2E and Zed were not rerun for this +follow-up; their earlier evidence and version boundaries remain below. + +## PR6 main integration and admission cleanup — September 24, 2026 + +Merged Apache `main` at `fb9df6c3d` into PR #5621. The only textual conflict was +the Runtime Host compatibility epoch: `main` had reached 183 and had assigned +179 to a different change. The merged protocol uses 184 for the ACP Session +capability replacement guard. The two compatible-change declarations introduced +by `main` were re-pinned to 184 after checking that their wire-neutral reasons +still hold. The prompt and restored-Turn admission paths now use one initializer +for their identical local state; no admission behavior was changed. + +Validation on macOS and Node 24.19.0 after the merge and initializer change: + +| Check | Result | +| --- | --- | +| `npm ci`, `npm run build`, `npm run typecheck`, `npm run lint`, `npm run format:check` | Passed. | +| Full CLI dist suite | 1208 passed, 3 skipped, 0 failed; includes official ACP SDK child-process tests with a real Runtime Host. | +| Full Runtime Host dist suite | 2090 passed, 12 skipped, 0 failed. | +| Full Desktop dist suite | 2841 passed, 0 failed. | +| Protocol epoch guard against `fb9df6c3d` and guard tests | Passed: 183 to 184; 17 tests passed. | +| Desktop/UI knip, ASF headers, CLI third-party notices, `git diff --check` | Passed. | +| Full Desktop Electron E2E | 34 passed, 0 failed. | + +An earlier full E2E run on an intermediate merge at `fef5b937a` passed 33/34: +Side Chat `page.screenshot({ fullPage: true })` timed out after its preceding +behavior assertions passed. The focused retry passed on that tree, and the +same focused test passed on the `fef5b937a` baseline. The later full run on +`fb9df6c3d` passed 34/34. The earlier Zed smoke below was on an older PR6 +head; no Zed result is claimed for this merged head. Current-head ACP +create/load/resume and interrupted-Turn coverage comes from the official SDK +tests against a real Host in the CLI suite. + +## PR6 final review follow-up — September 23, 2026 + +This follow-up starts at pushed PR #5621 head `813c9557d`. Two further +regressions were reproduced on that head before editing. When a dispatched +`turn.resume.start` lost its response and a subsequent `turn.query` returned +`not_found`, the rejected attempt left a phantom active prompt and blocked an +idle Session's MCP change. When output delivery for a completed attached Turn +was held while the next Turn started, the prior Turn's terminal status was +lost. The initial two formal regression tests failed before the repair and +pass after it; the terminal-status test was then extended to all three terminal +states. + +The registry now releases the rejected admission's observation and Turn queue +while preserving its Session, Turn, and source-run identity in the error. A +still-unknown admission remains observable. Subscription snapshots capture +terminal status on the matching Turn and run before a newer root replaces it; +status delivery waits for that Turn's output to finish. Discarded attachments +and disposed observations remain fenced from late callbacks. + +Validation on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Full CLI dist suite | 1205 passed, 3 skipped, 0 failed. Includes the rejected resume regression, all three terminal states under blocked output, and official SDK plus real Runtime Host child-process flows. | +| `npm run build`, workspace typecheck, lint, format, ASF headers and CLI notices | Passed. | +| Desktop/UI knip and `git diff --check` | Passed. | + +The independent terminal-status probe also passes. Its separate MCP probe +uses an incomplete fixture setup and stops at `mcp_not_ready`; the repository +regression uses the existing MCP fixture and verifies the resumed idle load. +The Runtime Host wire protocol is unchanged by this follow-up. + +## PR6 second review follow-up — September 23, 2026 + +This follow-up starts at pushed PR #5621 head `62eea00ef`. Three additional +regressions were reproduced on that exact commit before editing: a failed live +history load sent `turn.stop` for an existing Host Turn; closing during an MCP +replacement left the old stdio child alive; and cancellation after an accepted +replacement issued an unguarded rollback while a second client had started a +Turn. The three independent probes and their formal regression tests failed +before repair and pass after repair. + +Failed load now disposes only observations created for its new attachment +before closing the channel. The discarded attachment is fenced from late +callbacks, including those that could affect a replacement attachment. A +genuine interaction or output failure after successful adoption still stops +the exact Turn. MCP replacement retains both managers until Host publication +has a definite outcome. Close and authoritative retirement release both. A +known Host rejection restores the old manager without another Host write. If +the new provider was committed when cancellation arrives, it remains the +effective config and stays callable; a lost response retains both processes +until a later publication confirms which can be retired. The original Host +idle guard remains on every configuration replacement. + +Validation on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Full CLI dist suite | 1201 passed, 3 skipped, 0 failed. Includes the new load, MCP close/retire/unknown-outcome/isolation regressions and the official SDK plus real Host two-client cancellation test. | +| Independent pre-fix probes | All 3 failed at `62eea00ef`; all 3 passed after repair. | +| Full Runtime Host dist suite | 2054 passed, 12 skipped, 0 failed. | +| Full Runtime dist suite | 3516 passed, 14 skipped, 0 failed. | +| Full Desktop main dist suite | 2815 passed, 0 failed. | +| Full Eval dist suite | 114 passed, 1 skipped, 0 failed; 87 Python tests passed with 12 skipped. | +| Desktop E2E same-environment comparison | On `62eea00ef`, the Side Chat case failed at `page.screenshot({ fullPage: true })` after the preceding behavior assertions passed; both WorkHub cases passed. On the repaired tree, the same Side Chat capture timed out and both WorkHub cases passed. Earlier WorkHub failures were intermittent in the focused retry. | +| `npm run build`, workspace typecheck, lint, format, ASF headers and CLI notices | Passed. | +| Desktop/UI knip and protocol epoch guard | Passed; Host protocol is unchanged from `62eea00ef` and remains at epoch 179. | + +The Side Chat screenshot timeout occurs before that test's Desktop reconnect +portion, so this E2E case does not validate the remainder of its flow in either +tree. The test trace and failure location match across baseline and repair. + +Zed 1.20.2 was attempted with a disposable project and the final ACP build. +The existing Zed process displayed its project trust dialog, but the computer +UI controller returned `noWindowsAvailable` for the action that would +continue. No final-head Zed prompt/load result is claimed. The temporary +model and Host fixture were stopped and its files removed; the user's Zed +process was left running. + +## PR6 review repair — September 23, 2026 + +This follow-up starts at PR #5621 head `fd2e6a68` in a separate worktree. The +older PR6 results below describe that original head; the results in this +section describe the repaired tree. + +Four regressions were demonstrated before repair. Three added registry tests +failed on the original head: an owned but unattached Session leaked a newly +opened subscription after historical replay failed; unsupported restored +elicitation left the Host Turn waiting; and a cancelled restored question +submitted its late answer when Stop failed. An MCP test held the idle check, +admitted another client's Turn, then showed that replacement was still +accepted. After repair, those regressions pass. The cancellation suite also +checks restored permission selections and a newly arriving interaction ID. + +The adapter now fences an adopted Turn's interaction broker before Stop, using +the observed Session, Turn, and run identity. An adopted observation failure +stops that exact active Host Turn and reports `observation_failed` without +inventing an interaction answer. The restored interaction regressions cover +unsupported elicitation capability and method, invalid answers, and output +delivery failure. Load failure closes only the attachment it opened, +independently of prior Session ownership; retry succeeds. MCP config +changes stage a new manager while the old process remains callable, then use +an opt-in Host capability replacement that checks active and pending root +admission at its commit point. Normal dynamic tool-list publication remains +unchanged. This wire contract advances the Runtime Host compatibility epoch +from 178 to 179. Shared context construction and canonical JSON reuse remove +the two review-noted duplications. + +Validation after repair on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Full CLI dist suite | 1194 passed, 3 skipped, 0 failed; includes official SDK and real Host multi-client ACP child-process flows, prompt/load/resume, branch/revision, MCP, TUI and close/EOF tests. A second Host client starts a live Turn after the idle read and the Host rejects the staged MCP replacement. | +| Full Runtime Host dist suite | 2054 passed, 12 skipped, 0 failed. | +| Full Runtime dist suite | 3516 passed, 14 skipped, 0 failed. | +| Full Desktop main dist suite | 2815 passed, 0 failed. | +| Full Eval dist suite | 114 passed, 1 skipped, 0 failed; its 87 Python tests also passed. | +| `npm run lint`, `npm run format:check`, `npm run build`, `npm run typecheck` | Passed across the workspaces. | +| Desktop/UI knip, ASF headers, CLI notices, protocol epoch guard, `git diff --check` | Passed. | + +The first repository-wide `npm test` ran workspaces concurrently and had +unrelated fixed-deadline failures in Runtime, Desktop, and Eval. Every failing +test passed when rerun in isolation; their complete workspace suites then +passed serially as recorded above. The existing Zed third-party smoke below +was performed on the original PR6 head. A new isolated Zed 1.20.2 smoke was +attempted, but UI automation could not operate its project window, so no +post-repair Zed result is claimed. The temporary Host and model fixture were +closed and removed. + +The Desktop Electron E2E run passed 30 tests and failed 4 in Side Chat and +WorkHub UI flows. An isolated retry passed the reconstruction case but still +failed 3: two screenshot timeouts and a missing WorkHub dock backdrop. This +follow-up does not change Desktop UI files; these failures remain unverified +against the original PR head. + +## PR6 local implementation — September 23, 2026 + +Base: Apache `main` at `b004473ed`, on isolated branch +`feat/acp-session-restore`. The original Antigravity checkout was not +modified. This local implementation adds standard `session/load` and +`session/resume`, durable replay and live Turn attachment, complete stdio MCP +reconfiguration, explicit `_maka/turn/resume`, and Session branch/revision +create/abandon routes. Prompt and restored Turns now share the production +`AcpTurnObservation` consumer and the existing Session channel, mapper, +interaction broker, and MCP publication path. + +The follow-up hardens MCP replacement on an already attached Session: when +the Host reports an active Turn, a changed stdio configuration returns +`session_busy` before the existing MCP manager stops its processes or changes +the publication. Equivalent configuration still reuses the live process. +Official SDK and real Host tests now also cover a live Turn's historical +prefix followed by new output without duplicate chunks, a pending permission +answered after a second ACP process loads the Session, and explicit execution +of a ready interrupted Turn. A required MCP tool missing from the replacement +provider leaves the interrupted Turn `parked/safety_check_failed`; restoring +the matching server makes explicit resume start. Repeated load replays history +while repeated resume does not. A revision target remains usable after prompt +and returns `retained` when abandoned; an unused target returns `abandoned`. A failed +historical page read releases the newly opened subscription. + +On this follow-up, the complete CLI dist suite passed 1185 tests with 3 +skipped and no failures. CLI build and typecheck, repository lint and format, +and `git diff --check` passed. The earlier Desktop E2E and Zed smoke results +below were not repeated because this follow-up changes only CLI code, tests, +and ACP documentation. + +The official ACP SDK child-process test crossed two ACP processes against one +real Runtime Host: process A created and prompted a Session, then process B +loaded it with changed MCP configuration, resumed it with an empty MCP list, +prompted it, queried a parked Turn resume, branched and prompted the target, +created revision targets, prompted one, retained it on abandon, and abandoned +the unused target. The real Host revision and +Turn/capability suites passed 104 tests. Focused registry tests include +multi-page replay (including a fragment-only page), live/history overlap, +pending interaction restoration, close during load, exact lost-response Turn +identity, and copy revision conflict/unknown target identity. + +The final full CLI dist suite passed 1178 tests with 3 skipped and no failures. +The focused ACP suite passed 214 tests before the last two registry regressions; +the final registry suite passed 89 tests. The production workspace build and +workspace typecheck passed, as did lint, format, ASF headers, CLI notices, +Desktop/UI knip, and `git diff --check`. Runtime Host source and protocol did not +change, so no compatibility epoch update was required. + +Desktop Electron E2E passed all 34 tests. Zed 1.20.2 passed a third-party smoke +with a disposable project and user-data directory, an isolated Runtime Host, +and a local deterministic model fixture. In Zed, the custom ACP agent created +a Session and answered a prompt. Reloading the agent opened a new ACP process; +its logs showed `session/load`, historical `session/update` replay, and a +successful load response. Zed kept the prior exchange visible, and a second +prompt on the restored Session completed with `stopReason: "end_turn"`. The +temporary Zed settings were restored after the smoke run. + +The first GitHub CI rerun exposed a test-only scheduling race: a replay-overlap +test exhausted 100 event-loop checks before its fake transcript page read began. +The fake subscription now signals page-read entry directly. The focused test +and the complete CLI dist suite passed after this correction. + # PR5 validation record ## Follow-up main refresh diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index e1523bef9a..670e8f2142 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -17,14 +17,34 @@ * under the License. */ -import { agent, methods, type AgentApp, type ClientCapabilities } from '@agentclientprotocol/sdk'; -import type { AcpSessionRegistry } from './session-registry.js'; +import { + agent, + methods, + RequestError, + type AgentContext, + type AgentApp, + type ClientCapabilities, +} from '@agentclientprotocol/sdk'; +import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; +import type { AcpLoadContext, AcpSessionRegistry } from './session-registry.js'; export interface MakaAcpAgentOptions { readonly version: string; readonly sessionRegistry: Pick< AcpSessionRegistry, - 'create' | 'list' | 'setConfigOption' | 'prompt' | 'cancel' | 'close' + | 'create' + | 'load' + | 'resume' + | 'resumeTurn' + | 'queryCopySource' + | 'branch' + | 'createRevision' + | 'abandonRevision' + | 'list' + | 'setConfigOption' + | 'prompt' + | 'cancel' + | 'close' >; } @@ -35,7 +55,10 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { clientCapabilities = structuredClone(params.clientCapabilities ?? {}); return { protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { list: {}, close: {} } }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: options.version }, }; @@ -43,27 +66,135 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { .onRequest(methods.agent.session.new, ({ params, signal }) => options.sessionRegistry.create(params, signal), ) + .onRequest(methods.agent.session.load, ({ params, signal, client }) => + options.sessionRegistry.load( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest(methods.agent.session.resume, ({ params, signal, client }) => + options.sessionRegistry.resume( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest( + '_maka/turn/resume', + (value: unknown) => { + try { + return HOST_OPERATION_SPECS['turn.resume.query'].decodeInput(value); + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_resume_query' }, + 'Invalid Turn resume request', + ); + } + }, + ({ params, signal, client }) => + options.sessionRegistry.resumeTurn( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest( + '_maka/session/copy-source/query', + { + parse: (value: unknown) => { + try { + return HOST_OPERATION_SPECS['session.turns.query'].decodeInput(value); + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_copy_source_query' }, + 'Invalid Session copy source query', + ); + } + }, + }, + ({ params }) => options.sessionRegistry.queryCopySource(params), + ) + .onRequest( + '_maka/session/branch/create', + { + parse: (value: unknown) => { + try { + return HOST_OPERATION_SPECS['session.branch.create'].decodeInput(value); + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_branch_input' }, + 'Invalid Session branch request', + ); + } + }, + }, + ({ params }) => options.sessionRegistry.branch(params), + ) + .onRequest( + '_maka/session/revision/create', + { + parse: (value: unknown) => { + try { + return HOST_OPERATION_SPECS['session.revision.create'].decodeInput(value); + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_revision_input' }, + 'Invalid Session revision request', + ); + } + }, + }, + ({ params }) => options.sessionRegistry.createRevision(params), + ) + .onRequest( + '_maka/session/revision/abandon', + { + parse: (value: unknown) => { + try { + return HOST_OPERATION_SPECS['session.revision.abandon'].decodeInput(value); + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_abandon_input' }, + 'Invalid Session abandon request', + ); + } + }, + }, + ({ params }) => options.sessionRegistry.abandonRevision(params), + ) .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), - interactions: { - capabilities: clientCapabilities, - requestPermission: (params, cancellationSignal) => - client.request(methods.client.session.requestPermission, params, { - cancellationSignal, - }), - createElicitation: (params, cancellationSignal) => - client.request(methods.client.elicitation.create, params, { cancellationSignal }), - }, - }), + options.sessionRegistry.prompt(params, sessionContext(client, signal, clientCapabilities)), ) .onNotification(methods.agent.session.cancel, ({ params }) => options.sessionRegistry.cancel(params), ) .onRequest(methods.agent.session.close, ({ params }) => options.sessionRegistry.close(params)); } + +function sessionContext( + client: AgentContext, + signal: AbortSignal, + capabilities: ClientCapabilities, + turnStatus = false, +): AcpLoadContext { + return { + signal, + notify: (notification) => client.notify(methods.client.session.update, notification), + interactions: { + capabilities, + requestPermission: (request, cancellationSignal) => + client.request(methods.client.session.requestPermission, request, { cancellationSignal }), + createElicitation: (request, cancellationSignal) => + client.request(methods.client.elicitation.create, request, { cancellationSignal }), + }, + ...(turnStatus && capabilities._meta?.['_maka/turnStatus'] === true + ? { + notifyTurnStatus: ( + status: Parameters>[0], + ) => client.notify('_maka/turn/status', status), + } + : {}), + }; +} diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index d2dceaa80d..7b194851d3 100644 --- a/packages/cli/src/acp/session-event-mapper.ts +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -25,6 +25,7 @@ import { } from '@agentclientprotocol/sdk'; import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; +import { userFacingText } from '@maka/core/session'; import type { InteractionPendingSnapshot, InteractionSnapshot } from '@maka/runtime-host/protocol'; import { whileActive } from './active-promise.js'; import { AcpToolEventMapper } from './tool-event-mapper.js'; @@ -99,18 +100,79 @@ export class AcpSessionEventMapper { } /** Apply a bounded authoritative batch; absence from one batch never removes a tool. */ - acceptTranscriptMessages(turnId: string, messages: readonly StoredMessage[]): Promise { + acceptTranscriptMessages( + turnId: string, + messages: readonly StoredMessage[], + ignoreOlder = false, + ): Promise { return this.#enqueue(async () => { for (const message of messages) { if (message.turnId !== turnId) continue; if (message.type === 'assistant') { - await this.#acceptText('thinking', message.id, message.thinking?.text ?? ''); - await this.#acceptText('text', message.id, message.text); + const thinking = message.thinking?.text ?? ''; + if ( + !ignoreOlder || + thinking.startsWith(this.#streams.get(streamKey('thinking', message.id)) ?? '') + ) + await this.#acceptText('thinking', message.id, thinking); + if ( + !ignoreOlder || + message.text.startsWith(this.#streams.get(streamKey('text', message.id)) ?? '') + ) + await this.#acceptText('text', message.id, message.text); } else await this.#tools.acceptMessage(message); } }); } + /** Replay one durable row in transcript order for session/load. */ + acceptHistoricalMessage(message: StoredMessage): Promise { + return this.#enqueue(async () => { + if (message.type === 'user') { + // ACP v1 has no mid-Turn steering update. Live projection omits these + // rows, so historical replay must not present them as new user turns. + if (message.steeringEventId) return; + const attachments = message.attachments ?? []; + const text = [ + userFacingText(message), + ...attachments.map( + (attachment) => + `[Attachment: ${attachment.name} (${attachment.mimeType}, ${attachment.bytes} bytes)]`, + ), + ] + .filter(Boolean) + .join('\n\n'); + if (text) { + await this.#deliver({ + sessionId: this.#sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + messageId: message.id, + ...(attachments.length || message.origin + ? { + _meta: { + ...(attachments.length + ? { '_maka/attachments': structuredClone(attachments) } + : {}), + ...(message.origin + ? { '_maka/origin': structuredClone(message.origin) } + : {}), + }, + } + : {}), + }, + }); + } + } else if (message.type === 'assistant') { + await this.#acceptText('thinking', message.id, message.thinking?.text ?? ''); + await this.#acceptText('text', message.id, message.text); + } else { + await this.#tools.acceptMessage(message); + } + }); + } + finishTools( turnId: string, terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed', @@ -136,6 +198,10 @@ export class AcpSessionEventMapper { }); } + textForMessage(kind: StreamKind, messageId: string): string | undefined { + return this.#streams.get(streamKey(kind, messageId)); + } + async #acceptText(kind: StreamKind, hostMessageId: string, nextText: string): Promise { const key = streamKey(kind, hostMessageId); const current = this.#streams.get(key) ?? ''; diff --git a/packages/cli/src/acp/session-interactions.ts b/packages/cli/src/acp/session-interactions.ts index 8a3e2b7bd6..c77045e8c2 100644 --- a/packages/cli/src/acp/session-interactions.ts +++ b/packages/cli/src/acp/session-interactions.ts @@ -100,9 +100,17 @@ interface PermissionPresentation { readonly answers: ReadonlyMap; } +interface InteractionClientLease { + readonly client: AcpInteractionClient; + previous?: InteractionClientLease; + valid: boolean; +} + /** Connection-local presentation of Host interactions; the Host owns every answer and grant. */ export class AcpSessionInteractions { readonly #options: AcpSessionInteractionsOptions; + #client: AcpInteractionClient; + #clientLease: InteractionClientLease; readonly #lifetime = new AbortController(); readonly #pending = new Map(); readonly #resolving = new Map>(); @@ -112,6 +120,32 @@ export class AcpSessionInteractions { constructor(options: AcpSessionInteractionsOptions) { this.#options = options; + this.#client = options.client; + this.#clientLease = { client: options.client, valid: true }; + } + + setClient(client: AcpInteractionClient): { rollback(): void; commit(): void } { + const lease: InteractionClientLease = { + client, + previous: this.#clientLease, + valid: true, + }; + this.#clientLease = lease; + this.#client = client; + return { + rollback: () => { + lease.valid = false; + if (this.#clientLease !== lease) return; + let previous = lease.previous; + while (previous && !previous.valid) previous = previous.previous; + if (!previous) return; + this.#clientLease = previous; + this.#client = previous.client; + }, + commit: () => { + lease.previous = undefined; + }, + }; } pending(snapshot: InteractionPendingSnapshot): Promise { @@ -233,7 +267,7 @@ export class AcpSessionInteractions { const request = pending.request; if ( (request.kind === 'question' || request.kind === 'form') && - this.#options.client.capabilities.elicitation?.form == null + this.#client.capabilities.elicitation?.form == null ) { throw interactionError( pending, @@ -252,7 +286,7 @@ export class AcpSessionInteractions { let answer: InteractionAnswer; if (request.kind === 'question' || request.kind === 'form') { const response = await whileActive( - this.#options.client.createElicitation(elicitationRequest(pending), signal), + this.#client.createElicitation(elicitationRequest(pending), signal), signal, ); if (!response.active) return; @@ -269,7 +303,7 @@ export class AcpSessionInteractions { } else { const presentation = permissionPresentation(pending); const response = await whileActive( - this.#options.client.requestPermission(presentation.request, signal), + this.#client.requestPermission(presentation.request, signal), signal, ); if (!response.active) return; diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index 94fd6bd22a..8673703ca0 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -17,16 +17,19 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { isAbsolute } from 'node:path'; -import { RequestError, type NewSessionRequest } from '@agentclientprotocol/sdk'; +import { RequestError, type McpServer } from '@agentclientprotocol/sdk'; import { MCP_CONFIG_VERSION, type McpConfigFile } from '@maka/core/mcp'; +import { stableJsonStringify } from '@maka/core/canonical-json'; import { McpClientManager } from '@maka/mcp'; import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; -import type { - RuntimeHostConnectionAvailability, - RuntimeHostReconnectingConnection, +import { + RuntimeHostOperationError, + abortable, + type RuntimeHostConnectionAvailability, + type RuntimeHostReconnectingConnection, } from '@maka/runtime-host/client'; -import { abortable } from '@maka/runtime-host/client'; import { createMcpCapabilityProvider } from '../mcp-capability-provider.js'; import { McpCapabilityPublication } from '../mcp-capability-publication.js'; @@ -36,7 +39,12 @@ export type AcpMcpConnection = Pick< >; /** Validates before any process or Host work; never writes a user MCP configuration. */ -export function createAcpMcpConfig(params: NewSessionRequest): McpConfigFile { +export function createAcpMcpConfig< + T extends { + readonly cwd: string; + readonly mcpServers: readonly McpServer[]; + }, +>(params: T): McpConfigFile { const servers: Record = Object.create(null); if (!Array.isArray(params.mcpServers)) throw invalidMcpInput('invalid_servers'); for (const server of params.mcpServers) { @@ -55,7 +63,7 @@ export function createAcpMcpConfig(params: NewSessionRequest): McpConfigFile { } if ( !Array.isArray(server.args) || - server.args.some((arg) => typeof arg !== 'string' || arg.includes('\0')) + server.args.some((arg: unknown) => typeof arg !== 'string' || arg.includes('\0')) ) { throw invalidMcpInput('invalid_arguments'); } @@ -94,14 +102,20 @@ export function createAcpMcpConfig(params: NewSessionRequest): McpConfigFile { /** Owns one Session's in-memory MCP processes and publication on the shared Host connection. */ export class AcpSessionMcp { readonly #sessionId: string; - readonly #config: McpConfigFile; - readonly #manager = new McpClientManager({ + #config: McpConfigFile; + #configurationTail: Promise = Promise.resolve(); + #manager = new McpClientManager({ clientName: 'maka-acp', excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'], }); readonly #publication: McpCapabilityPublication; - readonly #unsubscribeManager: () => void; + #unsubscribeManager: () => void; readonly #unsubscribeConnection: () => void; + #pendingManager: McpClientManager | undefined; + readonly #retainedManagers = new Set(); + readonly #retirementTasks = new Set>(); + #publicationRevision = 0; + #requireIdlePublication = false; #availability: RuntimeHostConnectionAvailability | undefined; #prepared = false; #closed = false; @@ -116,7 +130,7 @@ export class AcpSessionMcp { this.#availability?.kind === 'connected' ? this.#availability.hostEpoch + '\0' + this.#availability.connectionId : undefined, - revision: () => this.#manager.toolSnapshot().revision, + revision: () => this.#publicationRevision, createProvider: () => createMcpCapabilityProvider(this.#manager, { admission: 'mcp', @@ -128,13 +142,24 @@ export class AcpSessionMcp { offers: () => [], currentRegistrationRetired: () => this.#retire(), }, - replace: (provider) => connection.replaceClientCapabilities(provider, { sessionId }), + replace: (provider) => + connection.replaceClientCapabilities(provider, { + sessionId, + sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(this.#config)).digest('hex')}`, + ...(this.#requireIdlePublication ? { requireIdleSession: true } : {}), + }), unregister: () => connection.unregisterClientCapabilities({ sessionId }), - onState: () => undefined, - }); - this.#unsubscribeManager = this.#manager.onChange(() => { - if (this.#prepared && !this.#closed) this.#publication.request(); + onState: (state) => { + if ( + (state === 'published' || state === 'not_published') && + this.#publication.publishedRevision === this.#publicationRevision + ) { + this.#requireIdlePublication = false; + this.#retireRetainedManagers(); + } + }, }); + this.#unsubscribeManager = this.#observeManager(this.#manager); this.#unsubscribeConnection = connection.subscribeConnectionAvailability((availability) => { this.#availability = availability; if (availability.kind !== 'connected') this.#publication.invalidate(); @@ -164,13 +189,131 @@ export class AcpSessionMcp { } } + get config(): McpConfigFile { + return structuredClone(this.#config); + } + + reconfigure( + config: McpConfigFile, + signal?: AbortSignal, + assertCanChange?: () => Promise, + ): Promise { + const task = this.#configurationTail + .catch(() => undefined) + .then(async () => { + this.#assertOpen('mcp.prepare'); + signal?.throwIfAborted(); + if (sameMcpConfig(this.#config, config)) return this.#settlePublication(signal); + if (this.#retainedManagers.size > 0) await this.#settlePublication(signal); + await assertCanChange?.(); + signal?.throwIfAborted(); + const previous = this.#manager; + const previousConfig = this.#config; + const staged = new McpClientManager({ + clientName: 'maka-acp', + excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'], + }); + this.#pendingManager = staged; + let swapped = false; + let previousRevision = this.#publicationRevision; + try { + await staged.sync(config); + signal?.throwIfAborted(); + this.#assertConnected(config, staged); + previousRevision = this.#publicationRevision; + this.#unsubscribeManager(); + this.#manager = staged; + this.#config = config; + this.#unsubscribeManager = this.#observeManager(staged); + this.#retainedManagers.add(previous); + this.#pendingManager = undefined; + swapped = true; + this.#requireIdlePublication = true; + this.#publicationRevision += 1; + this.#publication.request(); + await this.#settlePublication(signal); + this.#requireIdlePublication = false; + this.#retireRetainedManagers(); + await Promise.allSettled([...this.#retirementTasks]); + } catch (error) { + if (swapped && !this.#closed) { + // Cancellation can win after the Host committed a new provider but + // before its response arrived. Resolve the in-flight publication + // before deciding which manager may be released. + await this.#publication.waitForPending(); + if ( + this.#publication.publishedRevision === previousRevision && + this.#publication.lastError instanceof RuntimeHostOperationError + ) { + this.#unsubscribeManager(); + this.#manager = previous; + this.#config = previousConfig; + this.#unsubscribeManager = this.#observeManager(previous); + this.#retainedManagers.delete(previous); + this.#publicationRevision = previousRevision; + this.#requireIdlePublication = false; + this.#publication.request(); + await this.#publication.settle().catch(() => undefined); + await staged.close().catch(() => undefined); + } else { + // A committed or unknown outcome may already be serving a Turn. + // Keep that provider and its manager; the previous manager stays + // available until a definitive publication or Session close. + this.#retireRetainedManagers(); + } + } else if (!swapped) { + this.#pendingManager = undefined; + await staged.close().catch(() => undefined); + } + if (error instanceof RequestError) throw error; + throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_reconfiguration_failed'); + } + }); + this.#configurationTail = task; + return task; + } + async ready(signal?: AbortSignal): Promise { + await abortable(() => this.#configurationTail.catch(() => undefined), signal); + await this.#settlePublication(signal); + } + + async #settlePublication(signal?: AbortSignal): Promise { signal?.throwIfAborted(); this.#assertOpen('mcp.ready'); const state = await abortable(() => this.#publication.settle(), signal); signal?.throwIfAborted(); this.#assertOpen('mcp.ready'); if (state !== 'published' && state !== 'not_published') { + if ( + this.#publication.lastError instanceof RuntimeHostOperationError && + this.#publication.lastError.code === 'session_binding_conflict' + ) { + throw RequestError.internalError( + { + source: 'runtime_host', + operation: 'mcp.prepare', + sessionId: this.#sessionId, + code: 'session_binding_conflict', + }, + 'Session MCP configuration conflicts with an active or frozen provider; restore with the same configuration or close the active attachment', + ); + } + if ( + this.#requireIdlePublication && + this.#publication.lastError instanceof RuntimeHostOperationError && + this.#publication.lastError.code === 'session_busy' + ) { + throw RequestError.internalError( + { + source: 'runtime_host', + operation: 'mcp.prepare', + sessionId: this.#sessionId, + code: 'session_busy', + }, + 'Cannot replace Session MCP configuration during an active Turn', + ); + } throw mcpUnavailable(this.#sessionId, 'mcp.ready', 'mcp_publication_failed'); } } @@ -189,7 +332,12 @@ export class AcpSessionMcp { this.#closed = true; this.#unsubscribeManager(); this.#unsubscribeConnection(); - const managerClose = this.#manager.close(); + const managerClose = Promise.allSettled([ + this.#manager.close(), + this.#pendingManager?.close(), + ...[...this.#retainedManagers].map((manager) => manager.close()), + ...this.#retirementTasks, + ]); this.#closeTask = (async () => { try { try { @@ -207,11 +355,31 @@ export class AcpSessionMcp { return this.#closeTask; } - #assertConnected(): void { + #observeManager(manager: McpClientManager): () => void { + return manager.onChange(() => { + this.#publicationRevision += 1; + if (this.#prepared && !this.#closed) this.#publication.request(); + }); + } + + #retireRetainedManagers(): void { + if (this.#publication.publishedRevision !== this.#publicationRevision) return; + for (const manager of this.#retainedManagers) { + this.#retainedManagers.delete(manager); + if (manager === this.#manager) continue; + const task = manager.close().catch((error: unknown) => { + console.error('[acp] Previous Session MCP cleanup failed:', error); + }); + this.#retirementTasks.add(task); + void task.finally(() => this.#retirementTasks.delete(task)); + } + } + + #assertConnected(config = this.#config, manager = this.#manager): void { this.#assertOpen('mcp.prepare'); if ( - Object.keys(this.#config.mcpServers).some( - (serverId) => this.#manager.status(serverId)?.state !== 'connected', + Object.keys(config.mcpServers).some( + (serverId) => manager.status(serverId)?.state !== 'connected', ) ) { throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_not_ready'); @@ -223,6 +391,10 @@ export class AcpSessionMcp { } } +function sameMcpConfig(left: McpConfigFile, right: McpConfigFile): boolean { + return stableJsonStringify(left) === stableJsonStringify(right); +} + function invalidMcpInput(reason: string): RequestError { return RequestError.invalidParams( { field: 'mcpServers', reason }, diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 62fcec855d..e9fc3becfe 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -27,20 +27,26 @@ import { type CloseSessionResponse, type ListSessionsRequest, type ListSessionsResponse, + type LoadSessionRequest, + type LoadSessionResponse, type NewSessionRequest, type NewSessionResponse, type PromptRequest, type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, type SessionNotification, type SessionConfigOption, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, - type StopReason, } from '@agentclientprotocol/sdk'; -import type { SessionEvent } from '@maka/core/events'; import type { McpConfigFile } from '@maka/core/mcp'; -import { isRuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { + isRuntimeHostTerminalTurn, + type RuntimeHostTerminalTurn, +} from '@maka/runtime-host/adapter'; +import { + abortable, readRuntimeHostConnectionCatalog, readRuntimeHostSessionCatalogPage, RuntimeHostCatalogReadError, @@ -57,6 +63,13 @@ import { HOST_OPERATION_SPECS, type SessionCatalogProjection, type TurnSnapshot, + type TurnResumePlan, + type SessionConversationCopyInput, + type SessionConversationCopyResult, + type SessionRevisionAbandonInput, + type SessionRevisionAbandonResult, + type SessionTurnsQueryInput, + type SessionTurnsQueryResult, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { @@ -75,11 +88,23 @@ import { AcpSessionEventMapper } from './session-event-mapper.js'; import { mapAcpPromptContent, publishAcpPromptAttachments } from './prompt-content.js'; import { AcpSessionMcp, createAcpMcpConfig, type AcpMcpConnection } from './session-mcp.js'; import { AcpSessionInteractions, type AcpInteractionClient } from './session-interactions.js'; +import { AcpTurnObservation, AcpAdmittedTurnObservation } from './turn-observation.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; +const TURN_STOP_TIMEOUT_MS = 30_000; +const COPY_RECONCILIATION_TIMEOUT_MS = 30_000; +const UNAVAILABLE_INTERACTION_CLIENT: AcpInteractionClient = { + capabilities: {}, + requestPermission: async () => { + throw RequestError.methodNotFound('session/request_permission'); + }, + createElicitation: async () => { + throw RequestError.methodNotFound('elicitation/create'); + }, +}; type AcpSessionRegistryOperation = | 'connection.catalog.query' @@ -89,7 +114,14 @@ type AcpSessionRegistryOperation = | 'artifact.ingest' | 'subscription.open' | 'turn.start' - | 'turn.stop'; + | 'turn.stop' + | 'turn.query' + | 'turn.resume.query' + | 'turn.resume.start' + | 'session.turns.query' + | 'session.branch.create' + | 'session.revision.create' + | 'session.revision.abandon'; type AcpSessionRegistryLifecycleOperation = | 'connect' | 'session.close' @@ -112,6 +144,18 @@ export interface AcpPromptContext { readonly interactions?: AcpInteractionClient; } +export interface AcpAttachedTurnStatus { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly status: 'completed' | 'failed' | 'cancelled' | 'observation_failed'; + readonly failureClass?: string; +} + +export interface AcpLoadContext extends AcpPromptContext { + readonly notifyTurnStatus?: (status: AcpAttachedTurnStatus) => Promise; +} + export interface AcpSessionRegistryOptions { readonly connect: (signal: AbortSignal) => Promise; readonly newSessionId?: () => string; @@ -126,25 +170,20 @@ interface AcpAttachmentConfiguration { delivery?: Promise; } -interface ActiveAcpPrompt { - readonly sessionId: string; - readonly turnId: string; - readonly mapper: AcpSessionEventMapper; - readonly waiters: Set<() => void>; - attachment?: RuntimeHostSessionChannel; - transcript?: ReturnType; - readonly projectionAbort: AbortController; - readonly reconciliationAbort: AbortController; - projectionFailure?: unknown; - dispatchStarted: boolean; - startRequestSettled: boolean; - admissionSettled: boolean; - admissionQuery?: Promise; - admissionFailure?: RequestError; - startedTurn?: TurnSnapshot; - cancelled: boolean; - finished: boolean; - stopTask?: Promise; +interface HistoryReplayDelivery { + readonly textByMessage: Map; + readonly otherUpdates: Set; +} + +interface AcpExternalContextLease { + readonly context: AcpLoadContext; + previous?: AcpExternalContextLease; + valid: boolean; +} + +interface AcpContextReplacement { + rollback(): void; + commit(): void; } /** Owns all Runtime Host resources associated with one ACP connection. */ @@ -161,8 +200,17 @@ export class AcpSessionRegistry { readonly #attachmentOpenControllers = new Map(); readonly #attachmentConfigurations = new Map(); readonly #pendingConfigSets = new Map>>(); - readonly #activePrompts = new Map>(); + readonly #attachmentWaiters = new Map>(); + readonly #turnObservations = new Map>(); + readonly #discardedAttachments = new WeakSet(); + readonly #externalObservationContexts = new Map(); + readonly #externalContextLeases = new Map(); readonly #sessionCloseTasks = new Map>(); + readonly #sessionCloseGenerations = new Map(); + readonly #sessionLoadTails = new Map>(); + readonly #sessionLoadControllers = new Map(); + readonly #historyReplays = new Set(); + readonly #historyReplayDelivery = new Map(); #connection: AcpSessionRegistryConnection | undefined; #connectTask: Promise | undefined; #connectAbortController: AbortController | undefined; @@ -183,6 +231,37 @@ export class AcpSessionRegistry { return this.#track(this.#create(params, mcpConfig, signal)); } + async load(params: LoadSessionRequest, context: AcpLoadContext): Promise { + this.#assertOpen('subscription.open'); + validateNewSessionParams(params); + const mcpConfig = createAcpMcpConfig(params); + await this.#sessionCloseTasks.get(params.sessionId)?.catch(() => undefined); + this.#assertOpen('subscription.open'); + const generation = this.#sessionCloseGenerations.get(params.sessionId) ?? 0; + return this.#track( + this.#queueLoad(params.sessionId, () => + this.#load(params, context, mcpConfig, true, generation), + ), + ); + } + + async resume( + params: ResumeSessionRequest, + context: AcpLoadContext, + ): Promise { + this.#assertOpen('subscription.open'); + validateNewSessionParams(params); + const mcpConfig = createAcpMcpConfig({ ...params, mcpServers: params.mcpServers ?? [] }); + await this.#sessionCloseTasks.get(params.sessionId)?.catch(() => undefined); + this.#assertOpen('subscription.open'); + const generation = this.#sessionCloseGenerations.get(params.sessionId) ?? 0; + return this.#track( + this.#queueLoad(params.sessionId, () => + this.#load(params, context, mcpConfig, false, generation), + ), + ); + } + async list(params: ListSessionsRequest): Promise { this.#assertOpen('session.catalog.query'); return this.#track(this.#list(params)); @@ -236,12 +315,92 @@ export class AcpSessionRegistry { await this.#cancelSession(params.sessionId); } + async resumeTurn( + params: { sessionId: string; sourceRunId?: string; expectedRuntimeEventHighWater?: number }, + context: AcpLoadContext, + ): Promise< + | { kind: 'parked'; plan: Extract } + | { + kind: 'started'; + turn: TurnSnapshot; + sourceRunId: string; + sourceRuntimeEventHighWater: number; + } + > { + this.#assertOpen('turn.resume.query'); + this.#assertOwned(params.sessionId); + return this.#track(this.#resumeTurn(params, context)); + } + + async queryCopySource( + params: SessionTurnsQueryInput, + ): Promise { + this.#assertOpen('session.turns.query'); + this.#assertOwned(params.sessionId); + return this.#track(this.#queryCopySource(params)); + } + + async #queryCopySource( + params: SessionTurnsQueryInput, + ): Promise { + const connection = await this.#getConnection('session.turns.query'); + this.#assertOwned(params.sessionId); + let session; + try { + session = await getRuntimeHostSession(connection, params.sessionId); + } catch (error) { + throw requestErrorFromSessionUpdate(error, 'session.catalog.query'); + } + if (!session) throw unknownSessionError(); + this.#assertOwned(params.sessionId); + try { + const page = await connection.request('session.turns.query', params); + this.#assertOwned(params.sessionId); + // Read the revision before the page: concurrent source changes leave a + // stale CAS token, which Host rejects instead of silently copying new data. + return { ...page, expectedSourceRevision: session.revision }; + } catch (error) { + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'session.turns.query'); + } + } + + async branch(params: SessionConversationCopyInput): Promise { + return this.#track(this.#copySession('session.branch.create', params)); + } + + async createRevision( + params: SessionConversationCopyInput, + ): Promise { + return this.#track(this.#copySession('session.revision.create', params)); + } + + async abandonRevision( + params: SessionRevisionAbandonInput, + ): Promise { + this.#assertOpen('session.revision.abandon'); + this.#assertOwned(params.targetSessionId); + const task = this.#track(this.#abandonRevision(params)); + return task; + } + async close(params: CloseSessionRequest): Promise { this.#assertOpen('session.close'); const existing = this.#sessionCloseTasks.get(params.sessionId); if (existing) return existing; - this.#assertOwned(params.sessionId); + if ( + !this.#ownedSessionIds.has(params.sessionId) && + !this.#sessionLoadTails.has(params.sessionId) + ) { + throw unknownSessionError(); + } + this.#sessionCloseGenerations.set( + params.sessionId, + (this.#sessionCloseGenerations.get(params.sessionId) ?? 0) + 1, + ); + this.#sessionLoadControllers.get(params.sessionId)?.abort(); this.#ownedSessionIds.delete(params.sessionId); + this.#historyReplayDelivery.delete(params.sessionId); const configuration = this.#attachmentConfigurations.get(params.sessionId); const delivery = configuration?.delivery; this.#attachmentConfigurations.delete(params.sessionId); @@ -260,36 +419,25 @@ export class AcpSessionRegistry { this.#closing = true; this.#connectAbortController?.abort(); this.#creationAbort.abort(); + for (const controller of this.#sessionLoadControllers.values()) controller.abort(); this.#disposeTask ??= this.#dispose(); return this.#disposeTask; } async #prompt(params: PromptRequest, context: AcpPromptContext): Promise { const turnId = this.#newTurnId(); - const projectionAbort = new AbortController(); - const reconciliationAbort = new AbortController(); - const active: ActiveAcpPrompt = { + const active = new AcpAdmittedTurnObservation({ 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); - } - }, - signal: projectionAbort.signal, - }), - waiters: new Set(), - projectionAbort, - reconciliationAbort, - dispatchStarted: false, - startRequestSettled: false, - admissionSettled: false, - cancelled: false, - finished: false, - }; - this.#addActivePrompt(active); + notify: async (notification) => { + if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { + await context.notify(notification); + this.#recordLiveHistoryDelivery(params.sessionId, notification, active); + } + }, + }); + if (this.#historyReplays.has(params.sessionId)) void active.holdLive().catch(() => undefined); + this.#setTurnObservation(active); const onAbort = () => { void this.#cancelPrompt(active).catch(() => undefined); }; @@ -315,13 +463,16 @@ export class AcpSessionRegistry { const connection = await this.#getConnection('subscription.open'); let attachment: RuntimeHostSessionChannel; try { - attachment = await this.#ensureAttachment(params.sessionId, connection, context); + attachment = await this.#ensureAttachment(params.sessionId, connection, { + ...context, + signal: active.projectionAbort.signal, + }); } catch (error) { if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; throw error; } active.attachment = attachment; - this.#wake(active); + active.wake(); if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; try { @@ -345,19 +496,15 @@ export class AcpSessionRegistry { await this.#mcps.get(params.sessionId)?.ready(active.projectionAbort.signal); if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; - active.transcript = attachment.trackPromptTranscript(turnId); - const observation = this.#consumePromptEvents(active, attachment.eventsForTurn(turnId)); + this.#assertOwned(params.sessionId); + const observation = active.start(attachment); // 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); + active.markDispatched(); 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); + active.settleStartRequest(result.kind === 'started' ? result.turn : undefined); if (result.kind === 'blocked') { const error = new Error('Runtime Host blocked the requested Turn'); attachment.failTurn(turnId, error); @@ -366,14 +513,10 @@ export class AcpSessionRegistry { } 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) { + active.failStartRequest(error); + if (!active.admission.settled) { this.#queryPromptAdmission(active, connection); } - this.#wake(active); attachment.failTurn(turnId, error); if (!active.cancelled) throw requestErrorFromRuntimeHost(error, 'turn.start'); } @@ -389,7 +532,7 @@ export class AcpSessionRegistry { 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 (active.admission.failure) throw active.admission.failure; if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'subscription.open'); } finally { @@ -397,78 +540,29 @@ export class AcpSessionRegistry { // A terminal subscription event can precede the Stop response. Retain this // prompt so close/dispose cannot release its connection while Stop is in flight. await active.stopTask?.catch(() => undefined); - active.projectionAbort.abort(); - active.reconciliationAbort.abort(); - active.transcript?.dispose(); + active.dispose(); this.#attachmentInteractions.get(active.sessionId)?.settleTurn(active.turnId); const observed = active.attachment?.snapshot.rootTurn; if (observed?.turnId === active.turnId && isRuntimeHostTerminalTurn(observed)) { this.#attachmentInteractions.get(active.sessionId)?.terminalTurn(active.turnId); } - active.finished = true; - this.#wake(active); - this.#removeActivePrompt(active); + active.wake(); + this.#removeTurnObservation(active); } } - async #consumePromptEvents( - active: ActiveAcpPrompt, - events: AsyncIterable, - ): Promise { - let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; - try { - for await (const event of events) { - if (event.type === 'abort') terminalStatus = 'cancelled'; - else if (event.type === 'error' && !event.recoverable) terminalStatus = 'failed'; - if (terminalStatus !== 'completed') active.reconciliationAbort.abort(); - if (!active.cancelled) await active.mapper.accept(event); - } - if (active.cancelled) return this.#cancelledStopReason(active); - if (terminalStatus === 'completed') await this.#reconcilePrompt(active, true); - else active.reconciliationAbort.abort(); - if (active.projectionFailure) throw active.projectionFailure; - await active.mapper.finishTools(active.turnId, terminalStatus); - await active.mapper.flush(); - return active.cancelled ? this.#cancelledStopReason(active) : 'end_turn'; - } catch (error) { - if (active.cancelled) return this.#cancelledStopReason(active); - throw error; - } - } - - async #reconcilePrompt(active: ActiveAcpPrompt, replay = false): Promise { - if (active.cancelled || active.finished || !active.transcript) return; - try { - await active.transcript.reconcile( - (messages) => active.mapper.acceptTranscriptMessages(active.turnId, messages), - AbortSignal.any([active.projectionAbort.signal, active.reconciliationAbort.signal]), - // One final replay observes revisions below the consumed cut before end_turn. - { replay }, - ); - } catch (error) { - if ( - active.cancelled || - active.finished || - active.projectionAbort.signal.aborted || - active.reconciliationAbort.signal.aborted - ) - return; - active.projectionFailure ??= error; - active.attachment?.failTurn(active.turnId, error); - throw error; - } - } - - async #cancelledStopReason(active: ActiveAcpPrompt): Promise<'cancelled'> { - // A failed notification must not change the outcome of an explicit Host - // cancellation. The projection failure still fails uncancelled prompts. - await active.mapper.flush().catch(() => undefined); - return 'cancelled'; + async #cancelledStopReason(active: AcpAdmittedTurnObservation): Promise<'cancelled'> { + return active.cancelledStopReason(); } #cancelSession(sessionId: string): Promise[]> { - const active = [...(this.#activePrompts.get(sessionId) ?? [])]; + const active = this.#admittedTurns(sessionId); const cancellations = active.map((prompt) => this.#cancelPrompt(prompt)); + for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { + if (!(observation instanceof AcpAdmittedTurnObservation) && observation.stopTask) { + cancellations.push(observation.stopTask); + } + } this.#attachmentOpenControllers.get(sessionId)?.abort(); const attachment = this.#attachments.get(sessionId); if (attachment) { @@ -483,11 +577,13 @@ export class AcpSessionRegistry { !isRuntimeHostTerminalTurn(root) && !active.some((prompt) => prompt.turnId === root.turnId) ) { - await this.#connection?.request('turn.stop', { - sessionId: root.sessionId, - turnId: root.turnId, - runId: root.runId, - }); + const observation = this.#observation(sessionId, root.turnId); + if (observation && observation.attachment === opened) { + observation.stopTask ??= this.#stopAttachedTurn(opened, root); + await observation.stopTask; + } else { + await this.#stopAttachedTurn(opened, root); + } } }, () => undefined, @@ -497,16 +593,40 @@ export class AcpSessionRegistry { return Promise.allSettled(cancellations); } - async #cancelPrompt(active: ActiveAcpPrompt): Promise { - active.cancelled = true; - this.#wake(active); + #stopAttachedTurn(attachment: RuntimeHostSessionChannel, root: TurnSnapshot): Promise { + const current = attachment.snapshot.rootTurn; if ( - [...(this.#activePrompts.get(active.sessionId) ?? [])].every( - (prompt) => prompt.cancelled && !prompt.dispatchStarted, - ) - ) { - this.#attachmentOpenControllers.get(active.sessionId)?.abort(); + !current || + current.sessionId !== root.sessionId || + current.turnId !== root.turnId || + current.runId !== root.runId || + isRuntimeHostTerminalTurn(current) + ) + return Promise.resolve(); + const observation = this.#observation(root.sessionId, root.turnId); + if (observation && observation.runId !== undefined && observation.runId !== root.runId) + return Promise.resolve(); + if (observation && observation.attachment === attachment) { + observation.cancelled = true; + observation.projectionAbort.abort(); + observation.reconciliationAbort.abort(); } + // Fence before sending Stop: a failed delivery cannot authorize a late answer. + this.#attachmentInteractions.get(root.sessionId)?.cancelTurn(root.turnId); + return ( + this.#connection + ?.request( + 'turn.stop', + { sessionId: root.sessionId, turnId: root.turnId, runId: root.runId }, + TURN_STOP_TIMEOUT_MS, + ) + .then(() => undefined) ?? Promise.resolve() + ); + } + + async #cancelPrompt(active: AcpAdmittedTurnObservation): Promise { + active.cancelled = true; + active.wake(); active.projectionAbort.abort(); active.reconciliationAbort.abort(); this.#attachmentInteractions.get(active.sessionId)?.cancelTurn(active.turnId); @@ -522,57 +642,58 @@ export class AcpSessionRegistry { ]); } - async #stopPromptWhenObservable(active: ActiveAcpPrompt): Promise { - if (!active.dispatchStarted) return; + async #stopPromptWhenObservable(active: AcpAdmittedTurnObservation): Promise { + if (!active.admission.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; + const root = observed?.turnId === active.turnId ? observed : active.admission.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, - }); + await connection.request( + 'turn.stop', + { sessionId: root.sessionId, turnId: root.turnId, runId: root.runId }, + TURN_STOP_TIMEOUT_MS, + ); } 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; + if (active.admission.settled && active.admission.startRequestSettled) return; + if (active.admission.failure) { + console.error('[acp] Host Turn admission remains unknown:', active.admission.failure); + throw active.admission.failure; } - await this.#waitForPromptChange(active); + await active.waitForChange(); } } - #queryPromptAdmission(active: ActiveAcpPrompt, connection: AcpSessionRegistryConnection): void { + #queryPromptAdmission( + active: AcpAdmittedTurnObservation, + 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); + // bounded retry task; neither a healthy subscription nor a query failure + // other than not_found establishes whether a dispatched start was admitted. + active.admission.query ??= this.#readPromptAdmission(active, connection); } async #readPromptAdmission( - active: ActiveAcpPrompt, + active: AcpAdmittedTurnObservation, 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; + if (active.finished || active.admission.settled || (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); + active.observeStartedTurn(observed); return; } try { @@ -581,47 +702,82 @@ export class AcpSessionRegistry { { sessionId: active.sessionId, turnId: active.turnId }, ADMISSION_QUERY_TIMEOUT_MS, ); - if (!active.finished && !active.admissionSettled) { - active.startedTurn = turn; - active.admissionSettled = true; - this.#wake(active); + if (!active.finished && !active.admission.settled) { + active.observeStartedTurn(turn); } return; } catch (error) { if (error instanceof RuntimeHostOperationError && error.code === 'not_found') { - active.admissionSettled = true; - this.#wake(active); + const observed = active.attachment?.snapshot.rootTurn; + active.settleAbsentTurn(observed?.turnId === active.turnId ? observed : undefined); return; } lastError = error; } - if (active.finished || active.admissionSettled || this.#closing) return; + if (active.finished || active.admission.settled || this.#closing) return; if (attempt + 1 < ADMISSION_QUERY_MAX_ATTEMPTS) { - await this.#waitForPromptChange(active, ADMISSION_QUERY_RETRY_MS * 2 ** attempt); + await active.waitForChange(ADMISSION_QUERY_RETRY_MS * 2 ** attempt); } } if (active.attachment?.snapshot.rootTurn?.turnId === active.turnId) { - this.#wake(active); + active.wake(); 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', + active.failAdmission( + 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, context: AcpPromptContext, + ): Promise { + context.signal.throwIfAborted(); + let waiters = this.#attachmentWaiters.get(sessionId); + if (!waiters) { + waiters = new Set(); + this.#attachmentWaiters.set(sessionId, waiters); + } + const waiter = {}; + waiters.add(waiter); + try { + return await abortable( + () => this.#openAttachment(sessionId, connection, context), + context.signal, + ); + } finally { + waiters.delete(waiter); + if (waiters.size === 0 && this.#attachmentWaiters.get(sessionId) === waiters) { + this.#attachmentWaiters.delete(sessionId); + if (context.signal.aborted && !this.#hasAttachmentConsumers(sessionId)) { + this.#attachmentOpenControllers.get(sessionId)?.abort(); + } + } + } + } + + #hasAttachmentConsumers(sessionId: string): boolean { + return ( + (this.#attachmentWaiters.get(sessionId)?.size ?? 0) > 0 || + this.#admittedTurns(sessionId).some((active) => !active.cancelled && !active.finished) + ); + } + + async #openAttachment( + sessionId: string, + connection: AcpSessionRegistryConnection, + context: AcpPromptContext, ): Promise { const existing = this.#attachments.get(sessionId); if (existing) return existing; @@ -647,43 +803,41 @@ export class AcpSessionRegistry { const interactions = new AcpSessionInteractions({ sessionId, connection, - client: context.interactions ?? { - capabilities: {}, - requestPermission: async () => { - throw RequestError.methodNotFound('session/request_permission'); - }, - createElicitation: async () => { - throw RequestError.methodNotFound('elicitation/create'); - }, - }, + client: context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT, onPending: async (pending) => { - for (const active of this.#activePrompts.get(sessionId) ?? []) { - if (active.turnId === pending.turnId && !active.cancelled) { - await active.mapper.pendingInteraction(pending); - } + const observation = this.#observation(sessionId, pending.turnId); + if (observation && !observation.cancelled) { + await observation.pendingInteraction(pending); } }, onAnswered: (answered, pending) => attachment?.publishInteractionAnswer(answered, pending), onResolved: async (resolved, pending) => { - for (const active of this.#activePrompts.get(sessionId) ?? []) { - if (active.turnId === pending.turnId && !active.cancelled && !active.finished) { - await active.mapper.resolvedInteraction(resolved, pending); - } + const observation = this.#observation(sessionId, pending.turnId); + if (observation && !observation.cancelled && !observation.finished) { + await observation.resolvedInteraction(resolved, pending); } }, onFailure: (pending, error) => { - const active = [...(this.#activePrompts.get(sessionId) ?? [])].find( - (prompt) => prompt.turnId === pending.turnId && !prompt.finished, - ); - if (active?.attachment) { - active.projectionFailure ??= error; - active.attachment.failTurn(active.turnId, error); + const observation = this.#observation(sessionId, pending.turnId); + if (observation?.attachment) { + if (!(observation instanceof AcpAdmittedTurnObservation)) { + console.error('[acp] Attached interaction presentation failed:', error); + return; + } + observation.projectionFailure ??= error; + observation.attachment.failTurn(observation.turnId, error); } else if (!attachment) failAttachment(error); }, onCancelled: (pending) => { - for (const active of this.#activePrompts.get(sessionId) ?? []) { - if (active.turnId === pending.turnId) + let localPrompt = false; + for (const active of this.#admittedTurns(sessionId)) { + if (active.turnId === pending.turnId) { + localPrompt = true; void this.#cancelPrompt(active).catch(() => undefined); + } + } + if (!localPrompt && this.#observation(sessionId, pending.turnId)) { + void this.#cancelSession(sessionId).catch(() => undefined); } }, }); @@ -694,13 +848,39 @@ export class AcpSessionRegistry { openInitialSessionSubscription: connection.openSessionSubscriptionOnce.bind(connection), sessionId, now: Date.now, - onTurnStarted: () => undefined, + onTurnStarted: (turn) => { + if (attachment) + void this.#adoptTurn( + sessionId, + turn.turnId, + attachment, + false, + turn.runId, + attachment.terminalTurn(turn.turnId), + ).catch((error: unknown) => { + console.error('[acp] Attached Turn observation failed:', error); + }); + }, onRuntimeResourceChanged: () => undefined, onSnapshotChanged: (snapshot) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; this.#wakeSession(sessionId); if (snapshot.rootTurn && isRuntimeHostTerminalTurn(snapshot.rootTurn)) { interactions.terminalTurn(snapshot.rootTurn.turnId); } + const root = snapshot.rootTurn; + const observation = root && this.#observation(sessionId, root.turnId); + const prompt = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; + const observedRunId = + observation?.runId ?? + prompt?.admission.startedTurn?.runId ?? + observation?.terminalTurn?.runId; + if (root && observation && (observedRunId === undefined || observedRunId === root.runId)) { + if (isRuntimeHostTerminalTurn(root)) observation.terminalTurn = root; + if (prompt) { + prompt.observeStartedTurn(root); + } + } if (configuration.metadataRevision === undefined) { configuration.metadataRevision = snapshot.session.metadataRevision; return; @@ -722,20 +902,19 @@ export class AcpSessionRegistry { }); }, 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); - }); - } + if (attachment && this.#discardedAttachments.has(attachment)) return; + const observation = this.#observation(sessionId, turnId); + if (observation && !observation.cancelled) { + void observation.replaceTranscript(messages).catch((error: unknown) => { + observation.attachment?.failTurn(turnId, error); + }); } }, onInteractionPending: (pending) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; if ( !interactions.fencesTurn(pending.turnId) && - ![...(this.#activePrompts.get(sessionId) ?? [])].some( - (active) => active.turnId === pending.turnId && active.dispatchStarted, - ) + !this.#observation(sessionId, pending.turnId) ) { // An idle attachment can observe another client's Turn; it does not // transfer that Turn's interaction authority to this ACP client. @@ -744,28 +923,28 @@ export class AcpSessionRegistry { void interactions.pending(pending); }, onInteractionResolved: (pending) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; if ( !interactions.fencesTurn(pending.turnId) && - ![...(this.#activePrompts.get(sessionId) ?? [])].some( - (active) => active.turnId === pending.turnId && active.dispatchStarted, - ) + !this.#observation(sessionId, pending.turnId) ) return; void interactions.resolved(pending); }, onTranscriptSettlement: (turnId) => { - for (const active of this.#activePrompts.get(sessionId) ?? []) { - if (active.turnId === turnId) void this.#reconcilePrompt(active).catch(() => undefined); - } + if (attachment && this.#discardedAttachments.has(attachment)) return; + void this.#observation(sessionId, turnId) + ?.reconcile() + .catch(() => undefined); }, onGoalChanged: () => undefined, onFailed: failAttachment, onRecovered: () => { - for (const active of this.#activePrompts.get(sessionId) ?? []) { + for (const active of this.#admittedTurns(sessionId)) { if ( active.attachment !== attachment || - !active.startRequestSettled || - active.admissionSettled + !active.admission.startRequestSettled || + active.admission.settled ) { continue; } @@ -775,22 +954,38 @@ export class AcpSessionRegistry { if (attachment?.snapshot.rootTurn?.turnId !== active.turnId) { this.#queryPromptAdmission(active, connection); } - this.#wake(active); + active.wake(); } }, }) - .then(({ channel }) => { - channel.activate(); + .then(async ({ channel, attachedTurnId }) => { 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(); - }); + await channel.close(); + throw this.#closing ? registryClosedError('subscription.open') : unknownSessionError(); + } + if (attachedTurnId) { + const root = channel.snapshot.rootTurn; + await this.#adoptTurn( + sessionId, + attachedTurnId, + channel, + false, + root?.turnId === attachedTurnId + ? root.runId + : channel.terminalTurn(attachedTurnId)?.runId, + channel.terminalTurn(attachedTurnId), + ); } + channel.activate( + attachedTurnId && this.#observation(sessionId, attachedTurnId) + ? attachedTurnId + : undefined, + ); return channel; }) .catch((error: unknown) => { @@ -820,28 +1015,55 @@ export class AcpSessionRegistry { attachment: RuntimeHostSessionChannel, error: Error, ): void { - if (this.#attachments.get(sessionId) === task) { - this.#attachments.delete(sessionId); - this.#attachmentConfigurations.delete(sessionId); - this.#attachmentInteractions.get(sessionId)?.close(); - this.#attachmentInteractions.delete(sessionId); - } - for (const active of this.#activePrompts.get(sessionId) ?? []) { + this.#detachAttachment(sessionId, task); + for (const active of this.#admittedTurns(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); + active.wake(); + } + for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { + if ( + observation.attachment !== attachment || + observation instanceof AcpAdmittedTurnObservation + ) + continue; + observation.attachment.failTurn(observation.turnId, error); } void attachment.close().catch(() => undefined); } - async #closeSession(sessionId: string, delivery?: Promise): Promise { - const cancellation = await this.#cancelSession(sessionId); + #detachAttachment( + sessionId: string, + expected = this.#attachments.get(sessionId), + ): Promise | undefined { + if (!expected || this.#attachments.get(sessionId) !== expected) return; + this.#attachmentOpenControllers.get(sessionId)?.abort(); this.#attachmentInteractions.get(sessionId)?.close(); this.#attachmentInteractions.delete(sessionId); - const attachmentTask = this.#attachments.get(sessionId); + this.#attachmentConfigurations.delete(sessionId); this.#attachments.delete(sessionId); + return expected; + } + + #detachMcp(sessionId: string, expected = this.#mcps.get(sessionId)): AcpSessionMcp | undefined { + if (!expected || this.#mcps.get(sessionId) !== expected) return; + this.#mcps.delete(sessionId); + return expected; + } + + async #closeSession(sessionId: string, delivery?: Promise): Promise { + const cancellation = await this.#cancelSession(sessionId); + this.#externalObservationContexts.delete(sessionId); + this.#externalContextLeases.delete(sessionId); + for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { + if (!(observation instanceof AcpAdmittedTurnObservation)) { + observation.dispose(); + this.#removeTurnObservation(observation); + } + } + const attachmentTask = this.#detachAttachment(sessionId); let closeError: unknown; if (attachmentTask) { try { @@ -852,8 +1074,7 @@ export class AcpSessionRegistry { closeError = error; } } - const mcp = this.#mcps.get(sessionId); - this.#mcps.delete(sessionId); + const mcp = this.#detachMcp(sessionId); try { await mcp?.close(); } catch (error) { @@ -868,38 +1089,430 @@ export class AcpSessionRegistry { 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])); + async #adoptTurn( + sessionId: string, + turnId: string, + attachment: RuntimeHostSessionChannel, + trackAdmission = false, + expectedRunId?: string, + initialTerminalTurn?: RuntimeHostTerminalTurn, + ): Promise { + const context = this.#externalObservationContexts.get(sessionId); + if ( + !context || + this.#closing || + this.#discardedAttachments.has(attachment) || + !this.#ownedSessionIds.has(sessionId) + ) + return; + const existing = this.#observation(sessionId, turnId); + if (existing) { + this.#replayPendingInteractions(sessionId, turnId, attachment, existing); + return existing; + } + const runId = + expectedRunId ?? + (attachment.snapshot.rootTurn?.turnId === turnId + ? attachment.snapshot.rootTurn.runId + : undefined); + const Observation = trackAdmission ? AcpAdmittedTurnObservation : AcpTurnObservation; + const observation = new Observation({ + sessionId, + turnId, + ...(runId === undefined ? {} : { runId }), + notify: async (notification) => { + const current = this.#externalObservationContexts.get(sessionId); + if (!this.#closing && this.#ownedSessionIds.has(sessionId) && current) { + await current.notify(notification); + this.#recordLiveHistoryDelivery(sessionId, notification, observation); + } + }, + }); + if (initialTerminalTurn?.runId === runId) observation.terminalTurn = initialTerminalTurn; + if (this.#historyReplays.has(sessionId)) void observation.holdLive().catch(() => undefined); + const admission = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; + if (admission) this.#setTurnObservation(admission); + else this.#setTurnObservation(observation); + try { + await observation.seed(attachment.messages); + if ( + this.#observation(sessionId, turnId) !== observation || + this.#closing || + this.#discardedAttachments.has(attachment) + ) { + observation.dispose(); + if (admission) this.#removeTurnObservation(admission); + else this.#removeTurnObservation(observation); + return; + } + const task = observation.start(attachment); + this.#replayPendingInteractions(sessionId, turnId, attachment, observation); + const interactions = this.#attachmentInteractions.get(sessionId); + void task + .then( + async () => { + if ( + observation.finished || + this.#observation(sessionId, turnId) !== observation || + this.#discardedAttachments.has(attachment) + ) + return; + const root = observation.terminalTurn; + const outcome = root ?? observation.terminalOutcome; + const runId = root?.runId ?? observation.runId; + if (outcome && runId) { + await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ + sessionId, + turnId, + runId, + status: outcome.status, + ...(outcome.status === 'failed' + ? { failureClass: outcome.failureClass ?? 'runtime_error' } + : {}), + }); + } + }, + async (error: unknown) => { + if ( + !this.#closing && + !observation.finished && + this.#observation(sessionId, turnId) === observation && + !this.#discardedAttachments.has(attachment) + ) { + console.error('[acp] Attached Turn observation failed:', error); + const root = attachment.snapshot.rootTurn; + if ( + root?.turnId === turnId && + (observation.runId === undefined || root.runId === observation.runId) && + !isRuntimeHostTerminalTurn(root) + ) { + observation.stopTask ??= this.#stopAttachedTurn(attachment, root); + void observation.stopTask.catch((stopError: unknown) => { + console.error('[acp] Host Stop delivery failed:', stopError); + }); + } + await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ + sessionId, + turnId, + runId: root?.turnId === turnId ? root.runId : '', + status: 'observation_failed', + }); + } + }, + ) + .catch((error: unknown) => { + console.error('[acp] Attached Turn status delivery failed:', error); + }) + .finally(async () => { + if (admission) { + while ( + admission.admission.dispatchStarted && + !admission.admission.startRequestSettled && + !this.#closing + ) { + await admission.waitForChange(); + } + } + await observation.stopTask?.catch(() => undefined); + if (admission) this.#removeTurnObservation(admission); + interactions?.settleTurn(turnId); + observation.dispose(); + this.#removeTurnObservation(observation); + }); + return observation; + } catch (error) { + observation.dispose(); + if (admission) this.#removeTurnObservation(admission); + else this.#removeTurnObservation(observation); + throw error; + } } - #removeActivePrompt(active: ActiveAcpPrompt): void { - const prompts = this.#activePrompts.get(active.sessionId); - prompts?.delete(active); - if (prompts?.size === 0) this.#activePrompts.delete(active.sessionId); + #replayPendingInteractions( + sessionId: string, + turnId: string, + attachment: RuntimeHostSessionChannel, + observation: AcpTurnObservation, + ): void { + if (observation.finished || this.#discardedAttachments.has(attachment)) return; + const interactions = this.#attachmentInteractions.get(sessionId); + for (const pending of attachment.snapshot.interactions.pending) { + if (pending.turnId === turnId) void interactions?.pending(pending); + } } - #wakeSession(sessionId: string): void { - for (const active of this.#activePrompts.get(sessionId) ?? []) this.#wake(active); + async #resumeTurn( + params: { sessionId: string; sourceRunId?: string; expectedRuntimeEventHighWater?: number }, + context: AcpLoadContext, + ) { + context.signal.throwIfAborted(); + const connection = await this.#getConnection('turn.resume.query'); + let plan; + try { + plan = await connection.request('turn.resume.query', params); + } catch (error) { + throw requestErrorFromRuntimeHost(error, 'turn.resume.query'); + } + if (plan.disposition === 'parked') return { kind: 'parked' as const, plan }; + const restoreContext = this.#installExternalContext(params.sessionId, context); + const restoreClient = this.#attachmentInteractions + .get(params.sessionId) + ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); + let observation: AcpAdmittedTurnObservation | undefined; + let attachment: RuntimeHostSessionChannel | undefined; + const turnId = this.#newTurnId(); + let dispatched = false; + const onAbort = () => { + if (observation) void this.#cancelPrompt(observation).catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + try { + await this.#mcps.get(params.sessionId)?.ready(context.signal); + attachment = await this.#ensureAttachment(params.sessionId, connection, context); + context.signal.throwIfAborted(); + this.#assertOwned(params.sessionId); + const adopted = await this.#adoptTurn(params.sessionId, turnId, attachment, true); + if (!(adopted instanceof AcpAdmittedTurnObservation)) + throw registryClosedError('turn.resume.start'); + observation = adopted; + if (context.signal.aborted) onAbort(); + if (observation.cancelled) + throw RequestError.internalError( + { source: 'adapter', operation: 'turn.resume.start', code: 'cancelled' }, + 'Turn resume was cancelled before admission', + ); + dispatched = true; + observation.markDispatched(); + const result = await connection.request('turn.resume.start', { + sessionId: params.sessionId, + turnId, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }); + observation.settleStartRequest(result.kind === 'started' ? result.turn : undefined); + if (result.kind === 'parked') { + observation.dispose(); + this.#removeTurnObservation(observation); + attachment.failTurn(turnId, new Error(`Turn resume parked: ${result.plan.reason}`)); + restoreContext.commit(); + restoreClient?.commit(); + return { kind: 'parked' as const, plan: result.plan }; + } + await observation.stopTask?.catch(() => undefined); + restoreContext.commit(); + restoreClient?.commit(); + return { + kind: 'started' as const, + turn: result.turn, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }; + } catch (error) { + let failure = error; + if (observation) { + observation.failStartRequest(error); + } + if ( + dispatched && + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' && + observation + ) { + this.#queryPromptAdmission(observation, connection); + await observation.admission.query; + if (observation.admission.startedTurn) { + restoreContext.commit(); + restoreClient?.commit(); + return { + kind: 'started' as const, + turn: observation.admission.startedTurn, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }; + } + const admissionError = RequestError.internalError( + { + source: 'runtime_host', + operation: 'turn.resume.start', + code: 'outcome_unknown', + sessionId: params.sessionId, + turnId, + sourceRunId: plan.sourceRunId, + }, + 'Runtime Host Turn resume admission could not be established', + ); + if (!observation.admission.rejected) { + // The dispatched Turn may still be running, so its context and + // interaction client remain the current attachment authority. + restoreContext.commit(); + restoreClient?.commit(); + throw admissionError; + } + failure = admissionError; + } + if (observation) { + observation.dispose(); + this.#removeTurnObservation(observation); + attachment?.failTurn(turnId, failure); + } + restoreContext.rollback(); + restoreClient?.rollback(); + if (failure instanceof RequestError) throw failure; + throw requestErrorFromRuntimeHost(failure, 'turn.resume.start', { turnId }); + } finally { + context.signal.removeEventListener('abort', onAbort); + } } - #wake(active: ActiveAcpPrompt): void { - for (const resolve of active.waiters) resolve(); - active.waiters.clear(); + async #copySession( + operation: 'session.branch.create' | 'session.revision.create', + params: SessionConversationCopyInput, + ): Promise { + this.#assertOpen(operation); + this.#assertOwned(params.sourceSessionId); + if (this.#ownedSessionIds.has(params.targetSessionId)) { + throw RequestError.invalidParams( + { field: 'targetSessionId', reason: 'already_owned' }, + 'Target Session is already owned by this ACP connection', + ); + } + const connection = await this.#getConnection(operation); + this.#assertOwned(params.sourceSessionId); + let result: SessionConversationCopyResult; + try { + result = await connection.request(operation, params); + } catch (error) { + if ( + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' && + !this.#closing + ) { + // The target ID alone is not proof of ownership: a lost response may + // have been an operation_conflict with another caller's target. The + // Host's exact copy request is idempotent by target and fingerprint. + try { + result = await connection.request(operation, params, COPY_RECONCILIATION_TIMEOUT_MS); + } catch (reconciliationError) { + throw requestErrorFromRuntimeHost(reconciliationError, operation, { + targetSessionId: params.targetSessionId, + }); + } + } else { + throw requestErrorFromRuntimeHost(error, operation, { + targetSessionId: params.targetSessionId, + }); + } + } + if (result.kind === 'committed' && !this.#closing) { + this.#ownedSessionIds.add(params.targetSessionId); + } + return result; } - #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); - }); + async #abandonRevision( + params: SessionRevisionAbandonInput, + ): Promise { + const connection = await this.#getConnection('session.revision.abandon'); + this.#assertOwned(params.targetSessionId); + let result: SessionRevisionAbandonResult; + try { + result = await connection.request('session.revision.abandon', params); + } catch (error) { + throw requestErrorFromRuntimeHost(error, 'session.revision.abandon'); + } + if (result.kind === 'abandoned') { + this.#sessionCloseGenerations.set( + params.targetSessionId, + (this.#sessionCloseGenerations.get(params.targetSessionId) ?? 0) + 1, + ); + this.#sessionLoadControllers.get(params.targetSessionId)?.abort(); + this.#ownedSessionIds.delete(params.targetSessionId); + this.#historyReplayDelivery.delete(params.targetSessionId); + this.#externalObservationContexts.delete(params.targetSessionId); + this.#externalContextLeases.delete(params.targetSessionId); + for (const observation of this.#turnObservations.get(params.targetSessionId)?.values() ?? + []) { + observation.cancelled = true; + observation.dispose(); + if (observation instanceof AcpAdmittedTurnObservation) observation.wake(); + else this.#removeTurnObservation(observation); + } + const attachment = this.#detachAttachment(params.targetSessionId); + const mcp = this.#detachMcp(params.targetSessionId); + await Promise.allSettled([ + attachment?.then( + (channel) => channel.close(), + () => undefined, + ), + mcp?.close(), + ]); + } + return result; + } + + #admittedTurns(sessionId: string): AcpAdmittedTurnObservation[] { + return [...(this.#turnObservations.get(sessionId)?.values() ?? [])].filter( + (observation): observation is AcpAdmittedTurnObservation => + observation instanceof AcpAdmittedTurnObservation, + ); + } + + #installExternalContext(sessionId: string, context: AcpLoadContext): AcpContextReplacement { + const lease: AcpExternalContextLease = { + context, + previous: this.#externalContextLeases.get(sessionId), + valid: true, + }; + this.#externalContextLeases.set(sessionId, lease); + this.#externalObservationContexts.set(sessionId, context); + return { + rollback: () => { + lease.valid = false; + if (this.#externalContextLeases.get(sessionId) !== lease) return; + let previous = lease.previous; + while (previous && !previous.valid) previous = previous.previous; + if (previous) { + this.#externalContextLeases.set(sessionId, previous); + this.#externalObservationContexts.set(sessionId, previous.context); + } else { + this.#externalContextLeases.delete(sessionId); + this.#externalObservationContexts.delete(sessionId); + } + }, + commit: () => { + lease.previous = undefined; + }, + }; + } + + #setTurnObservation(observation: AcpTurnObservation): void { + let observations = this.#turnObservations.get(observation.sessionId); + if (!observations) { + observations = new Map(); + this.#turnObservations.set(observation.sessionId, observations); + } + observations.set(observation.turnId, observation); + } + + #removeTurnObservation(observation: AcpTurnObservation): void { + const observations = this.#turnObservations.get(observation.sessionId); + if (observations?.get(observation.turnId) !== observation) return; + observations.delete(observation.turnId); + if (observations.size === 0) this.#turnObservations.delete(observation.sessionId); + if ( + observation instanceof AcpAdmittedTurnObservation && + !this.#hasAttachmentConsumers(observation.sessionId) + ) { + this.#attachmentOpenControllers.get(observation.sessionId)?.abort(); + } + } + + #observation(sessionId: string, turnId: string): AcpTurnObservation | undefined { + return this.#turnObservations.get(sessionId)?.get(turnId); + } + + #wakeSession(sessionId: string): void { + for (const active of this.#admittedTurns(sessionId)) active.wake(); } #assertOwned(sessionId: string): void { @@ -965,6 +1578,312 @@ export class AcpSessionRegistry { return { sessionId, ...(configOptions ? { configOptions } : {}) }; } + async #load( + params: LoadSessionRequest | ResumeSessionRequest, + context: AcpLoadContext, + mcpConfig: McpConfigFile, + replayHistory: boolean, + requestedGeneration: number, + ): Promise { + if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== requestedGeneration) { + throw unknownSessionError(); + } + const existingReplayDelivery = this.#historyReplayDelivery.get(params.sessionId); + const replayDelivery = replayHistory + ? (existingReplayDelivery ?? { + textByMessage: new Map(), + otherUpdates: new Set(), + }) + : undefined; + if (replayDelivery && !existingReplayDelivery) { + // An attached Turn may have streamed before load started. Retain its + // absolute message prefix so later live chunks are not mistaken for one. + for (const observation of this.#turnObservations.get(params.sessionId)?.values() ?? []) { + for (const [key, prefix] of observation.deliveredTextByMessage) { + replayDelivery.textByMessage.set(key, prefix); + } + } + } + if (replayDelivery) this.#historyReplayDelivery.set(params.sessionId, replayDelivery); + const loadController = new AbortController(); + this.#sessionLoadControllers.set(params.sessionId, loadController); + const lifetime = AbortSignal.any([ + context.signal, + loadController.signal, + this.#creationAbort.signal, + ]); + const generation = this.#sessionCloseGenerations.get(params.sessionId) ?? 0; + const alreadyOwned = this.#ownedSessionIds.has(params.sessionId); + const heldObservations = new Set(); + let restoreContext: AcpContextReplacement | undefined; + let restoreClient: ReturnType | undefined; + const previousMcp = this.#mcps.get(params.sessionId); + const previousMcpConfig = previousMcp?.config; + let previousMcpReconfigureAttempted = false; + let installedMcp: AcpSessionMcp | undefined; + let newAttachment: Promise | undefined; + try { + lifetime.throwIfAborted(); + const connection = await this.#getConnection('session.catalog.query'); + let session: SessionCatalogProjection | null; + try { + session = await getRuntimeHostSession(connection, params.sessionId); + } catch (error) { + throw requestErrorFromSessionUpdate(error, 'session.catalog.query'); + } + if (!session) { + throw RequestError.invalidParams( + { source: 'runtime_host', operation: 'session.catalog.query', code: 'not_found' }, + 'Runtime Host Session was not found', + ); + } + if (session.isArchived) { + throw RequestError.invalidParams( + { source: 'runtime_host', operation: 'session.catalog.query', code: 'archived' }, + 'Archived Runtime Host Sessions cannot be loaded', + ); + } + const cwd = await normalizeCwd(params.cwd); + if (cwd !== session.workspace.hostCwd) { + throw RequestError.invalidParams( + { field: 'cwd', reason: 'session_cwd_mismatch' }, + 'cwd does not match the existing Session workspace', + ); + } + lifetime.throwIfAborted(); + const configOptions = await this.#projectConfigOptions(connection, session); + if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== generation) { + throw unknownSessionError(); + } + if (previousMcp) { + previousMcpReconfigureAttempted = true; + await previousMcp.reconfigure(mcpConfig, lifetime, async () => { + const latest = await getRuntimeHostSession(connection, params.sessionId); + if (!latest) throw unknownSessionError(); + if ( + latest.status === 'running' || + latest.status === 'waiting_for_user' || + (latest.liveRunState?.runningTurnIds.length ?? 0) > 0 || + this.#admittedTurns(params.sessionId).length > 0 + ) { + throw RequestError.internalError( + { source: 'adapter', operation: 'mcp.prepare', code: 'session_busy' }, + 'Cannot replace Session MCP configuration during an active Turn', + ); + } + }); + } else { + installedMcp = new AcpSessionMcp(params.sessionId, mcpConfig, connection); + this.#mcps.set(params.sessionId, installedMcp); + await installedMcp.prepare(lifetime); + } + if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== generation) { + throw unknownSessionError(); + } + this.#ownedSessionIds.add(params.sessionId); + if (replayHistory) { + this.#historyReplays.add(params.sessionId); + for (const observation of this.#turnObservations.get(params.sessionId)?.values() ?? []) { + heldObservations.add(observation); + } + await Promise.all([...heldObservations].map((observation) => observation.holdLive())); + } + restoreContext = this.#installExternalContext(params.sessionId, context); + restoreClient = this.#attachmentInteractions + .get(params.sessionId) + ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); + const creatingAttachment = !this.#attachments.has(params.sessionId); + const attachmentPromise = this.#ensureAttachment(params.sessionId, connection, { + ...context, + signal: lifetime, + }); + if (creatingAttachment) newAttachment = this.#attachments.get(params.sessionId); + const attachment = await attachmentPromise; + lifetime.throwIfAborted(); + this.#assertOpen('subscription.open'); + this.#assertOwned(params.sessionId); + if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== generation) { + throw unknownSessionError(); + } + // A retained attachment may have observed this Turn before load/resume + // installed its presentation context. Attach its existing event consumer + // now; opening a second channel would lose the queue and interaction state. + const root = attachment.snapshot.rootTurn; + if ( + root && + !isRuntimeHostTerminalTurn(root) && + !attachment.hasQueuedStartedTurn(root.turnId) + ) { + await this.#adoptTurn(params.sessionId, root.turnId, attachment, false, root.runId); + lifetime.throwIfAborted(); + this.#assertOpen('subscription.open'); + this.#assertOwned(params.sessionId); + if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== generation) { + throw unknownSessionError(); + } + } + if (replayHistory) { + const mappers = new Map(); + await attachment.replayTranscript(async (messages) => { + const active = this.#turnObservations.get(params.sessionId); + for (const observation of active?.values() ?? []) { + if (!heldObservations.has(observation)) { + heldObservations.add(observation); + await observation.holdLive(); + } + await observation.seed(messages); + } + for (const message of messages) { + lifetime.throwIfAborted(); + if (!message.turnId) continue; + let mapper = mappers.get(message.turnId); + if (!mapper) { + mapper = new AcpSessionEventMapper({ + sessionId: params.sessionId, + notify: (notification) => + this.#deliverHistoryNotification(notification, context.notify, replayDelivery!), + signal: lifetime, + }); + mappers.set(message.turnId, mapper); + } + await mapper.acceptHistoricalMessage(message); + if (message.type === 'turn_state' && message.status !== 'running') { + await mapper.finishTools( + message.turnId, + message.status === 'aborted' ? 'cancelled' : message.status, + ); + await mapper.flush(); + mappers.delete(message.turnId); + } + } + }, lifetime); + await Promise.all([...mappers.values()].map((mapper) => mapper.flush())); + } + restoreContext.commit(); + restoreClient?.commit(); + if (replayHistory) this.#historyReplayDelivery.delete(params.sessionId); + return { configOptions }; + } catch (error) { + const sharedAttachment = + newAttachment && + this.#attachments.get(params.sessionId) === newAttachment && + this.#hasAttachmentConsumers(params.sessionId); + if (sharedAttachment) { + // A concurrent prompt has adopted this prepared Session. Its attachment, + // ownership and MCP provider must outlive this cancelled/failed load. + restoreContext?.rollback(); + restoreClient?.rollback(); + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'subscription.open'); + } + if (newAttachment && this.#attachments.get(params.sessionId) === newAttachment) { + const channel = await newAttachment.catch(() => undefined); + if (channel) { + this.#discardedAttachments.add(channel); + for (const observation of this.#turnObservations.get(params.sessionId)?.values() ?? []) { + if (observation.attachment !== channel) continue; + observation.dispose(); + this.#removeTurnObservation(observation); + } + } + this.#detachAttachment(params.sessionId, newAttachment); + await channel?.close().catch(() => undefined); + } + if (installedMcp && this.#mcps.get(params.sessionId) === installedMcp) { + await this.#detachMcp(params.sessionId, installedMcp) + ?.close() + .catch(() => undefined); + } else if ( + previousMcp && + previousMcpReconfigureAttempted && + previousMcpConfig && + this.#mcps.get(params.sessionId) === previousMcp + ) { + await previousMcp.reconfigure(previousMcpConfig).catch(() => undefined); + } + restoreContext?.rollback(); + restoreClient?.rollback(); + if (!alreadyOwned) this.#ownedSessionIds.delete(params.sessionId); + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'subscription.open'); + } finally { + if (this.#sessionLoadControllers.get(params.sessionId) === loadController) { + this.#sessionLoadControllers.delete(params.sessionId); + } + if (replayHistory) this.#historyReplays.delete(params.sessionId); + for (const observation of heldObservations) observation.releaseLive(); + for (const observation of this.#turnObservations.get(params.sessionId)?.values() ?? []) { + observation.releaseLive(); + } + } + } + + #recordLiveHistoryDelivery( + sessionId: string, + notification: SessionNotification, + observation: AcpTurnObservation, + ): void { + const prefix = observation.recordDeliveredText(notification); + const delivery = this.#historyReplayDelivery.get(sessionId); + if (!delivery) return; + const chunk = historyTextChunk(notification); + if (chunk) { + const previous = delivery.textByMessage.get(chunk.key) ?? ''; + delivery.textByMessage.set( + chunk.key, + prefix?.startsWith(previous) ? prefix : previous + chunk.text, + ); + } else { + delivery.otherUpdates.add(JSON.stringify(notification.update)); + } + } + + async #deliverHistoryNotification( + notification: SessionNotification, + notify: AcpPromptContext['notify'], + delivery: HistoryReplayDelivery, + ): Promise { + const chunk = historyTextChunk(notification); + if (chunk) { + const previous = delivery.textByMessage.get(chunk.key) ?? ''; + const text = chunk.text.startsWith(previous) ? chunk.text.slice(previous.length) : chunk.text; + if (!text) return; + await notify({ + ...notification, + update: { + ...notification.update, + content: { type: 'text', text }, + }, + } as SessionNotification); + delivery.textByMessage.set( + chunk.key, + chunk.text.startsWith(previous) ? chunk.text : previous + chunk.text, + ); + return; + } + const key = JSON.stringify(notification.update); + if (delivery.otherUpdates.has(key)) return; + await notify(notification); + delivery.otherUpdates.add(key); + } + + #queueLoad(sessionId: string, operation: () => Promise): Promise { + const previous = this.#sessionLoadTails.get(sessionId) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(operation); + this.#sessionLoadTails.set(sessionId, result); + void result.then( + () => { + if (this.#sessionLoadTails.get(sessionId) === result) + this.#sessionLoadTails.delete(sessionId); + }, + () => { + if (this.#sessionLoadTails.get(sessionId) === result) + this.#sessionLoadTails.delete(sessionId); + }, + ); + return result; + } + async #setConfigOption( params: SetSessionConfigOptionRequest & { readonly value: string }, configuration?: AcpAttachmentConfiguration, @@ -1100,10 +2019,18 @@ export class AcpSessionRegistry { } async #dispose(): Promise { - const sessionIds = new Set([...this.#activePrompts.keys(), ...this.#attachments.keys()]); - const activePrompts = [...sessionIds].flatMap((sessionId) => [ - ...(this.#activePrompts.get(sessionId) ?? []), + this.#externalObservationContexts.clear(); + this.#externalContextLeases.clear(); + for (const observations of this.#turnObservations.values()) { + for (const observation of observations.values()) { + if (!(observation instanceof AcpAdmittedTurnObservation)) observation.dispose(); + } + } + const sessionIds = new Set([...this.#turnObservations.keys(), ...this.#attachments.keys()]); + const observations = [...this.#turnObservations.values()].flatMap((turns) => [ + ...turns.values(), ]); + const activePrompts = [...sessionIds].flatMap((sessionId) => this.#admittedTurns(sessionId)); const cancellations = [...sessionIds].map((sessionId) => this.#cancelSession(sessionId)); for (const interactions of this.#attachmentInteractions.values()) interactions.close(); this.#attachmentInteractions.clear(); @@ -1114,25 +2041,33 @@ export class AcpSessionRegistry { 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); + while ( + active.admission.dispatchStarted && + !active.admission.startRequestSettled && + !active.finished + ) { + await active.waitForChange(); } }), ); 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; + observed?.turnId === active.turnId || active.admission.startedTurn !== undefined; + return active.admission.dispatchStarted && !active.admission.settled && !hasStopIdentity; }); + await Promise.allSettled( + observations + .filter((observation) => unknownAdmissions.every((active) => active !== observation)) + .map((observation) => observation.stopTask), + ); 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); + active.settleOnClose(); } } await Promise.allSettled(cancellations); @@ -1145,6 +2080,7 @@ export class AcpSessionRegistry { ...configurations.map(({ tail }) => tail), ]); this.#ownedSessionIds.clear(); + this.#historyReplayDelivery.clear(); } #closeOwnedConnection(): Promise { @@ -1234,7 +2170,9 @@ function registryClosedError(operation: AcpSessionRegistryLifecycleOperation): R ); } -function validateNewSessionParams(params: NewSessionRequest): void { +function validateNewSessionParams( + params: Pick, +): void { assertBoundedAbsoluteCwd(params.cwd); if ((params.additionalDirectories?.length ?? 0) > 0) { throw RequestError.invalidParams( @@ -1448,3 +2386,17 @@ function isoTimestamp(timestamp: number): string | undefined { const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); } + +function historyTextChunk( + notification: SessionNotification, +): { key: string; text: string } | undefined { + const update = notification.update; + if ( + update.sessionUpdate !== 'user_message_chunk' && + update.sessionUpdate !== 'agent_message_chunk' && + update.sessionUpdate !== 'agent_thought_chunk' + ) + return; + if (update.content.type !== 'text' || !update.messageId) return; + return { key: `${update.sessionUpdate}:${update.messageId}`, text: update.content.text }; +} diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts new file mode 100644 index 0000000000..c0fb1d9cd1 --- /dev/null +++ b/packages/cli/src/acp/turn-observation.ts @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionEvent } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import type { TurnSnapshot } from '@maka/runtime-host/protocol'; +import type { RequestError, SessionNotification, StopReason } from '@agentclientprotocol/sdk'; +import { RuntimeHostRequestInterruptedError } from '@maka/runtime-host/client'; +import type { RuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; +import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; +import { AcpSessionEventMapper } from './session-event-mapper.js'; + +/** One consumer for a Host Turn, shared by prompt admission and resumed attachments. */ +export class AcpTurnObservation { + readonly sessionId: string; + readonly turnId: string; + readonly runId?: string; + readonly mapper: AcpSessionEventMapper; + readonly deliveredTextByMessage = new Map(); + readonly projectionAbort = new AbortController(); + readonly reconciliationAbort = new AbortController(); + attachment?: RuntimeHostSessionChannel; + transcript?: ReturnType; + projectionFailure?: unknown; + terminalTurn?: RuntimeHostTerminalTurn; + terminalOutcome?: { status: 'completed' | 'failed' | 'cancelled'; failureClass?: string }; + /** Keep the Host transport alive until a requested Stop has settled. */ + stopTask?: Promise; + cancelled = false; + finished = false; + #muteDepth = 0; + #liveGate?: { promise: Promise; release(): void }; + + constructor(options: { + sessionId: string; + turnId: string; + runId?: string; + notify: ConstructorParameters[0]['notify']; + }) { + this.sessionId = options.sessionId; + this.turnId = options.turnId; + this.runId = options.runId; + this.mapper = new AcpSessionEventMapper({ + sessionId: options.sessionId, + notify: async (notification) => { + if (this.#muteDepth === 0) await options.notify(notification); + }, + signal: this.projectionAbort.signal, + }); + } + + async seed(messages: readonly StoredMessage[]): Promise { + this.#muteDepth += 1; + try { + await this.mapper.acceptTranscriptMessages(this.turnId, messages, true); + await this.mapper.flush(); + } finally { + this.#muteDepth -= 1; + } + } + + recordDeliveredText(notification: SessionNotification): string | undefined { + const update = notification.update; + if ( + update.sessionUpdate !== 'agent_message_chunk' && + update.sessionUpdate !== 'agent_thought_chunk' + ) + return; + if (update.content.type !== 'text' || !update.messageId) return; + const kind = update.sessionUpdate === 'agent_message_chunk' ? 'text' : 'thinking'; + const prefix = this.mapper.textForMessage(kind, update.messageId); + const key = `${update.sessionUpdate}:${update.messageId}`; + const previous = this.deliveredTextByMessage.get(key) ?? ''; + // A resumed mapper may have silently seeded older text. Only a prefix + // actually sent to this client can suppress historical replay. + if (prefix !== previous + update.content.text) return; + this.deliveredTextByMessage.set(key, prefix); + return prefix; + } + + holdLive(): Promise { + if (!this.#liveGate) { + let release!: () => void; + this.#liveGate = { + promise: new Promise((resolve) => { + release = resolve; + }), + release, + }; + } + return this.mapper.flush(); + } + + releaseLive(): void { + const gate = this.#liveGate; + this.#liveGate = undefined; + gate?.release(); + } + + async #waitForLive(): Promise { + while (this.#liveGate && !this.finished) await this.#liveGate.promise; + } + + async pendingInteraction( + pending: Parameters[0], + ): Promise { + await this.#waitForLive(); + if (!this.finished) await this.mapper.pendingInteraction(pending); + } + + async resolvedInteraction( + resolved: Parameters[0], + pending: Parameters[1], + ): Promise { + await this.#waitForLive(); + if (!this.finished) await this.mapper.resolvedInteraction(resolved, pending); + } + + async replaceTranscript(messages: readonly StoredMessage[]): Promise { + await this.#waitForLive(); + if (!this.finished) await this.mapper.replaceTranscript(this.turnId, messages); + } + + start(channel: RuntimeHostSessionChannel): Promise { + this.attachment = channel; + this.transcript = channel.trackPromptTranscript(this.turnId); + const task = this.consume(channel.eventsForTurn(this.turnId)); + void task.catch(() => undefined); + return task; + } + + async consume(events: AsyncIterable): Promise { + let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; + try { + for await (const event of events) { + await this.#waitForLive(); + if (event.type === 'abort') { + terminalStatus = 'cancelled'; + this.terminalOutcome = { status: 'cancelled' }; + } else if (event.type === 'error' && !event.recoverable) { + terminalStatus = 'failed'; + this.terminalOutcome = { status: 'failed', failureClass: event.reason }; + } else if (event.type === 'complete') { + this.terminalOutcome = { status: 'completed' }; + } + if (terminalStatus !== 'completed') this.reconciliationAbort.abort(); + if (!this.cancelled) await this.mapper.accept(event); + } + if (this.cancelled) return this.cancelledStopReason(); + if (terminalStatus === 'completed') await this.reconcile(true); + else this.reconciliationAbort.abort(); + if (this.projectionFailure) throw this.projectionFailure; + await this.mapper.finishTools(this.turnId, terminalStatus); + await this.mapper.flush(); + return this.cancelled ? this.cancelledStopReason() : 'end_turn'; + } catch (error) { + if (this.cancelled) return this.cancelledStopReason(); + throw error; + } + } + + async reconcile(replay = false): Promise { + await this.#waitForLive(); + if (this.cancelled || this.finished || !this.transcript) return; + try { + await this.transcript.reconcile( + (messages) => this.mapper.acceptTranscriptMessages(this.turnId, messages), + AbortSignal.any([this.projectionAbort.signal, this.reconciliationAbort.signal]), + { replay }, + ); + } catch (error) { + if ( + this.cancelled || + this.finished || + this.projectionAbort.signal.aborted || + this.reconciliationAbort.signal.aborted + ) + return; + this.projectionFailure ??= error; + this.attachment?.failTurn(this.turnId, error); + throw error; + } + } + + async cancelledStopReason(): Promise<'cancelled'> { + await this.mapper.flush().catch(() => undefined); + return 'cancelled'; + } + + dispose(): void { + this.finished = true; + this.releaseLive(); + this.projectionAbort.abort(); + this.reconciliationAbort.abort(); + this.transcript?.dispose(); + } +} + +/** Admission bookkeeping exists only for Turns this connection dispatches. */ +export class AcpAdmittedTurnObservation extends AcpTurnObservation { + readonly admission: { + readonly waiters: Set<() => void>; + dispatchStarted: boolean; + startRequestSettled: boolean; + settled: boolean; + rejected?: boolean; + query?: Promise; + failure?: RequestError; + startedTurn?: TurnSnapshot; + } = { + waiters: new Set(), + dispatchStarted: false, + startRequestSettled: false, + settled: false, + }; + + markDispatched(): void { + this.admission.dispatchStarted = true; + this.wake(); + } + + settleStartRequest(turn?: TurnSnapshot): void { + this.admission.startRequestSettled = true; + this.admission.settled = true; + if (turn) this.admission.startedTurn = turn; + this.wake(); + } + + failStartRequest(error: unknown): void { + this.admission.startRequestSettled = true; + this.admission.settled ||= !( + this.admission.dispatchStarted && + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' + ); + this.wake(); + } + + observeStartedTurn(turn: TurnSnapshot): void { + this.admission.startedTurn ??= turn; + this.admission.settled = true; + this.wake(); + } + + settleAbsentTurn(observed?: TurnSnapshot): void { + if (observed) this.admission.startedTurn ??= observed; + else if (!this.admission.startedTurn) this.admission.rejected = true; + this.admission.settled = true; + this.wake(); + } + + failAdmission(error: RequestError): void { + this.admission.failure = error; + this.wake(); + } + + settleOnClose(): void { + this.admission.settled = true; + this.wake(); + } + + wake(): void { + for (const resolve of this.admission.waiters) resolve(); + this.admission.waiters.clear(); + } + + waitForChange(timeoutMs?: number): Promise { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const wake = () => { + if (timer !== undefined) clearTimeout(timer); + this.admission.waiters.delete(wake); + resolve(); + }; + this.admission.waiters.add(wake); + if (timeoutMs !== undefined) timer = setTimeout(wake, timeoutMs); + }); + } +} diff --git a/packages/cli/src/mcp-capability-publication.ts b/packages/cli/src/mcp-capability-publication.ts index 53a354c661..86c13a29b1 100644 --- a/packages/cli/src/mcp-capability-publication.ts +++ b/packages/cli/src/mcp-capability-publication.ts @@ -44,6 +44,7 @@ export class McpCapabilityPublication { #closed = false; #state: McpCapabilityPublicationState = 'unavailable'; #published: { identity: string; revision: number; registered: boolean } | undefined; + #lastError: unknown; constructor(options: McpCapabilityPublicationOptions) { this.#options = options; @@ -65,10 +66,26 @@ export class McpCapabilityPublication { async settle(): Promise { this.request(); + return this.waitForPending(); + } + + /** Waits for an already requested publication without starting another attempt. */ + async waitForPending(): Promise { while (this.#task) await this.#task; return this.#closed ? 'unavailable' : this.#state; } + get lastError(): unknown { + return this.#lastError; + } + + get publishedRevision(): number | undefined { + const published = this.#published; + return published?.identity === this.#options.connectionIdentity() + ? published?.revision + : undefined; + } + close(): Promise { this.#closeTask ??= this.#close(true); return this.#closeTask; @@ -123,7 +140,8 @@ export class McpCapabilityPublication { else if (this.#published?.identity === identity && this.#published.registered) { await this.#options.unregister(); } - } catch { + } catch (error) { + this.#lastError = error; try { await provider?.close?.(); } catch { @@ -133,6 +151,7 @@ export class McpCapabilityPublication { else if (!this.#closed) this.#requested = true; return; } + this.#lastError = undefined; // Even an obsolete snapshot may have committed on the current connection. // Retain that fact so an empty replacement or close can unregister it. if (this.#options.connectionIdentity() === identity) { diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 8736814dff..d6f8f76904 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -270,9 +270,11 @@ export class RuntimeHostSessionChannel { } async *eventsForTurn(turnId: string): AsyncIterable { + const queue = this.#queue(turnId); try { - yield* this.#queue(turnId); + yield* queue; } finally { + queue.terminalTurn = undefined; if (this.#startedTurnBarrier === turnId) { this.#startedTurnBarrier = undefined; if (!this.#closing) this.#flushStartedTurns(); @@ -292,6 +294,101 @@ export class RuntimeHostSessionChannel { return this.#pendingStartedTurns.keys().next().value; } + /** A successor behind the active consumer must be started by the channel. */ + hasQueuedStartedTurn(turnId: string): boolean { + return this.#pendingStartedTurns.has(turnId); + } + + /** The authoritative terminal fact travels with its queued events until consumption. */ + terminalTurn(turnId: string): TerminalTurnSnapshot | undefined { + return this.#turns.get(turnId)?.terminalTurn; + } + + /** Read a fresh, bounded page stream on the existing subscription for ACP load. */ + async replayTranscript( + onMessages: (messages: readonly StoredMessage[]) => Promise, + signal?: AbortSignal, + ): Promise { + const subscription = this.#subscription; + const throughSequence = this.#transcriptThrough; + if (throughSequence === null) return; + const lifetime = AbortSignal.any([this.#lifetime.signal, ...(signal ? [signal] : [])]); + const result = await this.#scanTranscriptRange( + subscription, + null, + throughSequence, + onMessages, + lifetime, + ); + if (result === 'replaced') { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session subscription changed during transcript replay', + ); + } + } + + /** The sole paged transcript scanner used by load and Turn reconciliation. */ + async #scanTranscriptRange( + subscription: RuntimeHostSessionSubscription, + afterSequence: number | null, + throughSequence: number, + onMessages: (messages: readonly StoredMessage[]) => Promise, + signal: AbortSignal, + turnId?: string, + ): Promise<'complete' | 'replaced'> { + let cursor: string | null = null; + do { + signal.throwIfAborted(); + const page: SessionTranscriptPage = await awaitTranscript( + subscription.loadTranscriptPage({ + direction: 'newer', + throughSequence, + cursor, + anchorSequence: cursor === null ? afterSequence : null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }), + signal, + ); + let assemblyBytes = 0; + const decoded: DecodedSessionTranscriptPage = await awaitTranscript( + subscription.decodeTranscriptPage( + page, + decodeStoredMessage, + PROMPT_TRANSCRIPT_RANGE_MAX_BYTES, + (delta) => { + assemblyBytes += delta; + if (assemblyBytes > PROMPT_TRANSCRIPT_RANGE_MAX_BYTES) { + throw new RangeError('Session transcript assembly exceeds the range byte limit'); + } + }, + ), + signal, + ); + signal.throwIfAborted(); + if (subscription !== this.#subscription) return 'replaced'; + if (decoded.nextCursor !== null && decoded.nextCursor === cursor) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript cursor did not advance', + ); + } + // A correlated cursor, not consecutive event ordinals, proves coverage. + const messages = decoded.messages + .map(({ message }) => message) + .filter((message) => turnId === undefined || message.turnId === turnId); + if (messages.length) await awaitTranscript(onMessages(messages), signal); + signal.throwIfAborted(); + if ( + turnId && + messages.some((message) => message.type === 'turn_state' && message.status !== 'running') + ) + return 'complete'; + cursor = decoded.nextCursor; + } while (cursor !== null); + return 'complete'; + } + /** Call before turn.start: this deliberately does not implement historical turn lookup. */ trackPromptTranscript(turnId: string): RuntimeHostPromptTranscript { if (this.#closing || this.#failure) @@ -379,64 +476,16 @@ export class RuntimeHostSessionChannel { ); } try { - let cursor: string | null = null; - do { - signal.throwIfAborted(); - const page: SessionTranscriptPage = await awaitTranscript( - subscription.loadTranscriptPage({ - direction: 'newer', - throughSequence, - cursor, - anchorSequence: cursor === null ? afterSequence : null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - }), - signal, - ); - let assemblyBytes = 0; - const decoded: DecodedSessionTranscriptPage = await awaitTranscript( - subscription.decodeTranscriptPage( - page, - decodeStoredMessage, - PROMPT_TRANSCRIPT_RANGE_MAX_BYTES, - (delta) => { - assemblyBytes += delta; - if (assemblyBytes > PROMPT_TRANSCRIPT_RANGE_MAX_BYTES) { - throw new RangeError( - 'Prompt transcript assembly exceeds the existing range byte limit', - ); - } - }, - ), - signal, - ); - signal.throwIfAborted(); - if (subscription !== this.#subscription) break; - if ( - decoded.nextCursor !== null && - (decoded.nextCursor === cursor || decoded.messages.length === 0) - ) { - throw new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Prompt transcript cursor did not advance', - ); - } - // Durable event ordinals may be sparse. The correlated cursor, not - // consecutive message numbers, establishes coverage of this cut. - const messages = decoded.messages - .map((entry) => entry.message) - .filter((message) => message.turnId === turnId); - if (messages.length) await awaitTranscript(onMessages(messages), signal); - signal.throwIfAborted(); - if ( - messages.some( - (message) => message.type === 'turn_state' && message.status !== 'running', - ) - ) - return nextCut; - cursor = decoded.nextCursor; - } while (cursor !== null); + const scan = await this.#scanTranscriptRange( + subscription, + afterSequence, + throughSequence, + onMessages, + signal, + turnId, + ); // Commit progress only after every page and its consumer succeed. - if (subscription === this.#subscription) return nextCut; + if (scan === 'complete' && subscription === this.#subscription) return nextCut; } catch (error) { lifetime.throwIfAborted(); if (this.#failure) throw this.#failure; @@ -478,6 +527,7 @@ export class RuntimeHostSessionChannel { seedTerminalCut(turn: TerminalTurnSnapshot): void { if (!this.#projector) return; for (const event of this.#projector.seedTerminal(turn)) this.#emit(event); + this.#queue(turn.turnId).terminalTurn = turn; this.#queue(turn.turnId).finish(); } @@ -752,6 +802,7 @@ export class RuntimeHostSessionChannel { } } else if (root && isTerminalTurn(root) && !sameRuntimeHostTerminalTurn(previousRoot, root)) { for (const event of this.#projector.seedTerminal(root)) this.#emit(event); + this.#queue(root.turnId).terminalTurn = root; this.#queue(root.turnId).finish(); if (this.#activated) this.#onTranscriptSettlement(root.turnId); else this.#pendingTranscriptSettlements.push(root.turnId); @@ -881,6 +932,7 @@ export class RuntimeHostSessionChannel { else this.#pendingStartedTurns.set(turn.turnId, turn); } if (update.terminalTurn) { + this.#queue(update.terminalTurn.turnId).terminalTurn = update.terminalTurn; this.#queue(update.terminalTurn.turnId).finish(); if (this.#activated) this.#onTranscriptSettlement(update.terminalTurn.turnId); else this.#pendingTranscriptSettlements.push(update.terminalTurn.turnId); @@ -940,6 +992,7 @@ function awaitTranscript(task: Promise, signal?: AbortSignal): Promise } class SessionEventQueue implements AsyncIterable, AsyncIterator { + terminalTurn?: TerminalTurnSnapshot; readonly #items: SessionEvent[] = []; readonly #onLag: () => void; #waiting: diff --git a/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json b/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json index 68dc7f3ebc..fd46b90c84 100644 --- a/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json +++ b/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json @@ -1,5 +1,5 @@ { - "epoch": 183, + "epoch": 188, "files": ["packages/runtime-host/src/protocol/plugin-platform.ts"], - "reason": "The browser-safe refactor moves the unchanged executor catalog normalizer from the Node-only runtime service into core and updates its import. Against the current epoch-183 protocol, this relocation does not change catalog validation, accepted values, output fields, errors, or wire encoding; the renderer no longer evaluates the service's Node crypto dependency." + "reason": "The browser-safe refactor moves the unchanged executor catalog normalizer from the Node-only runtime service into core and updates its import. Against the cumulative protocol through epoch 188, this relocation does not change catalog validation, accepted values, output fields, errors, or wire encoding; the renderer no longer evaluates the service's Node crypto dependency." } diff --git a/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json b/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json index 69c885c0f9..ebfdc31285 100644 --- a/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json +++ b/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json @@ -1,5 +1,5 @@ { - "epoch": 183, + "epoch": 188, "files": ["packages/runtime-host/src/protocol/index.ts"], - "reason": "Against the current epoch-183 protocol, this moves the existing interactive composition ID constant into a lightweight module while preserving the exact protocol export and encoded value" + "reason": "Against the cumulative protocol through epoch 188, this moves the existing interactive composition ID constant into a lightweight module while preserving the exact protocol export and encoded value" } diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index e88185560d..4657a4f007 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -41,6 +41,361 @@ import { } from './fixtures/client-capability.js'; describe('Host Client Capability coordinator', () => { + test('fences complete Session configurations before binding and retains the original on conflict', async () => { + const coordinator = createCoordinator(); + const first = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { + send: async () => {}, + }); + const second = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-b'), + { send: async () => {} }, + ); + const alpha = `sha256:${'a'.repeat(64)}`; + const beta = `sha256:${'b'.repeat(64)}`; + const publish = ( + connectionId: string, + registrationId: string, + sessionId: string, + sessionConfigurationId?: string, + ) => + coordinator.handlers['client.capability.replace']( + { + ...replacementInput(registrationId, 'inspect'), + sessionId, + sessionConfigurationId, + offers: replacementInput(registrationId, 'inspect').offers.map((offer) => ({ + ...offer, + hostPathAccess: 'none' as const, + })), + }, + connectionContext(connectionId), + ); + try { + assert.equal((await publish('connection-a', 'first', 'session-a', alpha)).ok, true); + // Fence even before the first Turn binds the Session; do not rely on a prior prompt. + const conflict = await publish('connection-b', 'conflict', 'session-a', beta); + assert.equal(conflict.ok, false); + if (!conflict.ok) assert.equal(conflict.error.code, 'session_binding_conflict'); + assert.equal((await publish('connection-b', 'legacy-conflict', 'session-a')).ok, false); + assert.equal((await publish('connection-b', 'other-session', 'session-b', beta)).ok, true); + assert.equal((await publish('connection-b', 'equivalent', 'session-a', alpha)).ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + assert.equal((await publish('connection-a', 'cannot-change', 'session-a', beta)).ok, false); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.deepEqual(snapshot?.registrationIds, ['first']); + snapshot?.release(); + await coordinator.handlers['client.capability.unregister']( + { registrationId: 'first' }, + connectionContext('connection-a'), + ); + assert.equal((await publish('connection-b', 'replacement', 'session-a', beta)).ok, true); + } finally { + first.close(); + second.close(); + await coordinator.close(); + } + }); + + test('a frozen scoped provider permits equivalent recovery but fences missing or changed configurations', async () => { + const coordinator = createCoordinator(); + const first = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { + send: async () => {}, + }); + const second = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-b'), + { + send: async () => {}, + }, + ); + const foreign = coordinator.attachConnection( + clientCapabilityConnectionIdentity('foreign', 'foreign', 'different-principal'), + { send: async () => {} }, + ); + const input = { + ...replacementInput('first', 'inspect'), + sessionId: 'session-a', + sessionConfigurationId: `sha256:${'a'.repeat(64)}`, + offers: replacementInput('first', 'inspect').offers.map((offer) => ({ + ...offer, + hostPathAccess: 'none' as const, + })), + }; + try { + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + input, + connectionContext('connection-a'), + ) + ).ok, + true, + ); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + await first.close(); + const rejected = await coordinator.handlers['client.capability.replace']( + { + ...input, + registrationId: 'other-config', + sessionConfigurationId: `sha256:${'b'.repeat(64)}`, + }, + connectionContext('connection-b'), + ); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'session_binding_conflict'); + const missing = await coordinator.handlers['client.capability.replace']( + { ...input, registrationId: 'missing-config', sessionConfigurationId: undefined }, + connectionContext('connection-b'), + ); + assert.equal(missing.ok, false); + if (!missing.ok) assert.equal(missing.error.code, 'session_binding_conflict'); + const foreignAttempt = await coordinator.handlers['client.capability.replace']( + { ...input, registrationId: 'foreign' }, + connectionContext('foreign'), + ); + assert.equal(foreignAttempt.ok, false); + if (!foreignAttempt.ok) assert.equal(foreignAttempt.error.code, 'session_binding_conflict'); + const reconnected = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-reconnected', 'connection-a'), + { send: async () => {} }, + ); + try { + for (const [registrationId, configId] of [ + ['same-provider-changed', `sha256:${'b'.repeat(64)}`], + ['same-provider-missing', undefined], + ] as const) { + const rejected = await coordinator.handlers['client.capability.replace']( + { ...input, registrationId, sessionConfigurationId: configId }, + connectionContext('connection-reconnected'), + ); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'session_binding_conflict'); + } + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + { ...input, registrationId: 'reconnected' }, + connectionContext('connection-reconnected'), + ) + ).ok, + true, + ); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-reconnected'), { + ok: true, + }); + } finally { + await reconnected.close(); + } + const equivalent = await coordinator.handlers['client.capability.replace']( + { ...input, registrationId: 'equivalent' }, + connectionContext('connection-b'), + ); + assert.equal(equivalent.ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-b'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.deepEqual(snapshot?.registrationIds, ['equivalent']); + snapshot?.release(); + } finally { + await first.close(); + await second.close(); + await foreign.close(); + await coordinator.close(); + } + }); + + test('a newer connection supersedes scoped publishes from the same provider identity', async () => { + const coordinator = createCoordinator(); + const first = coordinator.attachConnection( + clientCapabilityConnectionIdentity('first-connection', 'shared-client'), + { send: async () => {} }, + ); + const second = coordinator.attachConnection( + clientCapabilityConnectionIdentity('second-connection', 'shared-client'), + { send: async () => {} }, + ); + const publish = (connectionId: string, registrationId: string, sessionId = 'session-a') => + coordinator.handlers['client.capability.replace']( + { + ...replacementInput(registrationId, 'inspect'), + sessionId, + sessionConfigurationId: `sha256:${'a'.repeat(64)}`, + offers: replacementInput(registrationId, 'inspect').offers.map((offer) => ({ + ...offer, + hostPathAccess: 'none' as const, + })), + }, + connectionContext(connectionId), + ); + try { + assert.equal((await publish('first-connection', 'first')).ok, true); + assert.equal((await publish('second-connection', 'second')).ok, true); + const stale = await publish('first-connection', 'stale'); + assert.equal(stale.ok, false); + if (!stale.ok) assert.equal(stale.error.code, 'invalid_request'); + assert.equal((await publish('first-connection', 'other-session', 'session-b')).ok, true); + assert.deepEqual(await coordinator.bindSession('session-b', 'first-connection'), { + ok: true, + }); + assert.deepEqual(await coordinator.bindSession('session-a', 'second-connection'), { + ok: true, + }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.deepEqual(snapshot?.registrationIds, ['second']); + snapshot?.release(); + } finally { + await first.close(); + await second.close(); + await coordinator.close(); + } + }); + + test('a different connection cannot change a shared provider Session configuration', async () => { + const coordinator = createCoordinator(); + const first = coordinator.attachConnection( + clientCapabilityConnectionIdentity('first-connection', 'shared-client'), + { send: async () => {} }, + ); + const second = coordinator.attachConnection( + clientCapabilityConnectionIdentity('second-connection', 'shared-client'), + { send: async () => {} }, + ); + const alpha = `sha256:${'a'.repeat(64)}`; + const beta = `sha256:${'b'.repeat(64)}`; + const publish = ( + connectionId: string, + registrationId: string, + sessionId: string, + configId?: string, + ) => + coordinator.handlers['client.capability.replace']( + { + ...replacementInput(registrationId, 'inspect'), + sessionId, + sessionConfigurationId: configId, + offers: replacementInput(registrationId, 'inspect').offers.map((offer) => ({ + ...offer, + hostPathAccess: 'none' as const, + })), + }, + connectionContext(connectionId), + ); + try { + assert.equal((await publish('first-connection', 'first', 'session-a', alpha)).ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'first-connection'), { + ok: true, + }); + for (const [registrationId, configId] of [ + ['rejected-changed', beta], + ['rejected-missing', undefined], + ] as const) { + const rejected = await publish('second-connection', registrationId, 'session-a', configId); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'session_binding_conflict'); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.deepEqual(snapshot?.registrationIds, ['first']); + snapshot?.release(); + } + assert.equal( + (await publish('first-connection', 'other-session', 'session-b', beta)).ok, + true, + ); + assert.deepEqual(await coordinator.bindSession('session-b', 'first-connection'), { + ok: true, + }); + assert.equal((await publish('second-connection', 'equivalent', 'session-a', alpha)).ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'second-connection'), { + ok: true, + }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.deepEqual(snapshot?.registrationIds, ['equivalent']); + snapshot?.release(); + assert.equal( + (await publish('first-connection', 'another-session', 'session-c', beta)).ok, + true, + ); + } finally { + await first.close(); + await second.close(); + await coordinator.close(); + } + }); + + test('guards an opt-in Session replacement at the Host admission cut', async () => { + let busy = false; + const coordinator = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), + activation: new RuntimePolicyActivationGate(), + isSessionRetired: async () => false, + isSessionTurnBusy: () => busy, + onModelToolsChanged: () => undefined, + }); + const connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-a'), + { send: async () => {} }, + ); + const context = { connectionId: 'connection-a' } as Parameters< + (typeof coordinator.handlers)['client.capability.replace'] + >[1]; + try { + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-a', + sessionId: 'session-a', + offers: [], + }, + context, + ) + ).ok, + true, + ); + busy = true; + const blocked = await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-b', + sessionId: 'session-a', + requireIdleSession: true, + offers: [], + }, + context, + ); + assert.equal(blocked.ok, false); + if (!blocked.ok) assert.equal(blocked.error.code, 'session_busy'); + // Ordinary dynamic capability refreshes retain their existing behavior. + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-c', + sessionId: 'session-a', + offers: [], + }, + context, + ) + ).ok, + true, + ); + busy = false; + assert.equal( + ( + await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-d', + sessionId: 'session-a', + requireIdleSession: true, + offers: [], + }, + context, + ) + ).ok, + true, + ); + } finally { + connection.close(); + await coordinator.close(); + } + }); + test('freezes active snapshots across replacement and releases stale registrations', async () => { const sent: unknown[] = []; const coordinator = createCoordinator(); diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 332829d622..22f16a6d5a 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -27,10 +27,63 @@ import { CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, decodeClientCapabilityResult, decodeClientFrame, + decodeClientCapabilityReplaceInput, decodeHostFrame, } from '../protocol/index.js'; describe('Client Capability protocol', () => { + test('validates complete Session configuration identities', () => { + const input = { + registrationId: 'registration', + sessionId: 'session', + offers: [], + sessionConfigurationId: `sha256:${'a'.repeat(64)}`, + }; + assert.deepEqual(decodeClientCapabilityReplaceInput(input), input); + for (const sessionConfigurationId of [ + null, + 1, + '', + 'a'.repeat(64), + `sha256:${'z'.repeat(64)}`, + ]) { + assert.throws( + () => decodeClientCapabilityReplaceInput({ ...input, sessionConfigurationId }), + RuntimeHostProtocolError, + ); + } + assert.throws( + () => decodeClientCapabilityReplaceInput({ ...input, sessionId: undefined }), + RuntimeHostProtocolError, + ); + }); + + test('decodes the opt-in idle Session replacement fence', () => { + assert.deepEqual( + decodeClientCapabilityReplaceInput({ + registrationId: 'registration', + sessionId: 'session', + requireIdleSession: true, + offers: [], + }), + { + registrationId: 'registration', + sessionId: 'session', + requireIdleSession: true, + offers: [], + }, + ); + assert.throws( + () => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration', + requireIdleSession: true, + offers: [], + }), + RuntimeHostProtocolError, + ); + }); + test('preserves opaque tool-call IDs while retaining identity bounds', () => { const frame = { kind: 'client.capability.call', diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index baca370a91..0ab0f4a16c 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -105,6 +105,8 @@ export class ClientCapabilityChannel { provider: ClientCapabilityProvider, timeoutMs: number, sessionId?: string, + requireIdleSession = false, + sessionConfigurationId?: string, ): Promise { this.#assertOpen(); if (this.#pendingMutations.has(sessionId)) { @@ -118,6 +120,8 @@ export class ClientCapabilityChannel { const canonical = decodeClientCapabilityReplaceInput({ registrationId, ...(sessionId === undefined ? {} : { sessionId }), + ...(requireIdleSession ? { requireIdleSession: true } : {}), + ...(sessionConfigurationId === undefined ? {} : { sessionConfigurationId }), offers: provider.offers(), ...(services.length === 0 ? {} : { services }), }); diff --git a/packages/runtime-host/src/client/client-capability.ts b/packages/runtime-host/src/client/client-capability.ts index 4420401199..7faeca1c4d 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -30,6 +30,8 @@ import type { export interface ClientCapabilityRegistrationOptions { readonly sessionId?: string; readonly timeoutMs?: number; + readonly requireIdleSession?: boolean; + readonly sessionConfigurationId?: string; } /** A Client-owned open-world capability provider registered on one Host connection. */ diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index aed036233b..b363c9823d 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -705,9 +705,19 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { provider: ClientCapabilityProvider, options?: number | ClientCapabilityRegistrationOptions, ): Promise { - const { timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, sessionId } = - typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); - return this.#clientCapabilities.replace(provider, timeoutMs, sessionId); + const { + timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, + sessionId, + requireIdleSession, + sessionConfigurationId, + } = typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); + return this.#clientCapabilities.replace( + provider, + timeoutMs, + sessionId, + requireIdleSession, + sessionConfigurationId, + ); } async unregisterClientCapabilities( diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 7a148d1a6c..49f3522ff8 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -103,6 +103,8 @@ const CLIENT_CAPABILITY_ERRORS = [ 'host_draining', 'operation_unavailable', 'invalid_request', + 'session_busy', + 'session_binding_conflict', 'internal_failure', ] as const; @@ -127,6 +129,10 @@ export interface ClientCapabilityReplaceInput { readonly registrationId: string; /** Restrict publication to this Session; omission keeps the connection-wide slot. */ readonly sessionId?: string; + /** Require the target Session to have no active or admitting root Turn at replacement. */ + readonly requireIdleSession?: boolean; + /** Opaque identity of a complete Session configuration; conflicting providers cannot coexist. */ + readonly sessionConfigurationId?: string; readonly offers: readonly ClientCapabilityOffer[]; readonly services?: readonly ClientCapabilityServiceOffer[]; } @@ -305,7 +311,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi record, 'Client Capability replacement', ['registrationId', 'offers'], - ['services', 'sessionId'], + ['services', 'sessionId', 'requireIdleSession', 'sessionConfigurationId'], ); if (!Array.isArray(record.offers) || record.offers.length > CLIENT_CAPABILITY_MAX_OFFERS) { throw invalidProtocolFrame('Invalid Client Capability offers'); @@ -324,6 +330,20 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi const services = serviceValues.map((service) => decodeClientCapabilityServiceOffer(service)); const sessionId = record.sessionId === undefined ? undefined : requireEntityId(record.sessionId, 'sessionId'); + if (record.requireIdleSession !== undefined && typeof record.requireIdleSession !== 'boolean') { + throw invalidProtocolFrame('Invalid Client Capability idle requirement'); + } + if (record.requireIdleSession === true && sessionId === undefined) { + throw invalidProtocolFrame('Client Capability idle requirement needs a target Session'); + } + if ( + record.sessionConfigurationId !== undefined && + (sessionId === undefined || + typeof record.sessionConfigurationId !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(record.sessionConfigurationId)) + ) { + throw invalidProtocolFrame('Invalid Client Capability Session configuration identity'); + } if ( sessionId !== undefined && (services.length > 0 || @@ -365,6 +385,10 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi const decoded = { registrationId: requireEntityId(record.registrationId, 'registrationId'), ...(sessionId === undefined ? {} : { sessionId }), + ...(record.requireIdleSession === true ? { requireIdleSession: true } : {}), + ...(record.sessionConfigurationId === undefined + ? {} + : { sessionConfigurationId: record.sessionConfigurationId }), offers, ...(record.services === undefined ? {} : { services }), }; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b1b4f8c020..76069f6ba2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 187 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 188 as const; +// 188: Session capability replacement can require an atomic idle root check; +// complete MCP configuration identities fence conflicting providers across ACP clients. // 187: transcript_changed closes a subscription for automatic bounded-tail reseeding. // 186: Settled form/question history adds form_interaction transcript messages. // 185: Add the bounded next-prompt suggestion operation. diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index 6f9da70490..6e69d1986c 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -28,6 +28,7 @@ export type HostOperationErrorCode = | 'not_found' | 'session_archived' | 'session_busy' + | 'session_binding_conflict' | 'transcript_preparing' | 'candidate_set_stale' | 'operation_conflict' diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 9f031aa4dc..77134e1713 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -107,9 +107,11 @@ interface ClientProviderConnection { readonly provider: ClientProviderState; readonly sender: ClientCapabilityConnectionSender; superseded: boolean; + readonly supersededSessionIds: Set; } interface CapabilityRegistration { + readonly sessionConfigurationId?: string; readonly providerId: string; readonly connectionId: string; readonly registrationId: string; @@ -137,6 +139,7 @@ interface SessionCapabilityBinding { readonly kind: 'bound' | 'lost'; readonly providerId: string; readonly sessionId?: string; + readonly sessionConfigurationId?: string; } type SessionBindingMode = 'strict' | 'degrade'; @@ -183,6 +186,7 @@ type ClientCapabilityToolBinding = ClientCapabilityBoundTool['binding']; export interface HostClientCapabilityCoordinatorOptions { readonly activation: RuntimePolicyActivationGate; readonly isSessionRetired: (sessionId: string) => Promise; + readonly isSessionTurnBusy?: (sessionId: string) => boolean; readonly onModelToolsChanged: () => void; readonly interactions: Pick; readonly grants: Pick< @@ -218,6 +222,9 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly #activation: RuntimePolicyActivationGate; readonly #isSessionRetired: HostClientCapabilityCoordinatorOptions['isSessionRetired']; + readonly #isSessionTurnBusy: NonNullable< + HostClientCapabilityCoordinatorOptions['isSessionTurnBusy'] + >; readonly #onModelToolsChanged: () => void; readonly #interactions: HostClientCapabilityCoordinatorOptions['interactions']; readonly #grants: HostClientCapabilityCoordinatorOptions['grants']; @@ -234,6 +241,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService constructor(options: HostClientCapabilityCoordinatorOptions) { this.#activation = options.activation; this.#isSessionRetired = options.isSessionRetired; + this.#isSessionTurnBusy = options.isSessionTurnBusy ?? (() => false); this.#onModelToolsChanged = options.onModelToolsChanged; this.#interactions = options.interactions; this.#grants = options.grants; @@ -257,6 +265,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService provider, sender, superseded: false, + supersededSessionIds: new Set(), }); let closeTask: Promise | undefined; return { @@ -547,7 +556,10 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService providerId: candidate.registration.providerId, ...(candidate.registration.sessionId === undefined ? {} - : { sessionId: candidate.registration.sessionId }), + : { + sessionId: candidate.registration.sessionId, + sessionConfigurationId: candidate.registration.sessionConfigurationId, + }), }); selected.push(candidate); } @@ -562,7 +574,10 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService providerId: candidate.registration.providerId, ...(candidate.registration.sessionId === undefined ? {} - : { sessionId: candidate.registration.sessionId }), + : { + sessionId: candidate.registration.sessionId, + sessionConfigurationId: candidate.registration.sessionConfigurationId, + }), }); } else { next.delete(candidate.offer.contractId); @@ -970,6 +985,17 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, }; } + // This check and the registration commit below run in one synchronous + // activation continuation. A new root reservation cannot interleave them. + if (input.requireIdleSession && input.sessionId && this.#isSessionTurnBusy(input.sessionId)) { + return { + ok: false, + error: { + code: 'session_busy', + message: 'Session has an active or admitting root Turn', + }, + }; + } const connection = this.#connections.get(context.connectionId); if (!connection) { return { @@ -981,25 +1007,85 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } const { provider } = connection; + const sessionId = input.sessionId; if ( - input.sessionId !== undefined && - !provider.sessionRegistrations.has(input.sessionId) && - provider.sessionRegistrations.size >= MAX_SESSION_REGISTRATIONS_PER_PROVIDER + (sessionId === undefined && connection.superseded) || + (sessionId !== undefined && connection.supersededSessionIds.has(sessionId)) ) { return { ok: false, error: { code: 'invalid_request', - message: 'Client Capability Session registration limit reached', + message: 'Client Capability connection has been superseded', }, }; } - if (connection.superseded && input.sessionId === undefined) { + if (sessionId !== undefined) { + const current = provider.sessionRegistrations.get(sessionId); + // Connections with the same provider identity may overlap during + // reconnect. Only the owning connection can deliberately reconfigure + // a Session; another connection must prove the same complete config. + const crossConnectionConflict = + current !== undefined && + current.connectionId !== context.connectionId && + input.sessionConfigurationId !== current.sessionConfigurationId; + const conflicts = [...this.#providers.values()].some((other) => { + if (other.providerId === provider.providerId) return false; + const current = other.sessionRegistrations.get(sessionId); + return ( + current && + (input.sessionConfigurationId !== undefined || + current.sessionConfigurationId !== undefined) && + input.sessionConfigurationId !== current.sessionConfigurationId + ); + }); + // A disconnected frozen provider cannot silently be replaced either. + const lostBinding = [...(this.#sessions.get(sessionId)?.sessionBindings ?? [])].some( + ([contractId, binding]) => { + if (binding.sessionId !== sessionId) return false; + if (binding.providerId === provider.providerId) { + // A remote profile retains its provider ID across connections. + // Keep its frozen configuration fenced after the old channel exits. + return ( + binding.kind === 'lost' && + input.sessionConfigurationId !== binding.sessionConfigurationId + ); + } + return ( + !this.#providers.get(binding.providerId)?.sessionRegistrations.has(sessionId) && + !( + binding.kind === 'lost' && + provider.principalKind === 'local_owner' && + this.#providers.get(binding.providerId)?.principalId === provider.principalId && + this.#providers.get(binding.providerId)?.principalKind === provider.principalKind && + input.sessionConfigurationId !== undefined && + input.sessionConfigurationId === binding.sessionConfigurationId && + input.offers.some((offer) => capabilityGroupId(offer) === contractId) + ) + ); + }, + ); + if (crossConnectionConflict || conflicts || lostBinding) { + return { + ok: false, + error: { + code: 'session_binding_conflict', + message: + 'Session MCP configuration conflicts with an active or frozen provider; restore with the same configuration or close the active attachment', + }, + }; + } + } + if ( + input.sessionId !== undefined && + !provider.sessionRegistrations.has(input.sessionId) && + provider.sessionRegistrations.size >= MAX_SESSION_REGISTRATIONS_PER_PROVIDER + ) { return { ok: false, error: { code: 'invalid_request', - message: 'Client Capability connection has been superseded', + message: 'Client Capability Session registration limit reached', }, }; } @@ -1050,7 +1136,14 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService const previous = this.#currentRegistration(provider, registration.sessionId); const previousConnectionId = provider.activeConnectionId; if (registration.sessionId !== undefined) { + if (previous && previous.connectionId !== context.connectionId) { + const previousConnection = this.#connections.get(previous.connectionId); + previousConnection?.supersededSessionIds.add(registration.sessionId); + } provider.sessionRegistrations.set(registration.sessionId, registration); + if (registration.sessionConfigurationId !== undefined) { + this.#reclaimLostBindings(registration); + } } else { if (previousConnectionId && previousConnectionId !== context.connectionId) { const previousConnection = this.#connections.get(previousConnectionId); @@ -1486,6 +1579,40 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } } + #reclaimLostBindings(registration: CapabilityRegistration): void { + const sessionId = registration.sessionId; + if (sessionId === undefined) return; + const state = this.#sessions.get(sessionId); + if (!state) return; + const next = new Map(state.sessionBindings); + const previousProviderIds = new Set(); + for (const [contractId, binding] of state.sessionBindings) { + if ( + binding.kind !== 'lost' || + binding.sessionId !== sessionId || + binding.providerId === registration.providerId || + this.#providers.get(registration.providerId)?.principalKind !== 'local_owner' || + this.#providers.get(binding.providerId)?.principalId !== + this.#providers.get(registration.providerId)?.principalId || + this.#providers.get(binding.providerId)?.principalKind !== + this.#providers.get(registration.providerId)?.principalKind || + binding.sessionConfigurationId !== registration.sessionConfigurationId || + !registration.offersByContract.has(contractId) || + this.#providers.get(binding.providerId)?.sessionRegistrations.has(sessionId) + ) + continue; + previousProviderIds.add(binding.providerId); + next.set(contractId, { ...binding, kind: 'bound', providerId: registration.providerId }); + } + if (!bindingMapsEqual(state.sessionBindings, next)) { + this.#storeSessionState(sessionId, { ...state, sessionBindings: next }); + for (const providerId of previousProviderIds) { + const previous = this.#providers.get(providerId); + if (previous) this.#deleteProviderIfUnused(previous); + } + } + } + #retireBindings( providerId: string, contracts: ReadonlySet, @@ -1736,6 +1863,9 @@ function freezeRegistration( providerId, connectionId, registrationId: input.registrationId, + ...(input.sessionConfigurationId === undefined + ? {} + : { sessionConfigurationId: input.sessionConfigurationId }), ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), trustedProvider, offersByContract, @@ -2005,7 +2135,8 @@ function bindingMapsEqual( !candidate || candidate.kind !== binding.kind || candidate.providerId !== binding.providerId || - candidate.sessionId !== binding.sessionId + candidate.sessionId !== binding.sessionId || + candidate.sessionConfigurationId !== binding.sessionConfigurationId ) { return false; } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2b8b4f26b0..d825fc829f 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1642,6 +1642,7 @@ export async function createExecutionRuntimeHostComposition( state.kind === 'removed' || (state.kind === 'present' && state.record.header.isArchived) ); }, + isSessionTurnBusy: (sessionId) => rootCoordinator?.hasActiveOrPendingTurn(sessionId) ?? false, onModelToolsChanged: registerBackendInvalidation, interactions, grants: stores.interactionStore, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 57947dae1d..f6991416fc 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -667,6 +667,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return this.#admissions.reserve(sessionId); } + hasActiveOrPendingTurn(sessionId: string): boolean { + return ( + this.#admissions.has(sessionId) || + this.#executions.has(sessionId) || + this.#recoveryPlansBySession.get(sessionId)?.rootReplayAdmission !== undefined + ); + } + private parkContinuationAdmission(admission: RootTurnAdmission): void { this.#admissions.park(admission); }