From 2802ced71bfc31751e162dfa61f6332fe633246f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:46:19 +0800 Subject: [PATCH 01/22] feat(acp): restore sessions and interrupted turns Generated-by: OpenAI Codex --- packages/cli/src/__tests__/acp-agent.test.ts | 81 +- .../__tests__/acp-child-process-harness.ts | 33 +- .../src/__tests__/acp-child-process.test.ts | 176 +++- .../cli/src/__tests__/acp-session-mcp.test.ts | 99 ++ .../__tests__/acp-session-registry.test.ts | 946 +++++++++++++++++- .../src/__tests__/acp-stdio-server.test.ts | 5 +- packages/cli/src/acp/README.md | 25 +- packages/cli/src/acp/VALIDATION.md | 34 + packages/cli/src/acp/maka-acp-agent.ts | 143 ++- packages/cli/src/acp/session-event-mapper.ts | 44 +- packages/cli/src/acp/session-mcp.ts | 69 +- packages/cli/src/acp/session-registry.ts | 814 ++++++++++++--- packages/cli/src/acp/turn-observation.ts | 176 ++++ .../cli/src/runtime-host-session-channel.ts | 151 +-- 14 files changed, 2588 insertions(+), 208 deletions(-) create mode 100644 packages/cli/src/acp/turn-observation.ts diff --git a/packages/cli/src/__tests__/acp-agent.test.ts b/packages/cli/src/__tests__/acp-agent.test.ts index ae5c53f293..3f1beddb35 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,61 @@ 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 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 +259,27 @@ 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, + }, + }), + 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 25ecf797b9..177631d5b7 100644 --- a/packages/cli/src/__tests__/acp-child-process-harness.ts +++ b/packages/cli/src/__tests__/acp-child-process-harness.ts @@ -78,6 +78,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 +92,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 +101,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 +199,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 +231,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 +239,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; } @@ -337,6 +365,7 @@ export async function startAcpChildProcessHarness( ...(host ? { host } : {}), stdoutTap, timeoutMs, + env, }); await harness.waitForSpawn(); return harness; diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 616ba06cc0..d07349def4 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -18,12 +18,13 @@ */ 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 { waitFor } from '@maka/core/test-only/async-primitives'; @@ -31,6 +32,7 @@ import { connectRuntimeHost } from '@maka/runtime-host/client'; import { 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 { @@ -129,7 +131,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' }, }); @@ -591,6 +596,173 @@ 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' }, + ); + const host = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + assert.equal(host.kind, 'connected'); + if (host.kind !== 'connected') assert.fail('Host connection unavailable'); + let sourceTurnId: string; + let expectedSourceRevision: number; + try { + const session = await getRuntimeHostSession(host.connection, sessionId); + assert.ok(session); + expectedSourceRevision = session.revision; + const subscription = await host.connection.openSessionSubscription({ + sessionId, + transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, + }); + try { + assert.ok(subscription.snapshot.rootTurn); + sourceTurnId = subscription.snapshot.rootTurn.turnId; + } finally { + await subscription.close(); + } + } finally { + await host.connection.close(); + } + 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'); + assert.deepEqual( + await context.request('_maka/session/revision/abandon', { + targetSessionId: revisionTargetId, + }), + { kind: 'abandoned', sessionId: revisionTargetId }, + ); + 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 }); + }, + (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(); + } + }); }); async function startAcpModelFixture(): Promise<{ diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 8072ba00ec..4c2c40fea1 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -74,6 +74,105 @@ 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(), []); + await assertFixtureExited(root, 'fixture'); +}); + +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('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[] = [ diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index a49262f0ae..674de58297 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -48,6 +48,7 @@ import { type SessionContinuitySnapshot, type SubscriptionFrame, type SessionTranscriptPage, + type SessionTranscriptBootstrap, type SessionTranscriptPageInput, } from '@maka/runtime-host/protocol'; import { AcpSessionRegistry, type AcpSessionRegistryConnection } from '../acp/session-registry.js'; @@ -110,6 +111,899 @@ const DEFAULT_CONFIG_OPTIONS: Array { + 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: '/workspace', 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' }, + ); + 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: '/workspace', 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 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: '/workspace', 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('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, '/workspace', { + 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' : '/workspace', + 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: '/workspace' }, + 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(); + } + }); + + 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: '/workspace' }, + { + ...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: '/workspace', 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(); + subscription.transcriptPageGate = pageGate.promise; + 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: '/workspace', mcpServers: [] }, + promptContext(notifications), + ); + await waitFor(() => 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('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: '/workspace', 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(); + } + }); + + 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: '/workspace', 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: '/workspace', 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('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: '/workspace', 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('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: '/workspace', 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 keeps revision conflicts and unknown dispatched target identity without retrying', async () => { + const sourceSessionId = 'source-copy-conflict'; + const conflictedId = 'target-copy-conflict'; + const uncertainId = 'target-copy-uncertain'; + 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') { + const target = (input as { targetSessionId: string }).targetSessionId; + if (target === conflictedId) + throw new RuntimeHostOperationError( + operation, + 'operation_conflict', + 'Source revision changed', + ); + throw new RuntimeHostRequestInterruptedError( + operation, + 'command', + 'dispatched', + 'connection_lost', + ); + } + if (operation === 'session.catalog.query') + return { kind: 'session', session: catalogSession(uncertainId) }; + if (operation === 'session.configuration.update') + return { kind: 'committed', session: catalogSession(uncertainId) }; + throw new Error(`Unexpected operation ${operation}`); + }, + }), + }); + try { + await registry.create({ cwd: '/workspace', 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, + ); + assert.ok( + ( + await registry.setConfigOption({ + sessionId: uncertainId, + configId: 'collaboration_mode', + value: 'plan', + }) + ).configOptions.length > 0, + ); + assert.equal( + operations.filter((operation) => operation === 'session.branch.create').length, + 2, + ); + } finally { + await registry.dispose(); + } + }); + test('does not connect when disposed before a Session method is used', async () => { let connectCalls = 0; const registry = new AcpSessionRegistry({ @@ -3434,6 +4328,18 @@ function fakeConnection( ): AcpSessionRegistryConnection { return { reconnecting: true, + 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) => operation === 'connection.catalog.query' ? connectionCatalogPage(overrides.thinkingLevels ?? THINKING_LEVELS) @@ -3463,7 +4369,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 +4387,8 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< closeCalls = 0; nextCalls = 0; transcriptPageReads = 0; + transcriptPageSize = Number.POSITIVE_INFINITY; + transcriptEmptyFirstPage = false; transcriptPageGate?: Promise; #liveTranscript: StoredMessage[] = []; readonly #decodedPages = new WeakMap(); @@ -3492,6 +4400,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; } @@ -3668,6 +4592,22 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< ): Promise { this.transcriptPageReads += 1; 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 +4615,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; } 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/acp/README.md b/packages/cli/src/acp/README.md index 5b5cc521b5..449c0049c5 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -58,7 +58,11 @@ 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 parked plan is returned unchanged. A lost dispatched start returns `outcome_unknown` with the exact `turnId`; the adapter never retries that command. | +| 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. | +| 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 @@ -68,8 +72,23 @@ 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. +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. ## Tool output and completion diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 9b2f12053b..89ed8dec67 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -17,6 +17,40 @@ under the License. --> +# ACP validation record + +## 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 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 a revision, and abandoned that revision. 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. +Zed was launched with an isolated disposable project and user-data directory, +but its native window stopped accepting UI automation input. No third-party +client smoke result is claimed from that attempt; the official SDK real-process +test provides protocol boundary coverage. + # 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..fecf25bf3c 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -17,14 +17,32 @@ * under the License. */ -import { agent, methods, type AgentApp, type ClientCapabilities } from '@agentclientprotocol/sdk'; +import { + agent, + methods, + RequestError, + type AgentApp, + type ClientCapabilities, +} from '@agentclientprotocol/sdk'; +import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; import type { 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' + | 'branch' + | 'createRevision' + | 'abandonRevision' + | 'list' + | 'setConfigOption' + | 'prompt' + | 'cancel' + | 'close' >; } @@ -35,7 +53,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,6 +64,122 @@ 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, { + signal, + notify: (notification) => client.notify(methods.client.session.update, notification), + interactions: { + capabilities: clientCapabilities, + requestPermission: (request, cancellationSignal) => + client.request(methods.client.session.requestPermission, request, { + cancellationSignal, + }), + createElicitation: (request, cancellationSignal) => + client.request(methods.client.elicitation.create, request, { + cancellationSignal, + }), + }, + ...(clientCapabilities._meta?.['_maka/turnStatus'] === true + ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } + : {}), + }), + ) + .onRequest(methods.agent.session.resume, ({ params, signal, client }) => + options.sessionRegistry.resume(params, { + signal, + notify: (notification) => client.notify(methods.client.session.update, notification), + interactions: { + capabilities: clientCapabilities, + requestPermission: (request, cancellationSignal) => + client.request(methods.client.session.requestPermission, request, { + cancellationSignal, + }), + createElicitation: (request, cancellationSignal) => + client.request(methods.client.elicitation.create, request, { cancellationSignal }), + }, + ...(clientCapabilities._meta?.['_maka/turnStatus'] === true + ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } + : {}), + }), + ) + .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, { + signal, + notify: (notification) => client.notify(methods.client.session.update, notification), + interactions: { + capabilities: clientCapabilities, + requestPermission: (request, cancellationSignal) => + client.request(methods.client.session.requestPermission, request, { + cancellationSignal, + }), + createElicitation: (request, cancellationSignal) => + client.request(methods.client.elicitation.create, request, { cancellationSignal }), + }, + ...(clientCapabilities._meta?.['_maka/turnStatus'] === true + ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } + : {}), + }), + ) + .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), diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index 1ebc785d17..3ef3a74579 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,55 @@ 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') { + const text = userFacingText(message); + if (text) { + await this.#deliver({ + sessionId: this.#sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + messageId: message.id, + }, + }); + } + } 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', diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index 94fd6bd22a..e7dc68c723 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -18,7 +18,7 @@ */ 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 { McpClientManager } from '@maka/mcp'; import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; @@ -36,7 +36,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 +60,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,7 +99,8 @@ 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; + #config: McpConfigFile; + #configurationTail: Promise = Promise.resolve(); readonly #manager = new McpClientManager({ clientName: 'maka-acp', excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'], @@ -164,7 +170,45 @@ export class AcpSessionMcp { } } + get config(): McpConfigFile { + return structuredClone(this.#config); + } + + reconfigure(config: McpConfigFile, signal?: AbortSignal): Promise { + const task = this.#configurationTail + .catch(() => undefined) + .then(async () => { + this.#assertOpen('mcp.prepare'); + signal?.throwIfAborted(); + if (sameMcpConfig(this.#config, config)) return this.#settlePublication(signal); + const previous = this.#config; + try { + await this.#manager.sync(config); + signal?.throwIfAborted(); + this.#assertConnected(config); + this.#publication.request(); + await this.#settlePublication(signal); + this.#config = config; + } catch (error) { + if (!this.#closed) { + await this.#manager.sync(previous).catch(() => undefined); + this.#publication.request(); + await this.#publication.settle().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); @@ -207,10 +251,10 @@ export class AcpSessionMcp { return this.#closeTask; } - #assertConnected(): void { + #assertConnected(config = this.#config): void { this.#assertOpen('mcp.prepare'); if ( - Object.keys(this.#config.mcpServers).some( + Object.keys(config.mcpServers).some( (serverId) => this.#manager.status(serverId)?.state !== 'connected', ) ) { @@ -223,6 +267,19 @@ export class AcpSessionMcp { } } +function sameMcpConfig(left: McpConfigFile, right: McpConfigFile): boolean { + return stableConfigString(left) === stableConfigString(right); +} + +function stableConfigString(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableConfigString).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableConfigString(entry)}`).join(',')}}`; + } + return JSON.stringify(value); +} + 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..41f116788d 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -27,17 +27,19 @@ 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 { @@ -57,6 +59,11 @@ import { HOST_OPERATION_SPECS, type SessionCatalogProjection, type TurnSnapshot, + type TurnResumePlan, + type SessionConversationCopyInput, + type SessionConversationCopyResult, + type SessionRevisionAbandonInput, + type SessionRevisionAbandonResult, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { @@ -75,6 +82,7 @@ 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 } from './turn-observation.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; const ADMISSION_QUERY_MAX_ATTEMPTS = 5; @@ -89,7 +97,13 @@ type AcpSessionRegistryOperation = | 'artifact.ingest' | 'subscription.open' | 'turn.start' - | 'turn.stop'; + | 'turn.stop' + | 'turn.query' + | 'turn.resume.query' + | 'turn.resume.start' + | 'session.branch.create' + | 'session.revision.create' + | 'session.revision.abandon'; type AcpSessionRegistryLifecycleOperation = | 'connect' | 'session.close' @@ -112,6 +126,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,26 +152,16 @@ interface AcpAttachmentConfiguration { delivery?: Promise; } -interface ActiveAcpPrompt { - readonly sessionId: string; - readonly turnId: string; - readonly mapper: AcpSessionEventMapper; +type ActiveAcpPrompt = AcpTurnObservation & { 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; -} +}; /** Owns all Runtime Host resources associated with one ACP connection. */ export class AcpSessionRegistry { @@ -162,7 +178,13 @@ export class AcpSessionRegistry { readonly #attachmentConfigurations = new Map(); readonly #pendingConfigSets = new Map>>(); readonly #activePrompts = new Map>(); + readonly #turnObservations = new Map>(); + readonly #externalObservationContexts = new Map(); readonly #sessionCloseTasks = new Map>(); + readonly #sessionCloseGenerations = new Map(); + readonly #sessionLoadTails = new Map>(); + readonly #sessionLoadControllers = new Map(); + readonly #historyReplays = new Set(); #connection: AcpSessionRegistryConnection | undefined; #connectTask: Promise | undefined; #connectAbortController: AbortController | undefined; @@ -183,6 +205,33 @@ 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); + 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 ?? [] }); + 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,11 +285,57 @@ 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 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); const configuration = this.#attachmentConfigurations.get(params.sessionId); const delivery = configuration?.delivery; @@ -260,35 +355,31 @@ 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 = { - sessionId: params.sessionId, - turnId, - mapper: new AcpSessionEventMapper({ + const active: ActiveAcpPrompt = Object.assign( + new AcpTurnObservation({ sessionId: params.sessionId, + turnId, 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, - }; + { + waiters: new Set<() => void>(), + dispatchStarted: false, + startRequestSettled: false, + admissionSettled: false, + }, + ); + if (this.#historyReplays.has(params.sessionId)) void active.holdLive().catch(() => undefined); this.#addActivePrompt(active); const onAbort = () => { void this.#cancelPrompt(active).catch(() => undefined); @@ -345,8 +436,8 @@ 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); @@ -397,73 +488,19 @@ 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); } } - 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'; + return active.cancelledStopReason(); } #cancelSession(sessionId: string): Promise[]> { @@ -657,33 +694,35 @@ export class AcpSessionRegistry { }, }, 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) { + observation.projectionFailure ??= error; + observation.attachment.failTurn(observation.turnId, error); } else if (!attachment) failAttachment(error); }, onCancelled: (pending) => { + let localPrompt = false; for (const active of this.#activePrompts.get(sessionId) ?? []) { - if (active.turnId === pending.turnId) + 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,7 +733,12 @@ 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).catch((error: unknown) => { + console.error('[acp] Attached Turn observation failed:', error); + }); + }, onRuntimeResourceChanged: () => undefined, onSnapshotChanged: (snapshot) => { this.#wakeSession(sessionId); @@ -722,20 +766,17 @@ 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); - }); - } + 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 ( !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. @@ -746,17 +787,15 @@ export class AcpSessionRegistry { onInteractionResolved: (pending) => { 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); - } + void this.#observation(sessionId, turnId) + ?.reconcile() + .catch(() => undefined); }, onGoalChanged: () => undefined, onFailed: failAttachment, @@ -779,18 +818,22 @@ export class AcpSessionRegistry { } }, }) - .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) await this.#adoptTurn(sessionId, attachedTurnId, channel); + channel.activate( + attachedTurnId && this.#observation(sessionId, attachedTurnId) + ? attachedTurnId + : undefined, + ); return channel; }) .catch((error: unknown) => { @@ -833,11 +876,28 @@ export class AcpSessionRegistry { attachment.failTurn(active.turnId, error); this.#wake(active); } + for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { + if ( + observation.attachment !== attachment || + [...(this.#activePrompts.get(sessionId) ?? [])].includes(observation as ActiveAcpPrompt) + ) + 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); + this.#externalObservationContexts.delete(sessionId); + for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { + if ( + ![...(this.#activePrompts.get(sessionId) ?? [])].includes(observation as ActiveAcpPrompt) + ) { + observation.dispose(); + this.#removeTurnObservation(observation); + } + } this.#attachmentInteractions.get(sessionId)?.close(); this.#attachmentInteractions.delete(sessionId); const attachmentTask = this.#attachments.get(sessionId); @@ -868,18 +928,325 @@ export class AcpSessionRegistry { return {}; } + async #adoptTurn( + sessionId: string, + turnId: string, + attachment: RuntimeHostSessionChannel, + trackAdmission = false, + ): Promise { + const context = this.#externalObservationContexts.get(sessionId); + if (!context || this.#closing || !this.#ownedSessionIds.has(sessionId)) return; + if (this.#observation(sessionId, turnId)) return this.#observation(sessionId, turnId); + const observation = new AcpTurnObservation({ + sessionId, + turnId, + notify: async (notification) => { + const current = this.#externalObservationContexts.get(sessionId); + if (!this.#closing && this.#ownedSessionIds.has(sessionId) && current) { + await current.notify(notification); + } + }, + }); + if (this.#historyReplays.has(sessionId)) void observation.holdLive().catch(() => undefined); + const admission: ActiveAcpPrompt | undefined = trackAdmission + ? Object.assign(observation, { + waiters: new Set<() => void>(), + dispatchStarted: false, + startRequestSettled: false, + admissionSettled: false, + }) + : undefined; + if (admission) this.#addActivePrompt(admission); + else this.#setTurnObservation(observation); + try { + await observation.seed(attachment.messages); + if (this.#observation(sessionId, turnId) !== observation || this.#closing) return; + const task = observation.start(attachment); + void task + .then( + async () => { + const root = attachment.snapshot.rootTurn; + if (root?.turnId === turnId && isRuntimeHostTerminalTurn(root)) { + await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ + sessionId, + turnId, + runId: root.runId, + status: root.status, + ...(root.status === 'failed' ? { failureClass: root.failureClass } : {}), + }); + } + }, + async (error: unknown) => { + if (!this.#closing && !observation.finished) { + console.error('[acp] Attached Turn observation failed:', error); + const root = attachment.snapshot.rootTurn; + 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.dispatchStarted && !admission.startRequestSettled && !this.#closing) { + await this.#waitForPromptChange(admission); + } + await admission.stopTask?.catch(() => undefined); + this.#removeActivePrompt(admission); + } + this.#attachmentInteractions.get(sessionId)?.settleTurn(turnId); + observation.dispose(); + this.#removeTurnObservation(observation); + }); + return observation; + } catch (error) { + observation.dispose(); + if (admission) this.#removeActivePrompt(admission); + else this.#removeTurnObservation(observation); + throw error; + } + } + + 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 priorContext = this.#externalObservationContexts.get(params.sessionId); + this.#externalObservationContexts.set(params.sessionId, context); + let observation: ActiveAcpPrompt | 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); + observation = (await this.#adoptTurn(params.sessionId, turnId, attachment, true)) as + | ActiveAcpPrompt + | undefined; + if (!observation) throw registryClosedError('turn.resume.start'); + 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.dispatchStarted = true; + this.#wake(observation); + const result = await connection.request('turn.resume.start', { + sessionId: params.sessionId, + turnId, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }); + observation.startRequestSettled = true; + observation.admissionSettled = true; + this.#wake(observation); + if (result.kind === 'parked') { + observation.dispose(); + this.#removeActivePrompt(observation); + attachment.failTurn(turnId, new Error(`Turn resume parked: ${result.plan.reason}`)); + return { kind: 'parked' as const, plan: result.plan }; + } + observation.startedTurn = result.turn; + this.#wake(observation); + await observation.stopTask?.catch(() => undefined); + return { + kind: 'started' as const, + turn: result.turn, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }; + } catch (error) { + if (observation) { + observation.startRequestSettled = true; + observation.admissionSettled ||= !( + dispatched && + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' + ); + this.#wake(observation); + } + if ( + dispatched && + error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched' && + observation + ) { + this.#queryPromptAdmission(observation, connection); + await observation.admissionQuery; + if (observation.startedTurn) { + return { + kind: 'started' as const, + turn: observation.startedTurn, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + }; + } + throw 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) { + observation.dispose(); + this.#removeActivePrompt(observation); + attachment?.failTurn(turnId, error); + } + if (priorContext) this.#externalObservationContexts.set(params.sessionId, priorContext); + else this.#externalObservationContexts.delete(params.sessionId); + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'turn.resume.start', { turnId }); + } finally { + context.signal.removeEventListener('abort', onAbort); + } + } + + 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 + ) { + this.#ownedSessionIds.add(params.targetSessionId); + } + throw requestErrorFromRuntimeHost(error, operation, { + targetSessionId: params.targetSessionId, + }); + } + if (result.kind === 'committed' && !this.#closing) { + this.#ownedSessionIds.add(params.targetSessionId); + } + return result; + } + + 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.#externalObservationContexts.delete(params.targetSessionId); + for (const observation of this.#turnObservations.get(params.targetSessionId)?.values() ?? + []) { + observation.cancelled = true; + observation.dispose(); + this.#removeTurnObservation(observation); + } + for (const active of this.#activePrompts.get(params.targetSessionId) ?? []) { + active.cancelled = true; + this.#wake(active); + } + this.#attachmentInteractions.get(params.targetSessionId)?.close(); + this.#attachmentInteractions.delete(params.targetSessionId); + const attachment = this.#attachments.get(params.targetSessionId); + this.#attachments.delete(params.targetSessionId); + this.#attachmentOpenControllers.get(params.targetSessionId)?.abort(); + this.#attachmentConfigurations.delete(params.targetSessionId); + const mcp = this.#mcps.get(params.targetSessionId); + this.#mcps.delete(params.targetSessionId); + await Promise.allSettled([ + attachment?.then( + (channel) => channel.close(), + () => undefined, + ), + mcp?.close(), + ]); + } + return result; + } + #addActivePrompt(active: ActiveAcpPrompt): void { + this.#setTurnObservation(active); const prompts = this.#activePrompts.get(active.sessionId); if (prompts) prompts.add(active); else this.#activePrompts.set(active.sessionId, new Set([active])); } #removeActivePrompt(active: ActiveAcpPrompt): void { + this.#removeTurnObservation(active); const prompts = this.#activePrompts.get(active.sessionId); prompts?.delete(active); if (prompts?.size === 0) this.#activePrompts.delete(active.sessionId); } + #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); + } + + #observation(sessionId: string, turnId: string): AcpTurnObservation | undefined { + return this.#turnObservations.get(sessionId)?.get(turnId); + } + #wakeSession(sessionId: string): void { for (const active of this.#activePrompts.get(sessionId) ?? []) this.#wake(active); } @@ -965,6 +1332,186 @@ 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 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(); + const previousContext = this.#externalObservationContexts.get(params.sessionId); + const alreadyAttached = this.#attachments.has(params.sessionId); + const previousMcp = this.#mcps.get(params.sessionId); + const previousMcpConfig = previousMcp?.config; + let installedMcp: AcpSessionMcp | 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) { + await previousMcp.reconfigure(mcpConfig, lifetime); + } 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())); + } + this.#externalObservationContexts.set(params.sessionId, context); + const attachment = await this.#ensureAttachment(params.sessionId, connection, context); + 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: context.notify, + 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())); + } + return { configOptions }; + } catch (error) { + if (installedMcp && this.#mcps.get(params.sessionId) === installedMcp) { + this.#mcps.delete(params.sessionId); + await installedMcp.close().catch(() => undefined); + } else if ( + previousMcp && + previousMcpConfig && + this.#mcps.get(params.sessionId) === previousMcp + ) { + await previousMcp.reconfigure(previousMcpConfig).catch(() => undefined); + } + if (previousContext) this.#externalObservationContexts.set(params.sessionId, previousContext); + else this.#externalObservationContexts.delete(params.sessionId); + if (!alreadyOwned && this.#ownedSessionIds.has(params.sessionId)) { + this.#ownedSessionIds.delete(params.sessionId); + if (!alreadyAttached) { + this.#attachmentInteractions.get(params.sessionId)?.close(); + this.#attachmentInteractions.delete(params.sessionId); + const attachment = this.#attachments.get(params.sessionId); + this.#attachments.delete(params.sessionId); + await attachment + ?.then( + (channel) => channel.close(), + () => undefined, + ) + .catch(() => undefined); + } + } + 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(); + } + } + } + + #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,6 +1647,17 @@ export class AcpSessionRegistry { } async #dispose(): Promise { + this.#externalObservationContexts.clear(); + for (const observations of this.#turnObservations.values()) { + for (const observation of observations.values()) { + if ( + ![...(this.#activePrompts.get(observation.sessionId) ?? [])].includes( + observation as ActiveAcpPrompt, + ) + ) + observation.dispose(); + } + } const sessionIds = new Set([...this.#activePrompts.keys(), ...this.#attachments.keys()]); const activePrompts = [...sessionIds].flatMap((sessionId) => [ ...(this.#activePrompts.get(sessionId) ?? []), @@ -1234,7 +1792,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( diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts new file mode 100644 index 0000000000..a1fcb717a0 --- /dev/null +++ b/packages/cli/src/acp/turn-observation.ts @@ -0,0 +1,176 @@ +/* + * 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 { StopReason } from '@agentclientprotocol/sdk'; +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 mapper: AcpSessionEventMapper; + readonly projectionAbort = new AbortController(); + readonly reconciliationAbort = new AbortController(); + attachment?: RuntimeHostSessionChannel; + transcript?: ReturnType; + projectionFailure?: unknown; + cancelled = false; + finished = false; + #muteDepth = 0; + #liveGate?: { promise: Promise; release(): void }; + + constructor(options: { + sessionId: string; + turnId: string; + notify: ConstructorParameters[0]['notify']; + }) { + this.sessionId = options.sessionId; + this.turnId = options.turnId; + 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; + } + } + + 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'; + else if (event.type === 'error' && !event.recoverable) terminalStatus = 'failed'; + 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(); + } +} diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 14576bfdce..df6595986c 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -291,6 +291,91 @@ export class RuntimeHostSessionChannel { return this.#pendingStartedTurns.keys().next().value; } + /** 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) @@ -378,64 +463,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; From a9d33507d461de79f49fd8e5904a292b6d087da3 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:24:41 +0800 Subject: [PATCH 02/22] docs(acp): record Zed smoke and Desktop E2E Generated-by: OpenAI Codex --- packages/cli/src/acp/VALIDATION.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 89ed8dec67..833f104006 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -46,10 +46,15 @@ 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. -Zed was launched with an isolated disposable project and user-data directory, -but its native window stopped accepting UI automation input. No third-party -client smoke result is claimed from that attempt; the official SDK real-process -test provides protocol boundary coverage. + +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. # PR5 validation record From 378f93279faf0241fa9b90e3ccb9ba75e04cb33e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:31:47 +0800 Subject: [PATCH 03/22] test(acp): await transcript page entry deterministically Generated-by: OpenAI Codex --- .../cli/src/__tests__/acp-session-registry.test.ts | 11 +++++++++-- packages/cli/src/acp/VALIDATION.md | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 674de58297..8639e244a8 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -581,7 +581,9 @@ describe('ACP Session registry', () => { ); 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 () => @@ -599,7 +601,8 @@ describe('ACP Session registry', () => { { sessionId, cwd: '/workspace', mcpServers: [] }, promptContext(notifications), ); - await waitFor(() => subscription.transcriptPageReads === 1); + await pageRead.promise; + assert.equal(subscription.transcriptPageReads, 1); subscription.publishTranscript([ initial[0], { @@ -1365,8 +1368,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; @@ -1480,7 +1485,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 }); @@ -4387,6 +4392,7 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< closeCalls = 0; nextCalls = 0; transcriptPageReads = 0; + onTranscriptPageRead?: () => void; transcriptPageSize = Number.POSITIVE_INFINITY; transcriptEmptyFirstPage = false; transcriptPageGate?: Promise; @@ -4591,6 +4597,7 @@ 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 = { diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 833f104006..b3ded53541 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -56,6 +56,11 @@ 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 From 6d4b305c905274b5c5570098c461396d62f73a5e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:51:19 +0800 Subject: [PATCH 04/22] test(acp): cover restored turns and guard live MCP replacement Generated-by: OpenAI Codex --- .../__tests__/acp-child-process-harness.ts | 23 +- .../src/__tests__/acp-child-process.test.ts | 302 +++++++++++++++++- .../cli/src/__tests__/acp-session-mcp.test.ts | 37 +++ .../__tests__/acp-session-registry.test.ts | 117 ++++++- .../__tests__/acp-tools-child-process.test.ts | 112 +++++++ packages/cli/src/acp/README.md | 10 +- packages/cli/src/acp/VALIDATION.md | 21 +- packages/cli/src/acp/session-mcp.ts | 8 +- packages/cli/src/acp/session-registry.ts | 16 +- 9 files changed, 636 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/acp-child-process-harness.ts b/packages/cli/src/__tests__/acp-child-process-harness.ts index 177631d5b7..f951975d48 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[]; @@ -325,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, @@ -336,7 +346,7 @@ export async function startAcpChildProcessHarness( workspaceRoot, ); } catch (error) { - if (error instanceof StartupTimeoutError) { + if (error instanceof StartupTimeoutError && hostStartup) { rootCleanupFollowsHostStartup = true; void hostStartup .then( @@ -349,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( @@ -382,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'); @@ -424,6 +438,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 d07349def4..bf9919f3f2 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -28,6 +28,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; import { methods, type SessionNotification } from '@agentclientprotocol/sdk'; import { waitFor } from '@maka/core/test-only/async-primitives'; +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 { ARTIFACT_INGEST_CHUNK_MAX_BYTES, @@ -35,6 +40,9 @@ import { SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, } from '@maka/runtime-host/protocol'; import { getRuntimeHostSession } from '../runtime-host-session-update.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, @@ -728,11 +736,32 @@ describe('Maka ACP child process', () => { 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: 'abandoned', sessionId: 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, { @@ -742,6 +771,7 @@ describe('Maka ACP child process', () => { { 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) => @@ -763,17 +793,274 @@ describe('Maka ACP child process', () => { 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('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(); + } + }); }); +async function seedReadyAcpContinuation( + workspaceRoot: string, + modelConnectionId: 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.' }, + }); + const terminalAt = openedAt + 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) => { @@ -784,6 +1071,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; @@ -801,6 +1099,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-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 4c2c40fea1..5eb12c4a2f 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -138,6 +138,43 @@ test('failed Session MCP replacement restores the previous published configurati 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('Session MCP readiness waits for an in-flight replacement publication', { timeout: 20_000, }, async (t) => { diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 8639e244a8..b30aee9156 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; import { RequestError, @@ -211,6 +211,27 @@ describe('ACP Session registry', () => { ), { stopReason: 'end_turn' }, ); + const beforeSecondLoad = notifications.length; + await registry.load( + { sessionId, cwd: '/workspace', 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: '/workspace' }, promptContext(notifications)); + await registry.resume({ sessionId, cwd: '/workspace' }, promptContext(notifications)); + assert.equal(notifications.length, beforeResume); assert.equal(opens, 1); await registry.close({ sessionId }); assert.equal(subscription.closeCalls, 1); @@ -632,6 +653,100 @@ describe('ACP Session registry', () => { } }); + 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 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 assert.rejects( + registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])), + (error: unknown) => error instanceof RequestError, + ); + assert.equal(subscription.closeCalls, 1); + } finally { + 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'; 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..8d2c99201d 100644 --- a/packages/cli/src/__tests__/acp-tools-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-tools-child-process.test.ts @@ -498,6 +498,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 449c0049c5..43dd28e50c 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -61,7 +61,7 @@ or reconnection without waiting for the Host to become available. | 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 parked plan is returned unchanged. A lost dispatched start returns `outcome_unknown` with the exact `turnId`; the adapter never retries that command. | | 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. | +| 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 @@ -90,6 +90,14 @@ 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. + ## Tool output and completion ACP projects Runtime Host tool activity as `tool_call` followed by cumulative diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index b3ded53541..621fd8f88f 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -30,11 +30,30 @@ 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. 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 1184 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 a revision, and abandoned that revision. The real Host revision and +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 diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index e7dc68c723..7e6bf992f9 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -174,13 +174,19 @@ export class AcpSessionMcp { return structuredClone(this.#config); } - reconfigure(config: McpConfigFile, signal?: AbortSignal): Promise { + 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); + await assertCanChange?.(); + signal?.throwIfAborted(); const previous = this.#config; try { await this.#manager.sync(config); diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 41f116788d..be47e47a66 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -1391,7 +1391,21 @@ export class AcpSessionRegistry { throw unknownSessionError(); } if (previousMcp) { - await previousMcp.reconfigure(mcpConfig, lifetime); + 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.#activePrompts.get(params.sessionId)?.size ?? 0) > 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); From fd2e6a68c17ea2b490eb907b1264b4021d8e14a0 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:55:51 +0800 Subject: [PATCH 05/22] test(acp): verify incompatible resume contracts stay parked Generated-by: OpenAI Codex --- .../src/__tests__/acp-child-process.test.ts | 100 +++++++++++++++++- packages/cli/src/acp/README.md | 2 +- packages/cli/src/acp/VALIDATION.md | 10 +- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index bf9919f3f2..ea1403e57d 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -28,6 +28,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, test } from 'node:test'; import { methods, 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, @@ -955,11 +956,73 @@ describe('Maka ACP child process', () => { 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' }); @@ -1016,7 +1079,42 @@ async function seedReadyAcpContinuation( author: 'user', content: { kind: 'text', text: 'Continue this interrupted request.' }, }); - const terminalAt = openedAt + 1; + 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, diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 43dd28e50c..8fdd5739ca 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -59,7 +59,7 @@ or reconnection without waiting for the Host to become available. | 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 | `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 parked plan is returned unchanged. A lost dispatched start returns `outcome_unknown` with the exact `turnId`; the adapter never retries that command. | +| 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. | | 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. | diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 621fd8f88f..b6e312d4ed 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -37,12 +37,14 @@ 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. 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 +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 1184 tests with 3 +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, From 62eea00efcf8efc98193f6ac714a7cf0a19b1c95 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:35:14 +0800 Subject: [PATCH 06/22] fix(acp): fence restored turns and MCP replacement Generated-by: Codex --- .../src/__tests__/acp-child-process.test.ts | 95 ++++- .../cli/src/__tests__/acp-session-mcp.test.ts | 59 +++ .../__tests__/acp-session-registry.test.ts | 345 +++++++++++++++++- packages/cli/src/acp/VALIDATION.md | 56 +++ packages/cli/src/acp/maka-acp-agent.ts | 105 +++--- packages/cli/src/acp/session-mcp.ts | 108 ++++-- packages/cli/src/acp/session-registry.ts | 106 ++++-- packages/cli/src/acp/turn-observation.ts | 3 + .../cli/src/mcp-capability-publication.ts | 16 +- .../client-capability-coordinator.test.ts | 77 ++++ .../client-capability-protocol.test.ts | 27 ++ .../src/client/client-capability-channel.ts | 2 + .../src/client/client-capability.ts | 1 + .../runtime-host/src/client/connection.ts | 9 +- .../src/protocol/client-capability.ts | 12 +- packages/runtime-host/src/protocol/index.ts | 4 +- .../server/client-capability-coordinator.ts | 16 + .../src/server/execution-composition.ts | 1 + .../src/server/root-turn-coordinator.ts | 8 + 19 files changed, 924 insertions(+), 126 deletions(-) diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index ea1403e57d..2739e787ee 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -26,7 +26,7 @@ import { join } from 'node:path'; import { PassThrough } from 'node:stream'; 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'; @@ -41,6 +41,7 @@ import { 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'; @@ -900,6 +901,98 @@ describe('Maka ACP child process', () => { } }); + 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('explicitly resumes a ready interrupted Turn through ACP and the real Host', { timeout: 45_000, }, async () => { diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 5eb12c4a2f..271720b0f3 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -107,6 +107,7 @@ test('Session MCP reconfiguration reuses equivalent processes and applies replac ); 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 }); await assertFixtureExited(root, 'fixture'); }); @@ -175,6 +176,64 @@ test('an active Turn conflict leaves the existing MCP process and publication in 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('Session MCP readiness waits for an in-flight replacement publication', { timeout: 20_000, }, async (t) => { diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index b30aee9156..6cc4aace7a 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -668,6 +668,8 @@ describe('ACP Session registry', () => { subscription.onTranscriptPageRead = () => { throw new Error('transcript page unavailable'); }; + const retrySubscription = new FakeSubscription(continuitySnapshot(sessionId)); + let opens = 0; const registry = new AcpSessionRegistry({ connect: async () => fakeConnection({ @@ -676,7 +678,8 @@ describe('ACP Session registry', () => { return { kind: 'session', session: catalogSession(sessionId) }; throw new Error(`Unexpected operation ${operation}`); }, - openSessionSubscriptionOnce: async () => subscription, + openSessionSubscriptionOnce: async () => + ++opens === 1 ? subscription : retrySubscription, }), }); try { @@ -685,7 +688,347 @@ describe('ACP Session registry', () => { (error: unknown) => error instanceof RequestError, ); assert.equal(subscription.closeCalls, 1); + await registry.load({ sessionId, cwd: '/workspace', 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: '/workspace', mcpServers: [] }); + await assert.rejects( + registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])), + ); + assert.equal(subscription.closeCalls, 1); + await registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + assert.equal(retrySubscription.closeCalls, 0); + } finally { + await registry.dispose(); + } + }); + + test('unsupported restored interaction stops its exact Host Turn', async () => { + 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 }> = []; + 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') 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: '/workspace' }, + { + ...promptContext([]), + notifyTurnStatus: async (status) => { + statuses.push(status.status); + }, + }, + ); + await waitFor(() => statuses.includes('observation_failed')); + await waitFor(() => stops.length === 1); + assert.deepEqual(stops, [{ sessionId, turnId, runId: turn.runId }]); + } finally { + await registry.dispose(); + } + }); + + for (const failure of ['unsupported-method', 'invalid-answer', 'output-failure'] as const) { + test(`restored ${failure} stops the adopted Host Turn without answering`, async () => { + 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; + 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: '/workspace' }, + { + ...promptContext([]), + notify: async () => { + if (failure === 'output-failure') throw new Error('Output failed'); + }, + notifyTurnStatus: async (status) => { + statuses.push(status.status); + }, + interactions: { + capabilities: { elicitation: { form: {} } }, + createElicitation: async () => { + 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(() => statuses.includes('observation_failed')); + await waitFor(() => stops === 1); + assert.equal(answers, 0); + } finally { + 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: '/workspace' }, + { + ...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: '/workspace' }, + { + ...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(); } }); diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index b6e312d4ed..ef958442fb 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,62 @@ # ACP validation record +## 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 diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index fecf25bf3c..d06369c734 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -21,11 +21,12 @@ import { agent, methods, RequestError, + type AgentContext, type AgentApp, type ClientCapabilities, } from '@agentclientprotocol/sdk'; import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; -import type { AcpSessionRegistry } from './session-registry.js'; +import type { AcpLoadContext, AcpSessionRegistry } from './session-registry.js'; export interface MakaAcpAgentOptions { readonly version: string; @@ -65,42 +66,16 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { options.sessionRegistry.create(params, signal), ) .onRequest(methods.agent.session.load, ({ params, signal, client }) => - options.sessionRegistry.load(params, { - signal, - notify: (notification) => client.notify(methods.client.session.update, notification), - interactions: { - capabilities: clientCapabilities, - requestPermission: (request, cancellationSignal) => - client.request(methods.client.session.requestPermission, request, { - cancellationSignal, - }), - createElicitation: (request, cancellationSignal) => - client.request(methods.client.elicitation.create, request, { - cancellationSignal, - }), - }, - ...(clientCapabilities._meta?.['_maka/turnStatus'] === true - ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } - : {}), - }), + options.sessionRegistry.load( + params, + sessionContext(client, signal, clientCapabilities, true), + ), ) .onRequest(methods.agent.session.resume, ({ params, signal, client }) => - options.sessionRegistry.resume(params, { - signal, - notify: (notification) => client.notify(methods.client.session.update, notification), - interactions: { - capabilities: clientCapabilities, - requestPermission: (request, cancellationSignal) => - client.request(methods.client.session.requestPermission, request, { - cancellationSignal, - }), - createElicitation: (request, cancellationSignal) => - client.request(methods.client.elicitation.create, request, { cancellationSignal }), - }, - ...(clientCapabilities._meta?.['_maka/turnStatus'] === true - ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } - : {}), - }), + options.sessionRegistry.resume( + params, + sessionContext(client, signal, clientCapabilities, true), + ), ) .onRequest( '_maka/turn/resume', @@ -115,22 +90,10 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { } }, ({ params, signal, client }) => - options.sessionRegistry.resumeTurn(params, { - signal, - notify: (notification) => client.notify(methods.client.session.update, notification), - interactions: { - capabilities: clientCapabilities, - requestPermission: (request, cancellationSignal) => - client.request(methods.client.session.requestPermission, request, { - cancellationSignal, - }), - createElicitation: (request, cancellationSignal) => - client.request(methods.client.elicitation.create, request, { cancellationSignal }), - }, - ...(clientCapabilities._meta?.['_maka/turnStatus'] === true - ? { notifyTurnStatus: (status) => client.notify('_maka/turn/status', status) } - : {}), - }), + options.sessionRegistry.resumeTurn( + params, + sessionContext(client, signal, clientCapabilities, true), + ), ) .onRequest( '_maka/session/branch/create', @@ -185,22 +148,36 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { 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-mcp.ts b/packages/cli/src/acp/session-mcp.ts index 7e6bf992f9..d81cc13515 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -20,13 +20,15 @@ import { isAbsolute } from 'node:path'; 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'; @@ -101,13 +103,16 @@ export class AcpSessionMcp { readonly #sessionId: string; #config: McpConfigFile; #configurationTail: Promise = Promise.resolve(); - readonly #manager = new McpClientManager({ + #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; + #publicationRevision = 0; + #requireIdlePublication = false; #availability: RuntimeHostConnectionAvailability | undefined; #prepared = false; #closed = false; @@ -122,7 +127,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', @@ -134,13 +139,15 @@ export class AcpSessionMcp { offers: () => [], currentRegistrationRetired: () => this.#retire(), }, - replace: (provider) => connection.replaceClientCapabilities(provider, { sessionId }), + replace: (provider) => + connection.replaceClientCapabilities(provider, { + sessionId, + ...(this.#requireIdlePublication ? { requireIdleSession: true } : {}), + }), unregister: () => connection.unregisterClientCapabilities({ sessionId }), onState: () => undefined, }); - this.#unsubscribeManager = this.#manager.onChange(() => { - if (this.#prepared && !this.#closed) this.#publication.request(); - }); + this.#unsubscribeManager = this.#observeManager(this.#manager); this.#unsubscribeConnection = connection.subscribeConnectionAvailability((availability) => { this.#availability = availability; if (availability.kind !== 'connected') this.#publication.invalidate(); @@ -187,20 +194,50 @@ export class AcpSessionMcp { if (sameMcpConfig(this.#config, config)) return this.#settlePublication(signal); await assertCanChange?.(); signal?.throwIfAborted(); - const previous = this.#config; + const previous = this.#manager; + 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 this.#manager.sync(config); + await staged.sync(config); signal?.throwIfAborted(); - this.#assertConnected(config); + this.#assertConnected(config, staged); + previousRevision = this.#publicationRevision; + this.#unsubscribeManager(); + this.#manager = staged; + this.#unsubscribeManager = this.#observeManager(staged); + swapped = true; + this.#requireIdlePublication = true; + this.#publicationRevision += 1; this.#publication.request(); await this.#settlePublication(signal); this.#config = config; + this.#requireIdlePublication = false; + this.#pendingManager = undefined; + await previous.close().catch((error: unknown) => { + console.error('[acp] Previous Session MCP cleanup failed:', error); + }); } catch (error) { - if (!this.#closed) { - await this.#manager.sync(previous).catch(() => undefined); + this.#requireIdlePublication = false; + if (swapped && !this.#closed) { + this.#unsubscribeManager(); + this.#manager = previous; + this.#unsubscribeManager = this.#observeManager(previous); + // If the staged snapshot never committed, restore the revision so + // publication reuses the still-current provider without a Host write. + this.#publicationRevision = + this.#publication.publishedRevision === previousRevision + ? previousRevision + : this.#publicationRevision + 1; this.#publication.request(); await this.#publication.settle().catch(() => undefined); } + this.#pendingManager = undefined; + await staged.close().catch(() => undefined); if (error instanceof RequestError) throw error; throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_reconfiguration_failed'); } @@ -221,6 +258,21 @@ export class AcpSessionMcp { signal?.throwIfAborted(); this.#assertOpen('mcp.ready'); if (state !== 'published' && state !== 'not_published') { + 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'); } } @@ -239,7 +291,7 @@ 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.#closeTask = (async () => { try { try { @@ -257,11 +309,18 @@ export class AcpSessionMcp { return this.#closeTask; } - #assertConnected(config = this.#config): void { + #observeManager(manager: McpClientManager): () => void { + return manager.onChange(() => { + this.#publicationRevision += 1; + if (this.#prepared && !this.#closed) this.#publication.request(); + }); + } + + #assertConnected(config = this.#config, manager = this.#manager): void { this.#assertOpen('mcp.prepare'); if ( Object.keys(config.mcpServers).some( - (serverId) => this.#manager.status(serverId)?.state !== 'connected', + (serverId) => manager.status(serverId)?.state !== 'connected', ) ) { throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_not_ready'); @@ -274,16 +333,7 @@ export class AcpSessionMcp { } function sameMcpConfig(left: McpConfigFile, right: McpConfigFile): boolean { - return stableConfigString(left) === stableConfigString(right); -} - -function stableConfigString(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(stableConfigString).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); - return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableConfigString(entry)}`).join(',')}}`; - } - return JSON.stringify(value); + return stableJsonStringify(left) === stableJsonStringify(right); } function invalidMcpInput(reason: string): RequestError { diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index be47e47a66..bba2b566f0 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -520,11 +520,7 @@ 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, - }); + await this.#stopAttachedTurn(opened, root); } }, () => undefined, @@ -534,6 +530,37 @@ export class AcpSessionRegistry { return Promise.allSettled(cancellations); } + #stopAttachedTurn(attachment: RuntimeHostSessionChannel, root: TurnSnapshot): Promise { + const current = attachment.snapshot.rootTurn; + if ( + !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, + }) + .then(() => undefined) ?? Promise.resolve() + ); + } + async #cancelPrompt(active: ActiveAcpPrompt): Promise { active.cancelled = true; this.#wake(active); @@ -735,9 +762,11 @@ export class AcpSessionRegistry { now: Date.now, onTurnStarted: (turn) => { if (attachment) - void this.#adoptTurn(sessionId, turn.turnId, attachment).catch((error: unknown) => { - console.error('[acp] Attached Turn observation failed:', error); - }); + void this.#adoptTurn(sessionId, turn.turnId, attachment, false, turn.runId).catch( + (error: unknown) => { + console.error('[acp] Attached Turn observation failed:', error); + }, + ); }, onRuntimeResourceChanged: () => undefined, onSnapshotChanged: (snapshot) => { @@ -828,7 +857,16 @@ export class AcpSessionRegistry { await channel.close(); throw this.#closing ? registryClosedError('subscription.open') : unknownSessionError(); } - if (attachedTurnId) await this.#adoptTurn(sessionId, attachedTurnId, channel); + if (attachedTurnId) { + const root = channel.snapshot.rootTurn; + await this.#adoptTurn( + sessionId, + attachedTurnId, + channel, + false, + root?.turnId === attachedTurnId ? root.runId : undefined, + ); + } channel.activate( attachedTurnId && this.#observation(sessionId, attachedTurnId) ? attachedTurnId @@ -933,13 +971,20 @@ export class AcpSessionRegistry { turnId: string, attachment: RuntimeHostSessionChannel, trackAdmission = false, + expectedRunId?: string, ): Promise { const context = this.#externalObservationContexts.get(sessionId); if (!context || this.#closing || !this.#ownedSessionIds.has(sessionId)) return; if (this.#observation(sessionId, turnId)) return this.#observation(sessionId, turnId); + const runId = + expectedRunId ?? + (attachment.snapshot.rootTurn?.turnId === turnId + ? attachment.snapshot.rootTurn.runId + : undefined); const observation = new AcpTurnObservation({ sessionId, turnId, + ...(runId === undefined ? {} : { runId }), notify: async (notification) => { const current = this.#externalObservationContexts.get(sessionId); if (!this.#closing && this.#ownedSessionIds.has(sessionId) && current) { @@ -980,6 +1025,17 @@ export class AcpSessionRegistry { if (!this.#closing && !observation.finished) { 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) + ) { + void this.#track(this.#stopAttachedTurn(attachment, root)).catch( + (stopError: unknown) => { + console.error('[acp] Host Stop delivery failed:', stopError); + }, + ); + } await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ sessionId, turnId, @@ -1353,10 +1409,10 @@ export class AcpSessionRegistry { const alreadyOwned = this.#ownedSessionIds.has(params.sessionId); const heldObservations = new Set(); const previousContext = this.#externalObservationContexts.get(params.sessionId); - const alreadyAttached = this.#attachments.has(params.sessionId); const previousMcp = this.#mcps.get(params.sessionId); const previousMcpConfig = previousMcp?.config; let installedMcp: AcpSessionMcp | undefined; + let newAttachment: Promise | undefined; try { lifetime.throwIfAborted(); const connection = await this.#getConnection('session.catalog.query'); @@ -1423,7 +1479,10 @@ export class AcpSessionRegistry { await Promise.all([...heldObservations].map((observation) => observation.holdLive())); } this.#externalObservationContexts.set(params.sessionId, context); - const attachment = await this.#ensureAttachment(params.sessionId, connection, context); + const creatingAttachment = !this.#attachments.has(params.sessionId); + const attachmentPromise = this.#ensureAttachment(params.sessionId, connection, context); + if (creatingAttachment) newAttachment = this.#attachments.get(params.sessionId); + const attachment = await attachmentPromise; lifetime.throwIfAborted(); this.#assertOpen('subscription.open'); this.#assertOwned(params.sessionId); @@ -1480,21 +1539,18 @@ export class AcpSessionRegistry { } if (previousContext) this.#externalObservationContexts.set(params.sessionId, previousContext); else this.#externalObservationContexts.delete(params.sessionId); - if (!alreadyOwned && this.#ownedSessionIds.has(params.sessionId)) { - this.#ownedSessionIds.delete(params.sessionId); - if (!alreadyAttached) { - this.#attachmentInteractions.get(params.sessionId)?.close(); - this.#attachmentInteractions.delete(params.sessionId); - const attachment = this.#attachments.get(params.sessionId); - this.#attachments.delete(params.sessionId); - await attachment - ?.then( - (channel) => channel.close(), - () => undefined, - ) - .catch(() => undefined); - } + if (newAttachment && this.#attachments.get(params.sessionId) === newAttachment) { + this.#attachmentInteractions.get(params.sessionId)?.close(); + this.#attachmentInteractions.delete(params.sessionId); + this.#attachments.delete(params.sessionId); + await newAttachment + ?.then( + (channel) => channel.close(), + () => undefined, + ) + .catch(() => undefined); } + if (!alreadyOwned) this.#ownedSessionIds.delete(params.sessionId); if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'subscription.open'); } finally { diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index a1fcb717a0..abd9d0bf2d 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -27,6 +27,7 @@ import { AcpSessionEventMapper } from './session-event-mapper.js'; export class AcpTurnObservation { readonly sessionId: string; readonly turnId: string; + readonly runId?: string; readonly mapper: AcpSessionEventMapper; readonly projectionAbort = new AbortController(); readonly reconciliationAbort = new AbortController(); @@ -41,10 +42,12 @@ export class AcpTurnObservation { 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) => { diff --git a/packages/cli/src/mcp-capability-publication.ts b/packages/cli/src/mcp-capability-publication.ts index 53a354c661..7a2f8ebb8e 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; @@ -69,6 +70,17 @@ export class McpCapabilityPublication { 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 +135,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 +146,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/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index e88185560d..12f08f285a 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,83 @@ import { } from './fixtures/client-capability.js'; describe('Host Client Capability coordinator', () => { + 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..69d4afac6c 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,37 @@ import { CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, decodeClientCapabilityResult, decodeClientFrame, + decodeClientCapabilityReplaceInput, decodeHostFrame, } from '../protocol/index.js'; describe('Client Capability protocol', () => { + 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..6154ebe2f0 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -105,6 +105,7 @@ export class ClientCapabilityChannel { provider: ClientCapabilityProvider, timeoutMs: number, sessionId?: string, + requireIdleSession = false, ): Promise { this.#assertOpen(); if (this.#pendingMutations.has(sessionId)) { @@ -118,6 +119,7 @@ export class ClientCapabilityChannel { const canonical = decodeClientCapabilityReplaceInput({ registrationId, ...(sessionId === undefined ? {} : { sessionId }), + ...(requireIdleSession ? { requireIdleSession: true } : {}), 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..df3cbdc0c2 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -30,6 +30,7 @@ import type { export interface ClientCapabilityRegistrationOptions { readonly sessionId?: string; readonly timeoutMs?: number; + readonly requireIdleSession?: boolean; } /** 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..af73173782 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -705,9 +705,12 @@ 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, + } = typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); + return this.#clientCapabilities.replace(provider, timeoutMs, sessionId, requireIdleSession); } async unregisterClientCapabilities( diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 7a148d1a6c..7ba62a81e6 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -103,6 +103,7 @@ const CLIENT_CAPABILITY_ERRORS = [ 'host_draining', 'operation_unavailable', 'invalid_request', + 'session_busy', 'internal_failure', ] as const; @@ -127,6 +128,8 @@ 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; readonly offers: readonly ClientCapabilityOffer[]; readonly services?: readonly ClientCapabilityServiceOffer[]; } @@ -305,7 +308,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi record, 'Client Capability replacement', ['registrationId', 'offers'], - ['services', 'sessionId'], + ['services', 'sessionId', 'requireIdleSession'], ); if (!Array.isArray(record.offers) || record.offers.length > CLIENT_CAPABILITY_MAX_OFFERS) { throw invalidProtocolFrame('Invalid Client Capability offers'); @@ -324,6 +327,12 @@ 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 ( sessionId !== undefined && (services.length > 0 || @@ -365,6 +374,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi const decoded = { registrationId: requireEntityId(record.registrationId, 'registrationId'), ...(sessionId === undefined ? {} : { sessionId }), + ...(record.requireIdleSession === true ? { requireIdleSession: true } : {}), offers, ...(record.services === undefined ? {} : { services }), }; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9c516f1d7d..7e81f246a8 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 = 178 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 179 as const; +// 179: An opt-in Session capability replacement can require an atomic idle +// root check. Older Hosts would ignore that protection during MCP reconfiguration. // 178: WorkHub result turns carry a Host-owned workhub_result origin. Older // Clients reject that origin and cannot render the result notification. // 177: External Session import input may carry an optional Host-resolved diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 9f031aa4dc..44b99d22f3 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -183,6 +183,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 +219,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 +238,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; @@ -970,6 +975,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 { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 8953876687..e496381b61 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1529,6 +1529,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 61c45cf5eb..59e95b62d7 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -666,6 +666,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); } From 813c9557d79414646b9a6555641bcf4466a0308e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:07:03 +0800 Subject: [PATCH 07/22] fix(acp): preserve live turns and MCP providers during recovery Generated-by: Codex --- .../src/__tests__/acp-child-process.test.ts | 109 +++++++++ .../cli/src/__tests__/acp-session-mcp.test.ts | 225 +++++++++++++++++- .../__tests__/acp-session-registry.test.ts | 47 ++++ packages/cli/src/acp/VALIDATION.md | 47 ++++ packages/cli/src/acp/session-mcp.ts | 82 +++++-- packages/cli/src/acp/session-registry.ts | 67 ++++-- .../cli/src/mcp-capability-publication.ts | 5 + 7 files changed, 545 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 2739e787ee..84a1b08e00 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -993,6 +993,115 @@ describe('Maka ACP child process', () => { } }); + 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 () => { diff --git a/packages/cli/src/__tests__/acp-session-mcp.test.ts b/packages/cli/src/__tests__/acp-session-mcp.test.ts index 271720b0f3..3704b11925 100644 --- a/packages/cli/src/__tests__/acp-session-mcp.test.ts +++ b/packages/cli/src/__tests__/acp-session-mcp.test.ts @@ -111,6 +111,44 @@ test('Session MCP reconfiguration reuses equivalent processes and applies replac 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) => { @@ -234,6 +272,186 @@ test('a Turn admitted after the idle query cannot lose its previous MCP provider }); }); +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) => { @@ -912,7 +1130,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')); @@ -927,7 +1148,7 @@ async function invokeEnvironment(provider: ClientCapabilityProvider): Promise { } }); + 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: '/workspace', 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: '/workspace', mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + assert.equal(retrySubscription.closeCalls, 0); + assert.deepEqual(stops, []); + } finally { + await registry.dispose(); + } + }); + test('unsupported restored interaction stops its exact Host Turn', async () => { const sessionId = 'unsupported-restored', turnId = 'restored-turn'; diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index ef958442fb..a3f8c6c539 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,53 @@ # ACP validation record +## 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 diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index d81cc13515..01a0886257 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -111,6 +111,8 @@ export class AcpSessionMcp { #unsubscribeManager: () => void; readonly #unsubscribeConnection: () => void; #pendingManager: McpClientManager | undefined; + readonly #retainedManagers = new Set(); + readonly #retirementTasks = new Set>(); #publicationRevision = 0; #requireIdlePublication = false; #availability: RuntimeHostConnectionAvailability | undefined; @@ -145,7 +147,15 @@ export class AcpSessionMcp { ...(this.#requireIdlePublication ? { requireIdleSession: true } : {}), }), unregister: () => connection.unregisterClientCapabilities({ sessionId }), - onState: () => undefined, + 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) => { @@ -192,6 +202,7 @@ export class AcpSessionMcp { 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; @@ -210,6 +221,8 @@ export class AcpSessionMcp { this.#unsubscribeManager(); this.#manager = staged; this.#unsubscribeManager = this.#observeManager(staged); + this.#retainedManagers.add(previous); + this.#pendingManager = undefined; swapped = true; this.#requireIdlePublication = true; this.#publicationRevision += 1; @@ -217,27 +230,38 @@ export class AcpSessionMcp { await this.#settlePublication(signal); this.#config = config; this.#requireIdlePublication = false; - this.#pendingManager = undefined; - await previous.close().catch((error: unknown) => { - console.error('[acp] Previous Session MCP cleanup failed:', error); - }); + this.#retireRetainedManagers(); + await Promise.allSettled([...this.#retirementTasks]); } catch (error) { - this.#requireIdlePublication = false; if (swapped && !this.#closed) { - this.#unsubscribeManager(); - this.#manager = previous; - this.#unsubscribeManager = this.#observeManager(previous); - // If the staged snapshot never committed, restore the revision so - // publication reuses the still-current provider without a Host write. - this.#publicationRevision = - this.#publication.publishedRevision === previousRevision - ? previousRevision - : this.#publicationRevision + 1; - this.#publication.request(); - await this.#publication.settle().catch(() => undefined); + // 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.#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.#config = config; + this.#retireRetainedManagers(); + } + } else if (!swapped) { + this.#pendingManager = undefined; + await staged.close().catch(() => undefined); } - this.#pendingManager = undefined; - await staged.close().catch(() => undefined); if (error instanceof RequestError) throw error; throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_reconfiguration_failed'); } @@ -291,7 +315,12 @@ export class AcpSessionMcp { this.#closed = true; this.#unsubscribeManager(); this.#unsubscribeConnection(); - const managerClose = Promise.allSettled([this.#manager.close(), this.#pendingManager?.close()]); + const managerClose = Promise.allSettled([ + this.#manager.close(), + this.#pendingManager?.close(), + ...[...this.#retainedManagers].map((manager) => manager.close()), + ...this.#retirementTasks, + ]); this.#closeTask = (async () => { try { try { @@ -316,6 +345,19 @@ export class AcpSessionMcp { }); } + #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 ( diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index bba2b566f0..73ac96cafb 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -179,6 +179,7 @@ export class AcpSessionRegistry { readonly #pendingConfigSets = new Map>>(); readonly #activePrompts = new Map>(); readonly #turnObservations = new Map>(); + readonly #discardedAttachments = new WeakSet(); readonly #externalObservationContexts = new Map(); readonly #sessionCloseTasks = new Map>(); readonly #sessionCloseGenerations = new Map(); @@ -770,6 +771,7 @@ export class AcpSessionRegistry { }, 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); @@ -795,6 +797,7 @@ export class AcpSessionRegistry { }); }, onTranscriptReplaced: (turnId, messages) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; const observation = this.#observation(sessionId, turnId); if (observation && !observation.cancelled) { void observation.replaceTranscript(messages).catch((error: unknown) => { @@ -803,6 +806,7 @@ export class AcpSessionRegistry { } }, onInteractionPending: (pending) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; if ( !interactions.fencesTurn(pending.turnId) && !this.#observation(sessionId, pending.turnId) @@ -814,6 +818,7 @@ export class AcpSessionRegistry { void interactions.pending(pending); }, onInteractionResolved: (pending) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; if ( !interactions.fencesTurn(pending.turnId) && !this.#observation(sessionId, pending.turnId) @@ -822,6 +827,7 @@ export class AcpSessionRegistry { void interactions.resolved(pending); }, onTranscriptSettlement: (turnId) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; void this.#observation(sessionId, turnId) ?.reconcile() .catch(() => undefined); @@ -974,7 +980,13 @@ export class AcpSessionRegistry { expectedRunId?: string, ): Promise { const context = this.#externalObservationContexts.get(sessionId); - if (!context || this.#closing || !this.#ownedSessionIds.has(sessionId)) return; + if ( + !context || + this.#closing || + this.#discardedAttachments.has(attachment) || + !this.#ownedSessionIds.has(sessionId) + ) + return; if (this.#observation(sessionId, turnId)) return this.#observation(sessionId, turnId); const runId = expectedRunId ?? @@ -1005,11 +1017,27 @@ export class AcpSessionRegistry { else this.#setTurnObservation(observation); try { await observation.seed(attachment.messages); - if (this.#observation(sessionId, turnId) !== observation || this.#closing) return; + if ( + this.#observation(sessionId, turnId) !== observation || + this.#closing || + this.#discardedAttachments.has(attachment) + ) { + observation.dispose(); + if (admission) this.#removeActivePrompt(admission); + else this.#removeTurnObservation(observation); + return; + } const task = observation.start(attachment); + 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 = attachment.snapshot.rootTurn; if (root?.turnId === turnId && isRuntimeHostTerminalTurn(root)) { await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ @@ -1022,7 +1050,12 @@ export class AcpSessionRegistry { } }, async (error: unknown) => { - if (!this.#closing && !observation.finished) { + 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 ( @@ -1056,7 +1089,7 @@ export class AcpSessionRegistry { await admission.stopTask?.catch(() => undefined); this.#removeActivePrompt(admission); } - this.#attachmentInteractions.get(sessionId)?.settleTurn(turnId); + interactions?.settleTurn(turnId); observation.dispose(); this.#removeTurnObservation(observation); }); @@ -1527,6 +1560,21 @@ export class AcpSessionRegistry { } return { configOptions }; } catch (error) { + 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.#attachmentInteractions.get(params.sessionId)?.close(); + this.#attachmentInteractions.delete(params.sessionId); + this.#attachments.delete(params.sessionId); + await channel?.close().catch(() => undefined); + } if (installedMcp && this.#mcps.get(params.sessionId) === installedMcp) { this.#mcps.delete(params.sessionId); await installedMcp.close().catch(() => undefined); @@ -1539,17 +1587,6 @@ export class AcpSessionRegistry { } if (previousContext) this.#externalObservationContexts.set(params.sessionId, previousContext); else this.#externalObservationContexts.delete(params.sessionId); - if (newAttachment && this.#attachments.get(params.sessionId) === newAttachment) { - this.#attachmentInteractions.get(params.sessionId)?.close(); - this.#attachmentInteractions.delete(params.sessionId); - this.#attachments.delete(params.sessionId); - await newAttachment - ?.then( - (channel) => channel.close(), - () => undefined, - ) - .catch(() => undefined); - } if (!alreadyOwned) this.#ownedSessionIds.delete(params.sessionId); if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'subscription.open'); diff --git a/packages/cli/src/mcp-capability-publication.ts b/packages/cli/src/mcp-capability-publication.ts index 7a2f8ebb8e..86c13a29b1 100644 --- a/packages/cli/src/mcp-capability-publication.ts +++ b/packages/cli/src/mcp-capability-publication.ts @@ -66,6 +66,11 @@ 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; } From 10b2990af741d7fb36f3f361b020a8410e450b88 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:55:31 +0800 Subject: [PATCH 08/22] fix(acp): settle rejected resume and preserve attached turn status Generated-by: Codex --- .../__tests__/acp-session-registry.test.ts | 143 ++++++++++++++++++ packages/cli/src/acp/VALIDATION.md | 32 ++++ packages/cli/src/acp/session-registry.ts | 40 ++++- packages/cli/src/acp/turn-observation.ts | 2 + 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 36bc74da13..9bb8edabe5 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -1303,6 +1303,149 @@ describe('ACP Session registry', () => { } }); + 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: '/workspace' }, + { + ...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'; diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index a3f8c6c539..5873d33b0f 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,38 @@ # ACP validation record +## 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 diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 73ac96cafb..14648b2a02 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -157,6 +157,7 @@ type ActiveAcpPrompt = AcpTurnObservation & { dispatchStarted: boolean; startRequestSettled: boolean; admissionSettled: boolean; + admissionRejected?: boolean; admissionQuery?: Promise; admissionFailure?: RequestError; startedTurn?: TurnSnapshot; @@ -621,8 +622,8 @@ export class AcpSessionRegistry { #queryPromptAdmission(active: ActiveAcpPrompt, connection: AcpSessionRegistryConnection): void { // Recovery and the lost start response can both request this read. Keep one - // bounded retry task; neither a healthy subscription nor a failed query - // establishes whether a dispatched start was admitted. + // bounded retry task; neither a healthy subscription nor a query failure + // other than not_found establishes whether a dispatched start was admitted. active.admissionQuery ??= this.#readPromptAdmission(active, connection); } @@ -654,6 +655,9 @@ export class AcpSessionRegistry { return; } catch (error) { if (error instanceof RuntimeHostOperationError && error.code === 'not_found') { + const observed = active.attachment?.snapshot.rootTurn; + if (observed?.turnId === active.turnId) active.startedTurn = observed; + else if (!active.startedTurn) active.admissionRejected = true; active.admissionSettled = true; this.#wake(active); return; @@ -776,6 +780,23 @@ export class AcpSessionRegistry { if (snapshot.rootTurn && isRuntimeHostTerminalTurn(snapshot.rootTurn)) { interactions.terminalTurn(snapshot.rootTurn.turnId); } + const root = snapshot.rootTurn; + const observation = root && this.#observation(sessionId, root.turnId); + const active = this.#activePrompts.get(sessionId); + const prompt = + observation && active?.has(observation as ActiveAcpPrompt) + ? (observation as ActiveAcpPrompt) + : undefined; + const observedRunId = + observation?.runId ?? prompt?.startedTurn?.runId ?? observation?.terminalTurn?.runId; + if (root && observation && (observedRunId === undefined || observedRunId === root.runId)) { + if (isRuntimeHostTerminalTurn(root)) observation.terminalTurn = root; + if (prompt) { + prompt.startedTurn ??= root; + prompt.admissionSettled = true; + this.#wake(prompt); + } + } if (configuration.metadataRevision === undefined) { configuration.metadataRevision = snapshot.session.metadataRevision; return; @@ -1038,8 +1059,8 @@ export class AcpSessionRegistry { this.#discardedAttachments.has(attachment) ) return; - const root = attachment.snapshot.rootTurn; - if (root?.turnId === turnId && isRuntimeHostTerminalTurn(root)) { + const root = observation.terminalTurn; + if (root) { await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ sessionId, turnId, @@ -1168,6 +1189,7 @@ export class AcpSessionRegistry { sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }; } catch (error) { + let failure = error; if (observation) { observation.startRequestSettled = true; observation.admissionSettled ||= !( @@ -1193,7 +1215,7 @@ export class AcpSessionRegistry { sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }; } - throw RequestError.internalError( + const admissionError = RequestError.internalError( { source: 'runtime_host', operation: 'turn.resume.start', @@ -1204,16 +1226,18 @@ export class AcpSessionRegistry { }, 'Runtime Host Turn resume admission could not be established', ); + if (!observation.admissionRejected) throw admissionError; + failure = admissionError; } if (observation) { observation.dispose(); this.#removeActivePrompt(observation); - attachment?.failTurn(turnId, error); + attachment?.failTurn(turnId, failure); } if (priorContext) this.#externalObservationContexts.set(params.sessionId, priorContext); else this.#externalObservationContexts.delete(params.sessionId); - if (error instanceof RequestError) throw error; - throw requestErrorFromRuntimeHost(error, 'turn.resume.start', { turnId }); + if (failure instanceof RequestError) throw failure; + throw requestErrorFromRuntimeHost(failure, 'turn.resume.start', { turnId }); } finally { context.signal.removeEventListener('abort', onAbort); } diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index abd9d0bf2d..60bae9a4ef 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -20,6 +20,7 @@ import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; import type { StopReason } from '@agentclientprotocol/sdk'; +import type { RuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { AcpSessionEventMapper } from './session-event-mapper.js'; @@ -34,6 +35,7 @@ export class AcpTurnObservation { attachment?: RuntimeHostSessionChannel; transcript?: ReturnType; projectionFailure?: unknown; + terminalTurn?: RuntimeHostTerminalTurn; cancelled = false; finished = false; #muteDepth = 0; From f0c507a45b275b2dd44f3486945bb27061d8fbe5 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:28:14 +0800 Subject: [PATCH 09/22] refactor(acp): share Turn admission state and refresh validation Generated-by: OpenAI Codex --- packages/cli/src/acp/VALIDATION.md | 30 ++++++++++++++++++++++++ packages/cli/src/acp/session-registry.ts | 24 +++++++++---------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 5873d33b0f..c66331594d 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,36 @@ # ACP validation record +## PR6 main integration and admission cleanup — September 24, 2026 + +Merged Apache `main` at `fef5b937a` 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 | 2824 passed, 0 failed. | +| Protocol epoch guard against `fef5b937a` 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 | 33 passed, 1 failed: Side Chat `page.screenshot({ fullPage: true })` timed out after its preceding behavior assertions passed. | +| Focused Side Chat E2E retry | Passed on the merged PR tree; the same focused test also passed on `fef5b937a`. | + +The full Desktop E2E run is not claimed as passing. The screenshot timeout did +not reproduce in the focused retry, so these runs do not establish an ACP +regression or a clean full E2E result. 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 diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 14648b2a02..167e2626cd 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -164,6 +164,15 @@ type ActiveAcpPrompt = AcpTurnObservation & { stopTask?: Promise; }; +function withPromptAdmission(observation: AcpTurnObservation): ActiveAcpPrompt { + return Object.assign(observation, { + waiters: new Set<() => void>(), + dispatchStarted: false, + startRequestSettled: false, + admissionSettled: false, + }); +} + /** Owns all Runtime Host resources associated with one ACP connection. */ export class AcpSessionRegistry { readonly #connect: (signal: AbortSignal) => Promise; @@ -364,7 +373,7 @@ export class AcpSessionRegistry { async #prompt(params: PromptRequest, context: AcpPromptContext): Promise { const turnId = this.#newTurnId(); - const active: ActiveAcpPrompt = Object.assign( + const active = withPromptAdmission( new AcpTurnObservation({ sessionId: params.sessionId, turnId, @@ -374,12 +383,6 @@ export class AcpSessionRegistry { } }, }), - { - waiters: new Set<() => void>(), - dispatchStarted: false, - startRequestSettled: false, - admissionSettled: false, - }, ); if (this.#historyReplays.has(params.sessionId)) void active.holdLive().catch(() => undefined); this.#addActivePrompt(active); @@ -1027,12 +1030,7 @@ export class AcpSessionRegistry { }); if (this.#historyReplays.has(sessionId)) void observation.holdLive().catch(() => undefined); const admission: ActiveAcpPrompt | undefined = trackAdmission - ? Object.assign(observation, { - waiters: new Set<() => void>(), - dispatchStarted: false, - startRequestSettled: false, - admissionSettled: false, - }) + ? withPromptAdmission(observation) : undefined; if (admission) this.#addActivePrompt(admission); else this.#setTurnObservation(observation); From 561b5a3049e999e0d5bbf244616cb0904051e521 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:38:21 +0800 Subject: [PATCH 10/22] docs(acp): record validation on latest main Generated-by: OpenAI Codex --- packages/cli/src/acp/VALIDATION.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index c66331594d..13225d2ab0 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -21,7 +21,7 @@ ## PR6 main integration and admission cleanup — September 24, 2026 -Merged Apache `main` at `fef5b937a` into PR #5621. The only textual conflict was +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 @@ -36,18 +36,19 @@ Validation on macOS and Node 24.19.0 after the merge and initializer change: | `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 | 2824 passed, 0 failed. | -| Protocol epoch guard against `fef5b937a` and guard tests | Passed: 183 to 184; 17 tests passed. | +| 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 | 33 passed, 1 failed: Side Chat `page.screenshot({ fullPage: true })` timed out after its preceding behavior assertions passed. | -| Focused Side Chat E2E retry | Passed on the merged PR tree; the same focused test also passed on `fef5b937a`. | - -The full Desktop E2E run is not claimed as passing. The screenshot timeout did -not reproduce in the focused retry, so these runs do not establish an ACP -regression or a clean full E2E result. 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. +| 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 From 876dffea364bb9e1e2ae6f17de40eaba7e6f1769 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:58:30 +0800 Subject: [PATCH 11/22] fix(acp): complete copy workflows and restore cancellation Expose bounded copy-source discovery over ACP and preserve shared attachments when restore requests are cancelled. Keep admission bookkeeping in one typed observation index and share identity-checked resource detachment. Generated-by: OpenAI Codex --- packages/cli/src/__tests__/acp-agent.test.ts | 38 ++ .../src/__tests__/acp-child-process.test.ts | 76 +-- .../__tests__/acp-session-registry.test.ts | 195 ++++++++ packages/cli/src/acp/README.md | 39 ++ packages/cli/src/acp/VALIDATION.md | 43 ++ packages/cli/src/acp/maka-acp-agent.ts | 17 + packages/cli/src/acp/session-registry.ts | 441 ++++++++++-------- packages/cli/src/acp/turn-observation.ts | 23 +- 8 files changed, 653 insertions(+), 219 deletions(-) diff --git a/packages/cli/src/__tests__/acp-agent.test.ts b/packages/cli/src/__tests__/acp-agent.test.ts index 3f1beddb35..2cee82858c 100644 --- a/packages/cli/src/__tests__/acp-agent.test.ts +++ b/packages/cli/src/__tests__/acp-agent.test.ts @@ -98,6 +98,37 @@ describe('Maka ACP agent', () => { ); }); + 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() }), @@ -269,6 +300,13 @@ function fakeSessionRegistry( 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, diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 84a1b08e00..2eed321848 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -36,6 +36,7 @@ import { } 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, @@ -694,34 +695,57 @@ describe('Maka ACP child process', () => { }), { stopReason: 'end_turn' }, ); - const host = await connectRuntimeHost({ - rootPath: harness.workspaceRoot, - protocol: { - min: RUNTIME_HOST_PROTOCOL_VERSION, - max: RUNTIME_HOST_PROTOCOL_VERSION, - }, - }); - assert.equal(host.kind, 'connected'); - if (host.kind !== 'connected') assert.fail('Host connection unavailable'); - let sourceTurnId: string; - let expectedSourceRevision: number; - try { - const session = await getRuntimeHostSession(host.connection, sessionId); - assert.ok(session); - expectedSourceRevision = session.revision; - const subscription = await host.connection.openSessionSubscription({ + // 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, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, - }); - try { - assert.ok(subscription.snapshot.rootTurn); - sourceTurnId = subscription.snapshot.rootTurn.turnId; - } finally { - await subscription.close(); + 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); } - } finally { - await host.connection.close(); - } + 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, diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 9bb8edabe5..38b64a92d7 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -1506,6 +1506,51 @@ describe('ACP Session registry', () => { } }); + 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, '/workspace', { 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: '/workspace', 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'; @@ -2370,6 +2415,156 @@ 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`, async () => { + const sessionId = `cancel-${method}-${phase}`; + const transcript = deferred(); + const opening = deferred(); + const initial = new FakeSubscription( + continuitySnapshot(sessionId), + phase === 'hydrate' ? transcript.promise : Promise.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; + return opens > 1 ? retry : phase === 'open' ? opening.promise : initial; + }, + }), + }); + await registry.create({ cwd: '/workspace', mcpServers: [] }); + let settled = false; + const request = registry[method]( + { sessionId, cwd: '/workspace', mcpServers: [] }, + { ...promptContext([]), signal: controller.signal }, + ); + const rejected = assert.rejects(request).then(() => { + settled = true; + }); + try { + await waitFor(() => (phase === 'open' ? opens === 1 : initial.nextCalls > 0)); + controller.abort(); + await waitFor(() => settled); + 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: '/workspace', mcpServers: [] }, promptContext([])); + assert.equal(opens, 2); + opening.resolve(initial); + transcript.resolve([]); + await waitFor(() => 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: '/workspace', mcpServers: [] }); + const loading = registry.load( + { sessionId, cwd: '/workspace', 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(); diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 8fdd5739ca..91008bba5f 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -60,6 +60,7 @@ or reconnection without waiting for the Host to become available. | 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 | `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. | @@ -98,6 +99,44 @@ 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 ACP projects Runtime Host tool activity as `tool_call` followed by cumulative diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 13225d2ab0..0431ed8263 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,49 @@ # ACP validation record +## 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 diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index d06369c734..670e8f2142 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -36,6 +36,7 @@ export interface MakaAcpAgentOptions { | 'load' | 'resume' | 'resumeTurn' + | 'queryCopySource' | 'branch' | 'createRevision' | 'abandonRevision' @@ -95,6 +96,22 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { 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', { diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 167e2626cd..0e140d3971 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -43,6 +43,7 @@ import { import type { McpConfigFile } from '@maka/core/mcp'; import { isRuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { + abortable, readRuntimeHostConnectionCatalog, readRuntimeHostSessionCatalogPage, RuntimeHostCatalogReadError, @@ -64,6 +65,8 @@ import { type SessionConversationCopyResult, type SessionRevisionAbandonInput, type SessionRevisionAbandonResult, + type SessionTurnsQueryInput, + type SessionTurnsQueryResult, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { @@ -82,7 +85,7 @@ 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 } from './turn-observation.js'; +import { AcpTurnObservation, AcpAdmittedTurnObservation } from './turn-observation.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; const ADMISSION_QUERY_MAX_ATTEMPTS = 5; @@ -101,6 +104,7 @@ type AcpSessionRegistryOperation = | 'turn.query' | 'turn.resume.query' | 'turn.resume.start' + | 'session.turns.query' | 'session.branch.create' | 'session.revision.create' | 'session.revision.abandon'; @@ -152,27 +156,6 @@ interface AcpAttachmentConfiguration { delivery?: Promise; } -type ActiveAcpPrompt = AcpTurnObservation & { - readonly waiters: Set<() => void>; - dispatchStarted: boolean; - startRequestSettled: boolean; - admissionSettled: boolean; - admissionRejected?: boolean; - admissionQuery?: Promise; - admissionFailure?: RequestError; - startedTurn?: TurnSnapshot; - stopTask?: Promise; -}; - -function withPromptAdmission(observation: AcpTurnObservation): ActiveAcpPrompt { - return Object.assign(observation, { - waiters: new Set<() => void>(), - dispatchStarted: false, - startRequestSettled: false, - admissionSettled: false, - }); -} - /** Owns all Runtime Host resources associated with one ACP connection. */ export class AcpSessionRegistry { readonly #connect: (signal: AbortSignal) => Promise; @@ -187,7 +170,7 @@ 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(); @@ -313,6 +296,39 @@ export class AcpSessionRegistry { 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)); } @@ -373,19 +389,17 @@ export class AcpSessionRegistry { async #prompt(params: PromptRequest, context: AcpPromptContext): Promise { const turnId = this.#newTurnId(); - const active = withPromptAdmission( - new AcpTurnObservation({ - sessionId: params.sessionId, - turnId, - notify: async (notification) => { - if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { - await context.notify(notification); - } - }, - }), - ); + const active = new AcpAdmittedTurnObservation({ + sessionId: params.sessionId, + turnId, + notify: async (notification) => { + if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { + await context.notify(notification); + } + }, + }); if (this.#historyReplays.has(params.sessionId)) void active.holdLive().catch(() => undefined); - this.#addActivePrompt(active); + this.#setTurnObservation(active); const onAbort = () => { void this.#cancelPrompt(active).catch(() => undefined); }; @@ -411,7 +425,10 @@ 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; @@ -446,13 +463,13 @@ export class AcpSessionRegistry { // 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; + active.admission.dispatchStarted = true; this.#wake(active); try { const result = await connection.request('turn.start', startInput); - active.startRequestSettled = true; - active.admissionSettled = true; - if (result.kind === 'started') active.startedTurn = result.turn; + active.admission.startRequestSettled = true; + active.admission.settled = true; + if (result.kind === 'started') active.admission.startedTurn = result.turn; this.#wake(active); if (result.kind === 'blocked') { const error = new Error('Runtime Host blocked the requested Turn'); @@ -462,11 +479,11 @@ 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 ||= !( + active.admission.startRequestSettled = true; + active.admission.settled ||= !( error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched' ); - if (!active.admissionSettled) { + if (!active.admission.settled) { this.#queryPromptAdmission(active, connection); } this.#wake(active); @@ -475,24 +492,24 @@ export class AcpSessionRegistry { } if (active.cancelled) { - await active.stopTask?.catch(() => undefined); + await active.admission.stopTask?.catch(() => undefined); return { stopReason: await this.#cancelledStopReason(active) }; } const stopReason = await observation; return { stopReason }; } catch (error) { // A failed projection must not leave the corresponding Host Turn running. - active.stopTask ??= this.#stopPromptWhenObservable(active); - await active.stopTask.catch(() => undefined); + active.admission.stopTask ??= this.#stopPromptWhenObservable(active); + await active.admission.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 { context.signal.removeEventListener('abort', onAbort); // 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); + await active.admission.stopTask?.catch(() => undefined); active.dispose(); this.#attachmentInteractions.get(active.sessionId)?.settleTurn(active.turnId); const observed = active.attachment?.snapshot.rootTurn; @@ -500,16 +517,16 @@ export class AcpSessionRegistry { this.#attachmentInteractions.get(active.sessionId)?.terminalTurn(active.turnId); } this.#wake(active); - this.#removeActivePrompt(active); + this.#removeTurnObservation(active); } } - async #cancelledStopReason(active: ActiveAcpPrompt): Promise<'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)); this.#attachmentOpenControllers.get(sessionId)?.abort(); const attachment = this.#attachments.get(sessionId); @@ -566,23 +583,16 @@ export class AcpSessionRegistry { ); } - async #cancelPrompt(active: ActiveAcpPrompt): Promise { + async #cancelPrompt(active: AcpAdmittedTurnObservation): Promise { active.cancelled = true; this.#wake(active); - if ( - [...(this.#activePrompts.get(active.sessionId) ?? [])].every( - (prompt) => prompt.cancelled && !prompt.dispatchStarted, - ) - ) { - this.#attachmentOpenControllers.get(active.sessionId)?.abort(); - } active.projectionAbort.abort(); active.reconciliationAbort.abort(); this.#attachmentInteractions.get(active.sessionId)?.cancelTurn(active.turnId); - active.stopTask ??= this.#stopPromptWhenObservable(active); + active.admission.stopTask ??= this.#stopPromptWhenObservable(active); await Promise.all([ active.mapper.flush().catch(() => undefined), - active.stopTask.catch((error: unknown) => { + active.admission.stopTask.catch((error: unknown) => { // End only this prompt's observation. Failed delivery does not establish // a terminal Host Turn, and teardown still receives the original error. active.attachment?.failTurn(active.turnId, error); @@ -591,13 +601,13 @@ 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; @@ -614,33 +624,36 @@ export class AcpSessionRegistry { } 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); } } - #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 query failure // other than not_found establishes whether a dispatched start was admitted. - active.admissionQuery ??= this.#readPromptAdmission(active, connection); + 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; + active.admission.startedTurn = observed; + active.admission.settled = true; this.#wake(active); return; } @@ -650,24 +663,24 @@ export class AcpSessionRegistry { { sessionId: active.sessionId, turnId: active.turnId }, ADMISSION_QUERY_TIMEOUT_MS, ); - if (!active.finished && !active.admissionSettled) { - active.startedTurn = turn; - active.admissionSettled = true; + if (!active.finished && !active.admission.settled) { + active.admission.startedTurn = turn; + active.admission.settled = true; this.#wake(active); } return; } catch (error) { if (error instanceof RuntimeHostOperationError && error.code === 'not_found') { const observed = active.attachment?.snapshot.rootTurn; - if (observed?.turnId === active.turnId) active.startedTurn = observed; - else if (!active.startedTurn) active.admissionRejected = true; - active.admissionSettled = true; + if (observed?.turnId === active.turnId) active.admission.startedTurn = observed; + else if (!active.admission.startedTurn) active.admission.rejected = true; + active.admission.settled = true; this.#wake(active); 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); } @@ -676,7 +689,7 @@ export class AcpSessionRegistry { this.#wake(active); return; } - active.admissionFailure = RequestError.internalError( + active.admission.failure = RequestError.internalError( { source: 'runtime_host', operation: 'turn.query', @@ -694,6 +707,42 @@ export class AcpSessionRegistry { 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; @@ -750,7 +799,7 @@ export class AcpSessionRegistry { }, onCancelled: (pending) => { let localPrompt = false; - for (const active of this.#activePrompts.get(sessionId) ?? []) { + for (const active of this.#admittedTurns(sessionId)) { if (active.turnId === pending.turnId) { localPrompt = true; void this.#cancelPrompt(active).catch(() => undefined); @@ -785,18 +834,16 @@ export class AcpSessionRegistry { } const root = snapshot.rootTurn; const observation = root && this.#observation(sessionId, root.turnId); - const active = this.#activePrompts.get(sessionId); - const prompt = - observation && active?.has(observation as ActiveAcpPrompt) - ? (observation as ActiveAcpPrompt) - : undefined; + const prompt = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; const observedRunId = - observation?.runId ?? prompt?.startedTurn?.runId ?? observation?.terminalTurn?.runId; + 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.startedTurn ??= root; - prompt.admissionSettled = true; + prompt.admission.startedTurn ??= root; + prompt.admission.settled = true; this.#wake(prompt); } } @@ -859,11 +906,11 @@ export class AcpSessionRegistry { 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; } @@ -931,13 +978,8 @@ 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. @@ -947,7 +989,7 @@ export class AcpSessionRegistry { for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { if ( observation.attachment !== attachment || - [...(this.#activePrompts.get(sessionId) ?? [])].includes(observation as ActiveAcpPrompt) + observation instanceof AcpAdmittedTurnObservation ) continue; observation.attachment.failTurn(observation.turnId, error); @@ -955,21 +997,35 @@ export class AcpSessionRegistry { void attachment.close().catch(() => undefined); } + #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); + 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); for (const observation of this.#turnObservations.get(sessionId)?.values() ?? []) { - if ( - ![...(this.#activePrompts.get(sessionId) ?? [])].includes(observation as ActiveAcpPrompt) - ) { + if (!(observation instanceof AcpAdmittedTurnObservation)) { observation.dispose(); this.#removeTurnObservation(observation); } } - this.#attachmentInteractions.get(sessionId)?.close(); - this.#attachmentInteractions.delete(sessionId); - const attachmentTask = this.#attachments.get(sessionId); - this.#attachments.delete(sessionId); + const attachmentTask = this.#detachAttachment(sessionId); let closeError: unknown; if (attachmentTask) { try { @@ -980,8 +1036,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) { @@ -1017,7 +1072,8 @@ export class AcpSessionRegistry { (attachment.snapshot.rootTurn?.turnId === turnId ? attachment.snapshot.rootTurn.runId : undefined); - const observation = new AcpTurnObservation({ + const Observation = trackAdmission ? AcpAdmittedTurnObservation : AcpTurnObservation; + const observation = new Observation({ sessionId, turnId, ...(runId === undefined ? {} : { runId }), @@ -1029,10 +1085,8 @@ export class AcpSessionRegistry { }, }); if (this.#historyReplays.has(sessionId)) void observation.holdLive().catch(() => undefined); - const admission: ActiveAcpPrompt | undefined = trackAdmission - ? withPromptAdmission(observation) - : undefined; - if (admission) this.#addActivePrompt(admission); + const admission = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; + if (admission) this.#setTurnObservation(admission); else this.#setTurnObservation(observation); try { await observation.seed(attachment.messages); @@ -1042,7 +1096,7 @@ export class AcpSessionRegistry { this.#discardedAttachments.has(attachment) ) { observation.dispose(); - if (admission) this.#removeActivePrompt(admission); + if (admission) this.#removeTurnObservation(admission); else this.#removeTurnObservation(observation); return; } @@ -1102,11 +1156,15 @@ export class AcpSessionRegistry { }) .finally(async () => { if (admission) { - while (admission.dispatchStarted && !admission.startRequestSettled && !this.#closing) { + while ( + admission.admission.dispatchStarted && + !admission.admission.startRequestSettled && + !this.#closing + ) { await this.#waitForPromptChange(admission); } - await admission.stopTask?.catch(() => undefined); - this.#removeActivePrompt(admission); + await admission.admission.stopTask?.catch(() => undefined); + this.#removeTurnObservation(admission); } interactions?.settleTurn(turnId); observation.dispose(); @@ -1115,7 +1173,7 @@ export class AcpSessionRegistry { return observation; } catch (error) { observation.dispose(); - if (admission) this.#removeActivePrompt(admission); + if (admission) this.#removeTurnObservation(admission); else this.#removeTurnObservation(observation); throw error; } @@ -1136,7 +1194,7 @@ export class AcpSessionRegistry { if (plan.disposition === 'parked') return { kind: 'parked' as const, plan }; const priorContext = this.#externalObservationContexts.get(params.sessionId); this.#externalObservationContexts.set(params.sessionId, context); - let observation: ActiveAcpPrompt | undefined; + let observation: AcpAdmittedTurnObservation | undefined; let attachment: RuntimeHostSessionChannel | undefined; const turnId = this.#newTurnId(); let dispatched = false; @@ -1149,10 +1207,10 @@ export class AcpSessionRegistry { attachment = await this.#ensureAttachment(params.sessionId, connection, context); context.signal.throwIfAborted(); this.#assertOwned(params.sessionId); - observation = (await this.#adoptTurn(params.sessionId, turnId, attachment, true)) as - | ActiveAcpPrompt - | undefined; - if (!observation) throw registryClosedError('turn.resume.start'); + 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( @@ -1160,7 +1218,7 @@ export class AcpSessionRegistry { 'Turn resume was cancelled before admission', ); dispatched = true; - observation.dispatchStarted = true; + observation.admission.dispatchStarted = true; this.#wake(observation); const result = await connection.request('turn.resume.start', { sessionId: params.sessionId, @@ -1168,18 +1226,18 @@ export class AcpSessionRegistry { sourceRunId: plan.sourceRunId, sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }); - observation.startRequestSettled = true; - observation.admissionSettled = true; + observation.admission.startRequestSettled = true; + observation.admission.settled = true; this.#wake(observation); if (result.kind === 'parked') { observation.dispose(); - this.#removeActivePrompt(observation); + this.#removeTurnObservation(observation); attachment.failTurn(turnId, new Error(`Turn resume parked: ${result.plan.reason}`)); return { kind: 'parked' as const, plan: result.plan }; } - observation.startedTurn = result.turn; + observation.admission.startedTurn = result.turn; this.#wake(observation); - await observation.stopTask?.catch(() => undefined); + await observation.admission.stopTask?.catch(() => undefined); return { kind: 'started' as const, turn: result.turn, @@ -1189,8 +1247,8 @@ export class AcpSessionRegistry { } catch (error) { let failure = error; if (observation) { - observation.startRequestSettled = true; - observation.admissionSettled ||= !( + observation.admission.startRequestSettled = true; + observation.admission.settled ||= !( dispatched && error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched' @@ -1204,11 +1262,11 @@ export class AcpSessionRegistry { observation ) { this.#queryPromptAdmission(observation, connection); - await observation.admissionQuery; - if (observation.startedTurn) { + await observation.admission.query; + if (observation.admission.startedTurn) { return { kind: 'started' as const, - turn: observation.startedTurn, + turn: observation.admission.startedTurn, sourceRunId: plan.sourceRunId, sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }; @@ -1224,12 +1282,12 @@ export class AcpSessionRegistry { }, 'Runtime Host Turn resume admission could not be established', ); - if (!observation.admissionRejected) throw admissionError; + if (!observation.admission.rejected) throw admissionError; failure = admissionError; } if (observation) { observation.dispose(); - this.#removeActivePrompt(observation); + this.#removeTurnObservation(observation); attachment?.failTurn(turnId, failure); } if (priorContext) this.#externalObservationContexts.set(params.sessionId, priorContext); @@ -1299,20 +1357,11 @@ export class AcpSessionRegistry { []) { observation.cancelled = true; observation.dispose(); - this.#removeTurnObservation(observation); - } - for (const active of this.#activePrompts.get(params.targetSessionId) ?? []) { - active.cancelled = true; - this.#wake(active); + if (observation instanceof AcpAdmittedTurnObservation) this.#wake(observation); + else this.#removeTurnObservation(observation); } - this.#attachmentInteractions.get(params.targetSessionId)?.close(); - this.#attachmentInteractions.delete(params.targetSessionId); - const attachment = this.#attachments.get(params.targetSessionId); - this.#attachments.delete(params.targetSessionId); - this.#attachmentOpenControllers.get(params.targetSessionId)?.abort(); - this.#attachmentConfigurations.delete(params.targetSessionId); - const mcp = this.#mcps.get(params.targetSessionId); - this.#mcps.delete(params.targetSessionId); + const attachment = this.#detachAttachment(params.targetSessionId); + const mcp = this.#detachMcp(params.targetSessionId); await Promise.allSettled([ attachment?.then( (channel) => channel.close(), @@ -1324,18 +1373,11 @@ export class AcpSessionRegistry { return result; } - #addActivePrompt(active: ActiveAcpPrompt): void { - this.#setTurnObservation(active); - const prompts = this.#activePrompts.get(active.sessionId); - if (prompts) prompts.add(active); - else this.#activePrompts.set(active.sessionId, new Set([active])); - } - - #removeActivePrompt(active: ActiveAcpPrompt): void { - this.#removeTurnObservation(active); - const prompts = this.#activePrompts.get(active.sessionId); - prompts?.delete(active); - if (prompts?.size === 0) this.#activePrompts.delete(active.sessionId); + #admittedTurns(sessionId: string): AcpAdmittedTurnObservation[] { + return [...(this.#turnObservations.get(sessionId)?.values() ?? [])].filter( + (observation): observation is AcpAdmittedTurnObservation => + observation instanceof AcpAdmittedTurnObservation, + ); } #setTurnObservation(observation: AcpTurnObservation): void { @@ -1352,6 +1394,12 @@ export class AcpSessionRegistry { 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 { @@ -1359,23 +1407,23 @@ export class AcpSessionRegistry { } #wakeSession(sessionId: string): void { - for (const active of this.#activePrompts.get(sessionId) ?? []) this.#wake(active); + for (const active of this.#admittedTurns(sessionId)) this.#wake(active); } - #wake(active: ActiveAcpPrompt): void { - for (const resolve of active.waiters) resolve(); - active.waiters.clear(); + #wake(active: AcpAdmittedTurnObservation): void { + for (const resolve of active.admission.waiters) resolve(); + active.admission.waiters.clear(); } - #waitForPromptChange(active: ActiveAcpPrompt, timeoutMs?: number): Promise { + #waitForPromptChange(active: AcpAdmittedTurnObservation, timeoutMs?: number): Promise { return new Promise((resolve) => { let timer: ReturnType | undefined; const wake = () => { if (timer !== undefined) clearTimeout(timer); - active.waiters.delete(wake); + active.admission.waiters.delete(wake); resolve(); }; - active.waiters.add(wake); + active.admission.waiters.add(wake); if (timeoutMs !== undefined) timer = setTimeout(wake, timeoutMs); }); } @@ -1509,7 +1557,7 @@ export class AcpSessionRegistry { latest.status === 'running' || latest.status === 'waiting_for_user' || (latest.liveRunState?.runningTurnIds.length ?? 0) > 0 || - (this.#activePrompts.get(params.sessionId)?.size ?? 0) > 0 + this.#admittedTurns(params.sessionId).length > 0 ) { throw RequestError.internalError( { source: 'adapter', operation: 'mcp.prepare', code: 'session_busy' }, @@ -1535,7 +1583,10 @@ export class AcpSessionRegistry { } this.#externalObservationContexts.set(params.sessionId, context); const creatingAttachment = !this.#attachments.has(params.sessionId); - const attachmentPromise = this.#ensureAttachment(params.sessionId, connection, context); + const attachmentPromise = this.#ensureAttachment(params.sessionId, connection, { + ...context, + signal: lifetime, + }); if (creatingAttachment) newAttachment = this.#attachments.get(params.sessionId); const attachment = await attachmentPromise; lifetime.throwIfAborted(); @@ -1582,6 +1633,16 @@ export class AcpSessionRegistry { } 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. + 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) { @@ -1592,14 +1653,13 @@ export class AcpSessionRegistry { this.#removeTurnObservation(observation); } } - this.#attachmentInteractions.get(params.sessionId)?.close(); - this.#attachmentInteractions.delete(params.sessionId); - this.#attachments.delete(params.sessionId); + this.#detachAttachment(params.sessionId, newAttachment); await channel?.close().catch(() => undefined); } if (installedMcp && this.#mcps.get(params.sessionId) === installedMcp) { - this.#mcps.delete(params.sessionId); - await installedMcp.close().catch(() => undefined); + await this.#detachMcp(params.sessionId, installedMcp) + ?.close() + .catch(() => undefined); } else if ( previousMcp && previousMcpConfig && @@ -1779,18 +1839,11 @@ export class AcpSessionRegistry { this.#externalObservationContexts.clear(); for (const observations of this.#turnObservations.values()) { for (const observation of observations.values()) { - if ( - ![...(this.#activePrompts.get(observation.sessionId) ?? [])].includes( - observation as ActiveAcpPrompt, - ) - ) - observation.dispose(); + if (!(observation instanceof AcpAdmittedTurnObservation)) observation.dispose(); } } - const sessionIds = new Set([...this.#activePrompts.keys(), ...this.#attachments.keys()]); - const activePrompts = [...sessionIds].flatMap((sessionId) => [ - ...(this.#activePrompts.get(sessionId) ?? []), - ]); + const sessionIds = new Set([...this.#turnObservations.keys(), ...this.#attachments.keys()]); + 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(); @@ -1801,7 +1854,11 @@ 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) { + while ( + active.admission.dispatchStarted && + !active.admission.startRequestSettled && + !active.finished + ) { await this.#waitForPromptChange(active); } }), @@ -1809,8 +1866,8 @@ export class AcpSessionRegistry { 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; }); if (unknownAdmissions.length > 0) { // At shutdown the attachment is already closed and each start request has @@ -1818,7 +1875,7 @@ export class AcpSessionRegistry { // Close the owned connection so those reads cannot deadlock EOF cleanup. await Promise.allSettled([this.#closeOwnedConnection()]); for (const active of unknownAdmissions) { - active.admissionSettled = true; + active.admission.settled = true; this.#wake(active); } } diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index 60bae9a4ef..2b139f112b 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -19,7 +19,8 @@ import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; -import type { StopReason } from '@agentclientprotocol/sdk'; +import type { TurnSnapshot } from '@maka/runtime-host/protocol'; +import type { RequestError, StopReason } from '@agentclientprotocol/sdk'; import type { RuntimeHostTerminalTurn } from '@maka/runtime-host/adapter'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { AcpSessionEventMapper } from './session-event-mapper.js'; @@ -179,3 +180,23 @@ export class AcpTurnObservation { 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; + stopTask?: Promise; + } = { + waiters: new Set(), + dispatchStarted: false, + startRequestSettled: false, + settled: false, + }; +} From e02db21a0915828c1b8dbc9bfabea5fde8c1d907 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:11:14 +0800 Subject: [PATCH 12/22] test(acp): synchronize restore cancellation on lifecycle events Wait for subscription opening, transcript hydration and close signals instead of exhausting a fixed event-loop polling budget on Linux CI. Generated-by: OpenAI Codex --- .../__tests__/acp-session-registry.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 38b64a92d7..949dd97369 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -2417,14 +2417,21 @@ describe('ACP Session registry', () => { 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`, async () => { + 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; @@ -2450,30 +2457,29 @@ describe('ACP Session registry', () => { }, openSessionSubscriptionOnce: async () => { opens += 1; + if (phase === 'open') entered.resolve(); return opens > 1 ? retry : phase === 'open' ? opening.promise : initial; }, }), }); await registry.create({ cwd: '/workspace', mcpServers: [] }); - let settled = false; const request = registry[method]( { sessionId, cwd: '/workspace', mcpServers: [] }, { ...promptContext([]), signal: controller.signal }, ); - const rejected = assert.rejects(request).then(() => { - settled = true; - }); + const rejected = assert.rejects(request); try { - await waitFor(() => (phase === 'open' ? opens === 1 : initial.nextCalls > 0)); + await entered.promise; controller.abort(); - await waitFor(() => settled); + 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: '/workspace', mcpServers: [] }, promptContext([])); assert.equal(opens, 2); opening.resolve(initial); transcript.resolve([]); - await waitFor(() => initial.closeCalls === 1); + await closed.promise; + assert.equal(initial.closeCalls, 1); assert.equal(starts, 0); assert.equal(retry.closeCalls, 0); } finally { @@ -5236,6 +5242,7 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< nextCalls = 0; transcriptPageReads = 0; onTranscriptPageRead?: () => void; + onTranscriptRead?: () => void; transcriptPageSize = Number.POSITIVE_INFINITY; transcriptEmptyFirstPage = false; transcriptPageGate?: Promise; @@ -5416,6 +5423,7 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< } async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + this.onTranscriptRead?.(); return (await this.transcript).map(decodeMessage); } From 8fdfc8c9c411ae0c88a7985b12aec0296e0d1c88 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:34 +0800 Subject: [PATCH 13/22] test(acp): bound asynchronous waits by elapsed time The remaining concurrent-consumer CI failure exhausted 100 event-loop ticks before filesystem completion. Use a five-second deadline with a timer yield for the shared predicate helper; retain explicit lifecycle signals in restore cancellation tests. Generated-by: OpenAI Codex --- packages/cli/src/__tests__/acp-session-registry.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 949dd97369..ff7734b5c7 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -5640,9 +5640,10 @@ 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)); + await new Promise((resolve) => setTimeout(resolve, 1)); } assert.fail('condition was not reached'); } From 6afe13c2ca32c4f3897d3f51c093d25b68857c4d Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:20:54 +0800 Subject: [PATCH 14/22] test(acp): keep polling independent of mocked timers Generated-by: Codex --- packages/cli/src/__tests__/acp-session-registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index ff7734b5c7..75d9961497 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -5643,7 +5643,7 @@ async function waitFor(predicate: () => boolean): Promise { const deadline = performance.now() + 5_000; while (performance.now() < deadline) { if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 1)); + await new Promise((resolve) => setImmediate(resolve)); } assert.fail('condition was not reached'); } From 4efbae5c50ad4ed2c2f7e296c47580e932b647d2 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:27:13 +0800 Subject: [PATCH 15/22] fix(acp): preserve restore state and fence MCP configuration conflicts Retain initial terminal facts before observation adoption, replay attachment-only history, and reject conflicting scoped providers atomically in Host. Share admitted Turn transitions and add baseline-failing regression coverage. Generated-by: Codex --- .../src/__tests__/acp-child-process.test.ts | 108 ++++++++++------ .../acp-session-event-mapper.test.ts | 46 +++++++ .../cli/src/__tests__/acp-session-mcp.test.ts | 15 ++- .../__tests__/acp-session-registry.test.ts | 78 ++++++++++++ .../__tests__/acp-tools-child-process.test.ts | 106 +++++++++++++++- packages/cli/src/acp/README.md | 17 ++- packages/cli/src/acp/VALIDATION.md | 51 ++++++++ packages/cli/src/acp/session-event-mapper.ts | 14 +- packages/cli/src/acp/session-mcp.ts | 21 ++- packages/cli/src/acp/session-registry.ts | 119 ++++++++--------- packages/cli/src/acp/turn-observation.ts | 41 ++++++ .../executor-catalog-browser-safe.json | 4 +- .../issue-4032-composition-identity.json | 4 +- .../client-capability-coordinator.test.ts | 120 ++++++++++++++++++ .../client-capability-protocol.test.ts | 26 ++++ .../src/client/client-capability-channel.ts | 2 + .../src/client/client-capability.ts | 1 + .../runtime-host/src/client/connection.ts | 9 +- .../src/protocol/client-capability.ts | 16 ++- packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/operation-spec.ts | 1 + .../server/client-capability-coordinator.ts | 36 ++++++ 22 files changed, 715 insertions(+), 123 deletions(-) diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 2eed321848..7016d31ecc 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -364,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, 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..25c53e559f 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,52 @@ 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('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 3704b11925..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'; @@ -107,7 +109,11 @@ test('Session MCP reconfiguration reuses equivalent processes and applies replac ); 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 }); + assert.deepEqual(host.replacements.at(-1)?.options, { + sessionId, + requireIdleSession: true, + sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(mcp.config)).digest('hex')}`, + }); await assertFixtureExited(root, 'fixture'); }); @@ -567,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()) { @@ -597,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'), diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 75d9961497..fab33f1e42 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -432,6 +432,84 @@ describe('ACP Session registry', () => { } }); + for (const status of ['completed', 'failed', 'cancelled'] as const) { + for (const nextTurn of [false, true]) { + 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) subscription.setRoot(runningTurn(sessionId, 'next-turn')); + 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: '/workspace', 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) => { + assert.deepEqual(value, { + sessionId, + turnId, + runId: turn.runId, + status, + ...(status === 'failed' ? { failureClass: 'provider_failure' } : {}), + }); + delivered.push(status); + terminal.resolve(); + }, + }, + ); + await terminal.promise; + assert.deepEqual(delivered, ['final output', status]); + } 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'; 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 8d2c99201d..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 () => { diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 91008bba5f..27c79abf92 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 @@ -83,6 +86,8 @@ 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 received during initial subscription readiness are retained for +their exact Turn and run, even if a new root starts before observation is adopted. 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; @@ -185,6 +190,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`; close the other +client's Session attachment before retrying the replacement. Equivalent +configurations remain attachable, including live permission restoration; Host +retains its existing provider binding. A disconnected frozen provider must +reconnect under the same authenticated identity rather than being silently +replaced by another client. 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 0431ed8263..764e2b6394 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,57 @@ # ACP validation record +## 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. + +Initial subscription snapshots retain terminal facts by Turn and run until the +observer is adopted, including when the next root has already started. 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. + +Validation on macOS and Node 24.19.0: + +| Check | Result | +| --- | --- | +| Root build and typecheck | Passed. | +| Full CLI dist suite, concurrency 4 | 1229 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 diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index 3ef3a74579..788f34a501 100644 --- a/packages/cli/src/acp/session-event-mapper.ts +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -129,7 +129,16 @@ export class AcpSessionEventMapper { acceptHistoricalMessage(message: StoredMessage): Promise { return this.#enqueue(async () => { if (message.type === 'user') { - const text = userFacingText(message); + 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, @@ -137,6 +146,9 @@ export class AcpSessionEventMapper { sessionUpdate: 'user_message_chunk', content: { type: 'text', text }, messageId: message.id, + ...(attachments.length + ? { _meta: { '_maka/attachments': structuredClone(attachments) } } + : {}), }, }); } diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index 01a0886257..cbd0e92e32 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { isAbsolute } from 'node:path'; import { RequestError, type McpServer } from '@agentclientprotocol/sdk'; import { MCP_CONFIG_VERSION, type McpConfigFile } from '@maka/core/mcp'; @@ -144,6 +145,7 @@ export class AcpSessionMcp { replace: (provider) => connection.replaceClientCapabilities(provider, { sessionId, + sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(this.#config)).digest('hex')}`, ...(this.#requireIdlePublication ? { requireIdleSession: true } : {}), }), unregister: () => connection.unregisterClientCapabilities({ sessionId }), @@ -206,6 +208,7 @@ export class AcpSessionMcp { 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'], @@ -220,6 +223,7 @@ export class AcpSessionMcp { previousRevision = this.#publicationRevision; this.#unsubscribeManager(); this.#manager = staged; + this.#config = config; this.#unsubscribeManager = this.#observeManager(staged); this.#retainedManagers.add(previous); this.#pendingManager = undefined; @@ -228,7 +232,6 @@ export class AcpSessionMcp { this.#publicationRevision += 1; this.#publication.request(); await this.#settlePublication(signal); - this.#config = config; this.#requireIdlePublication = false; this.#retireRetainedManagers(); await Promise.allSettled([...this.#retirementTasks]); @@ -244,6 +247,7 @@ export class AcpSessionMcp { ) { this.#unsubscribeManager(); this.#manager = previous; + this.#config = previousConfig; this.#unsubscribeManager = this.#observeManager(previous); this.#retainedManagers.delete(previous); this.#publicationRevision = previousRevision; @@ -255,7 +259,6 @@ export class AcpSessionMcp { // 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.#config = config; this.#retireRetainedManagers(); } } else if (!swapped) { @@ -282,6 +285,20 @@ export class AcpSessionMcp { 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 another provider; close its Session attachment before replacing it', + ); + } if ( this.#requireIdlePublication && this.#publication.lastError instanceof RuntimeHostOperationError && diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 0e140d3971..ce01317933 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -41,7 +41,10 @@ import { type SetSessionConfigOptionResponse, } from '@agentclientprotocol/sdk'; 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, @@ -434,7 +437,7 @@ export class AcpSessionRegistry { throw error; } active.attachment = attachment; - this.#wake(active); + active.wake(); if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; try { @@ -463,14 +466,10 @@ export class AcpSessionRegistry { // 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.admission.dispatchStarted = true; - this.#wake(active); + active.markDispatched(); try { const result = await connection.request('turn.start', startInput); - active.admission.startRequestSettled = true; - active.admission.settled = true; - if (result.kind === 'started') active.admission.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); @@ -479,14 +478,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.admission.startRequestSettled = true; - active.admission.settled ||= !( - error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched' - ); + 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'); } @@ -516,7 +511,7 @@ export class AcpSessionRegistry { if (observed?.turnId === active.turnId && isRuntimeHostTerminalTurn(observed)) { this.#attachmentInteractions.get(active.sessionId)?.terminalTurn(active.turnId); } - this.#wake(active); + active.wake(); this.#removeTurnObservation(active); } } @@ -585,7 +580,7 @@ export class AcpSessionRegistry { async #cancelPrompt(active: AcpAdmittedTurnObservation): Promise { active.cancelled = true; - this.#wake(active); + active.wake(); active.projectionAbort.abort(); active.reconciliationAbort.abort(); this.#attachmentInteractions.get(active.sessionId)?.cancelTurn(active.turnId); @@ -629,7 +624,7 @@ export class AcpSessionRegistry { console.error('[acp] Host Turn admission remains unknown:', active.admission.failure); throw active.admission.failure; } - await this.#waitForPromptChange(active); + await active.waitForChange(); } } @@ -654,7 +649,7 @@ export class AcpSessionRegistry { if (observed?.turnId === active.turnId) { active.admission.startedTurn = observed; active.admission.settled = true; - this.#wake(active); + active.wake(); return; } try { @@ -666,7 +661,7 @@ export class AcpSessionRegistry { if (!active.finished && !active.admission.settled) { active.admission.startedTurn = turn; active.admission.settled = true; - this.#wake(active); + active.wake(); } return; } catch (error) { @@ -675,18 +670,18 @@ export class AcpSessionRegistry { if (observed?.turnId === active.turnId) active.admission.startedTurn = observed; else if (!active.admission.startedTurn) active.admission.rejected = true; active.admission.settled = true; - this.#wake(active); + active.wake(); return; } lastError = error; } 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.admission.failure = RequestError.internalError( @@ -700,7 +695,7 @@ export class AcpSessionRegistry { }, 'Runtime Host Turn admission could not be established; Stop could not be confirmed', ); - this.#wake(active); + active.wake(); } async #ensureAttachment( @@ -758,6 +753,9 @@ export class AcpSessionRegistry { let task!: Promise; let attachment: RuntimeHostSessionChannel | undefined; let earlyFailure: Error | undefined; + // ready() can deliver a terminal snapshot and its successor before open() + // returns the channel. Keep the exact facts until activation adopts those Turns. + const initialTerminalTurns = new Map(); const failAttachment = (error: Error) => { if (!attachment) { earlyFailure = error; @@ -819,11 +817,16 @@ export class AcpSessionRegistry { now: Date.now, onTurnStarted: (turn) => { if (attachment) - void this.#adoptTurn(sessionId, turn.turnId, attachment, false, turn.runId).catch( - (error: unknown) => { - console.error('[acp] Attached Turn observation failed:', error); - }, - ); + void this.#adoptTurn( + sessionId, + turn.turnId, + attachment, + false, + turn.runId, + initialTerminalTurns.get(turn.turnId), + ).catch((error: unknown) => { + console.error('[acp] Attached Turn observation failed:', error); + }); }, onRuntimeResourceChanged: () => undefined, onSnapshotChanged: (snapshot) => { @@ -833,6 +836,9 @@ export class AcpSessionRegistry { interactions.terminalTurn(snapshot.rootTurn.turnId); } const root = snapshot.rootTurn; + if (!attachment && root && isRuntimeHostTerminalTurn(root)) { + initialTerminalTurns.set(root.turnId, root); + } const observation = root && this.#observation(sessionId, root.turnId); const prompt = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; const observedRunId = @@ -844,7 +850,7 @@ export class AcpSessionRegistry { if (prompt) { prompt.admission.startedTurn ??= root; prompt.admission.settled = true; - this.#wake(prompt); + prompt.wake(); } } if (configuration.metadataRevision === undefined) { @@ -920,7 +926,7 @@ export class AcpSessionRegistry { if (attachment?.snapshot.rootTurn?.turnId !== active.turnId) { this.#queryPromptAdmission(active, connection); } - this.#wake(active); + active.wake(); } }, }) @@ -941,7 +947,10 @@ export class AcpSessionRegistry { attachedTurnId, channel, false, - root?.turnId === attachedTurnId ? root.runId : undefined, + root?.turnId === attachedTurnId + ? root.runId + : initialTerminalTurns.get(attachedTurnId)?.runId, + initialTerminalTurns.get(attachedTurnId), ); } channel.activate( @@ -949,6 +958,7 @@ export class AcpSessionRegistry { ? attachedTurnId : undefined, ); + initialTerminalTurns.clear(); return channel; }) .catch((error: unknown) => { @@ -984,7 +994,7 @@ export class AcpSessionRegistry { // 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 ( @@ -1057,6 +1067,7 @@ export class AcpSessionRegistry { attachment: RuntimeHostSessionChannel, trackAdmission = false, expectedRunId?: string, + initialTerminalTurn?: RuntimeHostTerminalTurn, ): Promise { const context = this.#externalObservationContexts.get(sessionId); if ( @@ -1084,6 +1095,7 @@ export class AcpSessionRegistry { } }, }); + 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); @@ -1161,7 +1173,7 @@ export class AcpSessionRegistry { !admission.admission.startRequestSettled && !this.#closing ) { - await this.#waitForPromptChange(admission); + await admission.waitForChange(); } await admission.admission.stopTask?.catch(() => undefined); this.#removeTurnObservation(admission); @@ -1218,25 +1230,20 @@ export class AcpSessionRegistry { 'Turn resume was cancelled before admission', ); dispatched = true; - observation.admission.dispatchStarted = true; - this.#wake(observation); + observation.markDispatched(); const result = await connection.request('turn.resume.start', { sessionId: params.sessionId, turnId, sourceRunId: plan.sourceRunId, sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }); - observation.admission.startRequestSettled = true; - observation.admission.settled = true; - this.#wake(observation); + 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}`)); return { kind: 'parked' as const, plan: result.plan }; } - observation.admission.startedTurn = result.turn; - this.#wake(observation); await observation.admission.stopTask?.catch(() => undefined); return { kind: 'started' as const, @@ -1247,13 +1254,7 @@ export class AcpSessionRegistry { } catch (error) { let failure = error; if (observation) { - observation.admission.startRequestSettled = true; - observation.admission.settled ||= !( - dispatched && - error instanceof RuntimeHostRequestInterruptedError && - error.dispatch === 'dispatched' - ); - this.#wake(observation); + observation.failStartRequest(error); } if ( dispatched && @@ -1357,7 +1358,7 @@ export class AcpSessionRegistry { []) { observation.cancelled = true; observation.dispose(); - if (observation instanceof AcpAdmittedTurnObservation) this.#wake(observation); + if (observation instanceof AcpAdmittedTurnObservation) observation.wake(); else this.#removeTurnObservation(observation); } const attachment = this.#detachAttachment(params.targetSessionId); @@ -1407,25 +1408,7 @@ export class AcpSessionRegistry { } #wakeSession(sessionId: string): void { - for (const active of this.#admittedTurns(sessionId)) this.#wake(active); - } - - #wake(active: AcpAdmittedTurnObservation): void { - for (const resolve of active.admission.waiters) resolve(); - active.admission.waiters.clear(); - } - - #waitForPromptChange(active: AcpAdmittedTurnObservation, timeoutMs?: number): Promise { - return new Promise((resolve) => { - let timer: ReturnType | undefined; - const wake = () => { - if (timer !== undefined) clearTimeout(timer); - active.admission.waiters.delete(wake); - resolve(); - }; - active.admission.waiters.add(wake); - if (timeoutMs !== undefined) timer = setTimeout(wake, timeoutMs); - }); + for (const active of this.#admittedTurns(sessionId)) active.wake(); } #assertOwned(sessionId: string): void { @@ -1859,7 +1842,7 @@ export class AcpSessionRegistry { !active.admission.startRequestSettled && !active.finished ) { - await this.#waitForPromptChange(active); + await active.waitForChange(); } }), ); @@ -1876,7 +1859,7 @@ export class AcpSessionRegistry { await Promise.allSettled([this.#closeOwnedConnection()]); for (const active of unknownAdmissions) { active.admission.settled = true; - this.#wake(active); + active.wake(); } } await Promise.allSettled(cancellations); diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index 2b139f112b..eb76aef16b 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -21,6 +21,7 @@ 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, 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'; @@ -199,4 +200,44 @@ export class AcpAdmittedTurnObservation extends AcpTurnObservation { 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(); + } + + 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/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json b/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json index e4fbf87ff0..1ed19cba44 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": 184, + "epoch": 185, "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 cumulative protocol through epoch 184, 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 185, 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 8fb4422198..7b87e5f44b 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": 184, + "epoch": 185, "files": ["packages/runtime-host/src/protocol/index.ts"], - "reason": "Against the cumulative protocol through epoch 184, 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 185, 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 12f08f285a..659609bdbe 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,126 @@ 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 requires its authenticated identity to reconnect', async () => { + const coordinator = createCoordinator(); + const first = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { + send: async () => {}, + }); + const second = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-b'), + { + 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' }, + connectionContext('connection-b'), + ); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'session_binding_conflict'); + const reconnected = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-reconnected', 'connection-a'), + { send: async () => {} }, + ); + try { + 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(); + } + } 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({ 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 69d4afac6c..22f16a6d5a 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -32,6 +32,32 @@ import { } 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({ diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index 6154ebe2f0..0ab0f4a16c 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -106,6 +106,7 @@ export class ClientCapabilityChannel { timeoutMs: number, sessionId?: string, requireIdleSession = false, + sessionConfigurationId?: string, ): Promise { this.#assertOpen(); if (this.#pendingMutations.has(sessionId)) { @@ -120,6 +121,7 @@ export class ClientCapabilityChannel { 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 df3cbdc0c2..7faeca1c4d 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -31,6 +31,7 @@ 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 af73173782..b363c9823d 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -709,8 +709,15 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS, sessionId, requireIdleSession, + sessionConfigurationId, } = typeof options === 'number' ? { timeoutMs: options } : (options ?? {}); - return this.#clientCapabilities.replace(provider, timeoutMs, sessionId, requireIdleSession); + 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 7ba62a81e6..49f3522ff8 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -104,6 +104,7 @@ const CLIENT_CAPABILITY_ERRORS = [ 'operation_unavailable', 'invalid_request', 'session_busy', + 'session_binding_conflict', 'internal_failure', ] as const; @@ -130,6 +131,8 @@ export interface ClientCapabilityReplaceInput { 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[]; } @@ -308,7 +311,7 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi record, 'Client Capability replacement', ['registrationId', 'offers'], - ['services', 'sessionId', 'requireIdleSession'], + ['services', 'sessionId', 'requireIdleSession', 'sessionConfigurationId'], ); if (!Array.isArray(record.offers) || record.offers.length > CLIENT_CAPABILITY_MAX_OFFERS) { throw invalidProtocolFrame('Invalid Client Capability offers'); @@ -333,6 +336,14 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi 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 || @@ -375,6 +386,9 @@ export function decodeClientCapabilityReplaceInput(value: unknown): ClientCapabi 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 4b984e935c..b619efca6c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,8 @@ 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 = 184 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 185 as const; +// 185: Session MCP configuration identities fence conflicting providers across ACP clients. // 184: An opt-in Session capability replacement can require an atomic idle // root check. Older Hosts would ignore that protection during MCP reconfiguration. // 183: Jev policy snapshots, set_jev mutation and credential locator require matching peers. 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 44b99d22f3..4ae832e6cd 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -110,6 +110,7 @@ interface ClientProviderConnection { } interface CapabilityRegistration { + readonly sessionConfigurationId?: string; readonly providerId: string; readonly connectionId: string; readonly registrationId: string; @@ -997,6 +998,38 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } const { provider } = connection; + const sessionId = input.sessionId; + if (sessionId !== undefined) { + 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 = + input.sessionConfigurationId !== undefined && + [...(this.#sessions.get(sessionId)?.sessionBindings.values() ?? [])].some( + (binding) => + binding.sessionId === sessionId && + binding.providerId !== provider.providerId && + !this.#providers.get(binding.providerId)?.sessionRegistrations.has(sessionId), + ); + if (conflicts || lostBinding) { + return { + ok: false, + error: { + code: 'session_binding_conflict', + message: + 'Session MCP configuration conflicts with another provider; close its Session attachment before replacing it', + }, + }; + } + } if ( input.sessionId !== undefined && !provider.sessionRegistrations.has(input.sessionId) && @@ -1752,6 +1785,9 @@ function freezeRegistration( providerId, connectionId, registrationId: input.registrationId, + ...(input.sessionConfigurationId === undefined + ? {} + : { sessionConfigurationId: input.sessionConfigurationId }), ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), trustedProvider, offersByContract, From c50987a6b4d792b6e67bf8ddd3f022decba221c8 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:31:57 +0800 Subject: [PATCH 16/22] fix(acp): retain queued terminal facts across observation barriers Keep the authoritative terminal snapshot beside its queued events until consumption, including successors that complete before initial output drains. Add all three terminal-state regressions for that boundary. Generated-by: Codex --- .../__tests__/acp-session-registry.test.ts | 34 ++++++++++++++++--- packages/cli/src/acp/README.md | 5 +-- packages/cli/src/acp/VALIDATION.md | 11 +++--- packages/cli/src/acp/session-registry.ts | 13 ++----- .../cli/src/runtime-host-session-channel.ts | 13 ++++++- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index fab33f1e42..9807643a87 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -433,7 +433,7 @@ describe('ACP Session registry', () => { }); for (const status of ['completed', 'failed', 'cancelled'] as const) { - for (const nextTurn of [false, true]) { + for (const nextTurn of ['none', 'running', 'completed'] as const) { test(`restore reports ${status} during readiness with next Turn ${nextTurn}`, { timeout: 10_000, }, async () => { @@ -459,7 +459,14 @@ describe('ACP Session registry', () => { : { status: 'cancelled' as const, abortSource: 'user' }), }, ); - if (nextTurn) subscription.setRoot(runningTurn(sessionId, 'next-turn')); + 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[] = []; @@ -489,6 +496,17 @@ describe('ACP Session registry', () => { 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, @@ -497,12 +515,20 @@ describe('ACP Session registry', () => { ...(status === 'failed' ? { failureClass: 'provider_failure' } : {}), }); delivered.push(status); - terminal.resolve(); + if (nextTurn !== 'completed') terminal.resolve(); }, }, ); await terminal.promise; - assert.deepEqual(delivered, ['final output', status]); + 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(); } diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 27c79abf92..a23a4cbf5d 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -86,8 +86,9 @@ 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 received during initial subscription readiness are retained for -their exact Turn and run, even if a new root starts before observation is adopted. +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; diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 764e2b6394..cee3867825 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -38,8 +38,9 @@ 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. -Initial subscription snapshots retain terminal facts by Turn and run until the -observer is adopted, including when the next root has already started. Completed, +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 @@ -53,14 +54,16 @@ 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. +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 | 1229 passed, 3 skipped, 0 failed. | +| 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. | diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index ce01317933..63f2b33bb9 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -753,9 +753,6 @@ export class AcpSessionRegistry { let task!: Promise; let attachment: RuntimeHostSessionChannel | undefined; let earlyFailure: Error | undefined; - // ready() can deliver a terminal snapshot and its successor before open() - // returns the channel. Keep the exact facts until activation adopts those Turns. - const initialTerminalTurns = new Map(); const failAttachment = (error: Error) => { if (!attachment) { earlyFailure = error; @@ -823,7 +820,7 @@ export class AcpSessionRegistry { attachment, false, turn.runId, - initialTerminalTurns.get(turn.turnId), + attachment.terminalTurn(turn.turnId), ).catch((error: unknown) => { console.error('[acp] Attached Turn observation failed:', error); }); @@ -836,9 +833,6 @@ export class AcpSessionRegistry { interactions.terminalTurn(snapshot.rootTurn.turnId); } const root = snapshot.rootTurn; - if (!attachment && root && isRuntimeHostTerminalTurn(root)) { - initialTerminalTurns.set(root.turnId, root); - } const observation = root && this.#observation(sessionId, root.turnId); const prompt = observation instanceof AcpAdmittedTurnObservation ? observation : undefined; const observedRunId = @@ -949,8 +943,8 @@ export class AcpSessionRegistry { false, root?.turnId === attachedTurnId ? root.runId - : initialTerminalTurns.get(attachedTurnId)?.runId, - initialTerminalTurns.get(attachedTurnId), + : channel.terminalTurn(attachedTurnId)?.runId, + channel.terminalTurn(attachedTurnId), ); } channel.activate( @@ -958,7 +952,6 @@ export class AcpSessionRegistry { ? attachedTurnId : undefined, ); - initialTerminalTurns.clear(); return channel; }) .catch((error: unknown) => { diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index df6595986c..daddf15fce 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -269,9 +269,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(); @@ -291,6 +293,11 @@ export class RuntimeHostSessionChannel { return this.#pendingStartedTurns.keys().next().value; } + /** 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, @@ -514,6 +521,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(); } @@ -788,6 +796,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); @@ -911,6 +920,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); @@ -970,6 +980,7 @@ function awaitTranscript(task: Promise, signal?: AbortSignal): Promise } class SessionEventQueue implements AsyncIterable, AsyncIterator { + terminalTurn?: TerminalTurnSnapshot; readonly #items: SessionEvent[] = []; readonly #onLag: () => void; #waiting: From 518f482a26186c90a194372a9309affe40a84e92 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:46:48 +0800 Subject: [PATCH 17/22] fix(acp): restore retained Turn observation and await Stop delivery Adopt the current external Turn and pending interactions when load or resume reuses an attachment. Retain output-failure Stop on the shared observation through close and dispose, and centralize admitted Turn result transitions. Generated-by: Codex --- .../__tests__/acp-session-registry.test.ts | 171 ++++++++++++++++++ packages/cli/src/acp/VALIDATION.md | 29 +++ packages/cli/src/acp/session-interactions.ts | 12 +- packages/cli/src/acp/session-registry.ts | 120 +++++++----- packages/cli/src/acp/turn-observation.ts | 26 ++- 5 files changed, 305 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 9807643a87..23db504f66 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -111,6 +111,102 @@ const DEFAULT_CONFIG_OPTIONS: Array { + 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: '/workspace', 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: '/workspace', 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: '/workspace' }, 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[] = [ @@ -1317,6 +1413,81 @@ describe('ACP Session registry', () => { } }); + 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: '/workspace', 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; diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index cee3867825..528afccc77 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,35 @@ # ACP validation record +## 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 diff --git a/packages/cli/src/acp/session-interactions.ts b/packages/cli/src/acp/session-interactions.ts index 8a3e2b7bd6..e18a5c8c7f 100644 --- a/packages/cli/src/acp/session-interactions.ts +++ b/packages/cli/src/acp/session-interactions.ts @@ -103,6 +103,7 @@ interface PermissionPresentation { /** Connection-local presentation of Host interactions; the Host owns every answer and grant. */ export class AcpSessionInteractions { readonly #options: AcpSessionInteractionsOptions; + #client: AcpInteractionClient; readonly #lifetime = new AbortController(); readonly #pending = new Map(); readonly #resolving = new Map>(); @@ -112,6 +113,11 @@ export class AcpSessionInteractions { constructor(options: AcpSessionInteractionsOptions) { this.#options = options; + this.#client = options.client; + } + + setClient(client: AcpInteractionClient): void { + this.#client = client; } pending(snapshot: InteractionPendingSnapshot): Promise { @@ -233,7 +239,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 +258,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 +275,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-registry.ts b/packages/cli/src/acp/session-registry.ts index 63f2b33bb9..725203a4ff 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -94,6 +94,15 @@ 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 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' @@ -487,15 +496,15 @@ export class AcpSessionRegistry { } if (active.cancelled) { - await active.admission.stopTask?.catch(() => undefined); + await active.stopTask?.catch(() => undefined); return { stopReason: await this.#cancelledStopReason(active) }; } const stopReason = await observation; return { stopReason }; } catch (error) { // A failed projection must not leave the corresponding Host Turn running. - active.admission.stopTask ??= this.#stopPromptWhenObservable(active); - await active.admission.stopTask.catch(() => undefined); + active.stopTask ??= this.#stopPromptWhenObservable(active); + await active.stopTask.catch(() => undefined); if (active.cancelled) return { stopReason: await this.#cancelledStopReason(active) }; if (active.admission.failure) throw active.admission.failure; if (error instanceof RequestError) throw error; @@ -504,7 +513,7 @@ export class AcpSessionRegistry { context.signal.removeEventListener('abort', onAbort); // 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.admission.stopTask?.catch(() => undefined); + await active.stopTask?.catch(() => undefined); active.dispose(); this.#attachmentInteractions.get(active.sessionId)?.settleTurn(active.turnId); const observed = active.attachment?.snapshot.rootTurn; @@ -584,10 +593,10 @@ export class AcpSessionRegistry { active.projectionAbort.abort(); active.reconciliationAbort.abort(); this.#attachmentInteractions.get(active.sessionId)?.cancelTurn(active.turnId); - active.admission.stopTask ??= this.#stopPromptWhenObservable(active); + active.stopTask ??= this.#stopPromptWhenObservable(active); await Promise.all([ active.mapper.flush().catch(() => undefined), - active.admission.stopTask.catch((error: unknown) => { + active.stopTask.catch((error: unknown) => { // End only this prompt's observation. Failed delivery does not establish // a terminal Host Turn, and teardown still receives the original error. active.attachment?.failTurn(active.turnId, error); @@ -647,9 +656,7 @@ export class AcpSessionRegistry { if (active.finished || active.admission.settled || (this.#closing && attempt > 0)) return; const observed = active.attachment?.snapshot.rootTurn; if (observed?.turnId === active.turnId) { - active.admission.startedTurn = observed; - active.admission.settled = true; - active.wake(); + active.observeStartedTurn(observed); return; } try { @@ -659,18 +666,13 @@ export class AcpSessionRegistry { ADMISSION_QUERY_TIMEOUT_MS, ); if (!active.finished && !active.admission.settled) { - active.admission.startedTurn = turn; - active.admission.settled = true; - active.wake(); + active.observeStartedTurn(turn); } return; } catch (error) { if (error instanceof RuntimeHostOperationError && error.code === 'not_found') { const observed = active.attachment?.snapshot.rootTurn; - if (observed?.turnId === active.turnId) active.admission.startedTurn = observed; - else if (!active.admission.startedTurn) active.admission.rejected = true; - active.admission.settled = true; - active.wake(); + active.settleAbsentTurn(observed?.turnId === active.turnId ? observed : undefined); return; } lastError = error; @@ -684,18 +686,19 @@ export class AcpSessionRegistry { active.wake(); return; } - active.admission.failure = 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', + ), ); - active.wake(); } async #ensureAttachment( @@ -763,15 +766,7 @@ 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) => { const observation = this.#observation(sessionId, pending.turnId); if (observation && !observation.cancelled) { @@ -842,9 +837,7 @@ export class AcpSessionRegistry { if (root && observation && (observedRunId === undefined || observedRunId === root.runId)) { if (isRuntimeHostTerminalTurn(root)) observation.terminalTurn = root; if (prompt) { - prompt.admission.startedTurn ??= root; - prompt.admission.settled = true; - prompt.wake(); + prompt.observeStartedTurn(root); } } if (configuration.metadataRevision === undefined) { @@ -1141,11 +1134,10 @@ export class AcpSessionRegistry { (observation.runId === undefined || root.runId === observation.runId) && !isRuntimeHostTerminalTurn(root) ) { - void this.#track(this.#stopAttachedTurn(attachment, root)).catch( - (stopError: unknown) => { - console.error('[acp] Host Stop delivery failed:', stopError); - }, - ); + 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, @@ -1168,9 +1160,9 @@ export class AcpSessionRegistry { ) { await admission.waitForChange(); } - await admission.admission.stopTask?.catch(() => undefined); - this.#removeTurnObservation(admission); } + await observation.stopTask?.catch(() => undefined); + if (admission) this.#removeTurnObservation(admission); interactions?.settleTurn(turnId); observation.dispose(); this.#removeTurnObservation(observation); @@ -1199,6 +1191,9 @@ export class AcpSessionRegistry { if (plan.disposition === 'parked') return { kind: 'parked' as const, plan }; const priorContext = this.#externalObservationContexts.get(params.sessionId); this.#externalObservationContexts.set(params.sessionId, context); + this.#attachmentInteractions + .get(params.sessionId) + ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); let observation: AcpAdmittedTurnObservation | undefined; let attachment: RuntimeHostSessionChannel | undefined; const turnId = this.#newTurnId(); @@ -1237,7 +1232,7 @@ export class AcpSessionRegistry { attachment.failTurn(turnId, new Error(`Turn resume parked: ${result.plan.reason}`)); return { kind: 'parked' as const, plan: result.plan }; } - await observation.admission.stopTask?.catch(() => undefined); + await observation.stopTask?.catch(() => undefined); return { kind: 'started' as const, turn: result.turn, @@ -1286,6 +1281,9 @@ export class AcpSessionRegistry { } if (priorContext) this.#externalObservationContexts.set(params.sessionId, priorContext); else this.#externalObservationContexts.delete(params.sessionId); + this.#attachmentInteractions + .get(params.sessionId) + ?.setClient(priorContext?.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); if (failure instanceof RequestError) throw failure; throw requestErrorFromRuntimeHost(failure, 'turn.resume.start', { turnId }); } finally { @@ -1558,6 +1556,9 @@ export class AcpSessionRegistry { await Promise.all([...heldObservations].map((observation) => observation.holdLive())); } this.#externalObservationContexts.set(params.sessionId, context); + 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, @@ -1571,6 +1572,17 @@ export class AcpSessionRegistry { 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)) { + await this.#adoptTurn(params.sessionId, root.turnId, attachment, false, root.runId); + const interactions = this.#attachmentInteractions.get(params.sessionId); + for (const pending of attachment.snapshot.interactions.pending) { + if (pending.turnId === root.turnId) void interactions?.pending(pending); + } + } if (replayHistory) { const mappers = new Map(); await attachment.replayTranscript(async (messages) => { @@ -1645,6 +1657,9 @@ export class AcpSessionRegistry { } if (previousContext) this.#externalObservationContexts.set(params.sessionId, previousContext); else this.#externalObservationContexts.delete(params.sessionId); + this.#attachmentInteractions + .get(params.sessionId) + ?.setClient(previousContext?.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); if (!alreadyOwned) this.#ownedSessionIds.delete(params.sessionId); if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'subscription.open'); @@ -1819,6 +1834,9 @@ export class AcpSessionRegistry { } } 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(); @@ -1845,14 +1863,18 @@ export class AcpSessionRegistry { 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.admission.settled = true; - active.wake(); + active.settleOnClose(); } } await Promise.allSettled(cancellations); diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index eb76aef16b..cc933f3634 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -38,6 +38,8 @@ export class AcpTurnObservation { transcript?: ReturnType; projectionFailure?: unknown; terminalTurn?: RuntimeHostTerminalTurn; + /** Keep the Host transport alive until a requested Stop has settled. */ + stopTask?: Promise; cancelled = false; finished = false; #muteDepth = 0; @@ -193,7 +195,6 @@ export class AcpAdmittedTurnObservation extends AcpTurnObservation { query?: Promise; failure?: RequestError; startedTurn?: TurnSnapshot; - stopTask?: Promise; } = { waiters: new Set(), dispatchStarted: false, @@ -223,6 +224,29 @@ export class AcpAdmittedTurnObservation extends AcpTurnObservation { 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(); From d05fbb25fec1c028003b20a29c3c71a6cc39995b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:48:07 +0800 Subject: [PATCH 18/22] fix(acp): fence restore interaction replay against close Recheck restore lifetime and Session ownership after awaiting current Turn adoption before presenting pending interactions. Generated-by: Codex --- packages/cli/src/acp/session-registry.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 725203a4ff..1d7d24d2e7 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -1577,10 +1577,24 @@ export class AcpSessionRegistry { // now; opening a second channel would lose the queue and interaction state. const root = attachment.snapshot.rootTurn; if (root && !isRuntimeHostTerminalTurn(root)) { - await this.#adoptTurn(params.sessionId, root.turnId, attachment, false, root.runId); - const interactions = this.#attachmentInteractions.get(params.sessionId); - for (const pending of attachment.snapshot.interactions.pending) { - if (pending.turnId === root.turnId) void interactions?.pending(pending); + const observation = 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 (observation && !observation.finished && !this.#discardedAttachments.has(attachment)) { + const interactions = this.#attachmentInteractions.get(params.sessionId); + for (const pending of attachment.snapshot.interactions.pending) { + if (pending.turnId === root.turnId) void interactions?.pending(pending); + } } } if (replayHistory) { From 80dd7cf6ae1e499d666ea7926f78664de376a1d5 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:15:03 +0800 Subject: [PATCH 19/22] fix(acp): preserve restore ordering and Stop lifetime Keep adopted Turn Stops owned through close, roll back only the interaction client and context replaced by a failed restore, and let the Session channel schedule queued successors. Cover failure, cancellation, repeated restore, pending interaction, and teardown boundaries. Generated-by: OpenAI Codex --- .../__tests__/acp-session-registry.test.ts | 572 ++++++++++++++++++ packages/cli/src/acp/VALIDATION.md | 46 ++ packages/cli/src/acp/session-interactions.ts | 30 +- packages/cli/src/acp/session-registry.ts | 143 ++++- .../cli/src/runtime-host-session-channel.ts | 5 + 5 files changed, 762 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 23db504f66..cb2ce91643 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -111,6 +111,460 @@ const DEFAULT_CONFIG_OPTIONS: Array { + 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: '/workspace', 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: '/workspace' }, 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: '/workspace' }, 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' }, + ]); + const pageRead = deferred(); + 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: '/workspace', 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.resolve(); + const loading = registry.load( + { sessionId, cwd: '/workspace', mcpServers: [] }, + { + ...promptContext([]), + signal: abort.signal, + interactions: client(() => (newDialogs += 1)), + }, + ); + await pageRead.promise; + 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: '/workspace', mcpServers: [] }); + try { + await registry.resume({ sessionId, cwd: '/workspace' }, promptContext(oldNotifications)); + const resuming = registry.resumeTurn({ sessionId }, newerContext); + void resuming.catch(() => undefined); + await startEntered.promise; + await registry.resume({ sessionId, cwd: '/workspace' }, 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' }, + ]); + const pageRead = deferred(); + const pageGate = deferred(); + const startEntered = deferred(); + 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.resolve(); + 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: '/workspace', 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.resolve(); + const loading = registry.load( + { sessionId, cwd: '/workspace', mcpServers: [] }, + { ...promptContext([]), signal: abort.signal, interactions: client('load') }, + ); + void loading.catch(() => undefined); + await pageRead.promise; + const resuming = registry.resumeTurn( + { sessionId }, + { ...promptContext([]), interactions: client('resumeTurn') }, + ); + void resuming.catch(() => undefined); + await startEntered.promise; + 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}`; @@ -3836,6 +4290,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: '/workspace', mcpServers: [] }); + await registry.resume( + { sessionId, cwd: '/workspace' }, + { + 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: '/workspace', mcpServers: [] }); + await registry.resume( + { sessionId, cwd: '/workspace' }, + { + 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)); diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index 528afccc77..d0a096dba6 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,52 @@ # 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 diff --git a/packages/cli/src/acp/session-interactions.ts b/packages/cli/src/acp/session-interactions.ts index e18a5c8c7f..c77045e8c2 100644 --- a/packages/cli/src/acp/session-interactions.ts +++ b/packages/cli/src/acp/session-interactions.ts @@ -100,10 +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>(); @@ -114,10 +121,31 @@ export class AcpSessionInteractions { constructor(options: AcpSessionInteractionsOptions) { this.#options = options; this.#client = options.client; + this.#clientLease = { client: options.client, valid: true }; } - setClient(client: AcpInteractionClient): void { + 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 { diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 1d7d24d2e7..38cca5f8cc 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -168,6 +168,17 @@ interface AcpAttachmentConfiguration { delivery?: Promise; } +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. */ export class AcpSessionRegistry { readonly #connect: (signal: AbortSignal) => Promise; @@ -186,6 +197,7 @@ export class AcpSessionRegistry { 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>(); @@ -532,6 +544,11 @@ export class AcpSessionRegistry { #cancelSession(sessionId: string): Promise[]> { 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) { @@ -546,7 +563,13 @@ export class AcpSessionRegistry { !isRuntimeHostTerminalTurn(root) && !active.some((prompt) => prompt.turnId === root.turnId) ) { - await this.#stopAttachedTurn(opened, root); + 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, @@ -1015,6 +1038,7 @@ export class AcpSessionRegistry { 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(); @@ -1063,7 +1087,11 @@ export class AcpSessionRegistry { !this.#ownedSessionIds.has(sessionId) ) return; - if (this.#observation(sessionId, turnId)) return this.#observation(sessionId, turnId); + const existing = this.#observation(sessionId, turnId); + if (existing) { + this.#replayPendingInteractions(sessionId, turnId, attachment, existing); + return existing; + } const runId = expectedRunId ?? (attachment.snapshot.rootTurn?.turnId === turnId @@ -1099,6 +1127,7 @@ export class AcpSessionRegistry { return; } const task = observation.start(attachment); + this.#replayPendingInteractions(sessionId, turnId, attachment, observation); const interactions = this.#attachmentInteractions.get(sessionId); void task .then( @@ -1176,6 +1205,19 @@ export class AcpSessionRegistry { } } + #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); + } + } + async #resumeTurn( params: { sessionId: string; sourceRunId?: string; expectedRuntimeEventHighWater?: number }, context: AcpLoadContext, @@ -1189,9 +1231,8 @@ export class AcpSessionRegistry { throw requestErrorFromRuntimeHost(error, 'turn.resume.query'); } if (plan.disposition === 'parked') return { kind: 'parked' as const, plan }; - const priorContext = this.#externalObservationContexts.get(params.sessionId); - this.#externalObservationContexts.set(params.sessionId, context); - this.#attachmentInteractions + const restoreContext = this.#installExternalContext(params.sessionId, context); + const restoreClient = this.#attachmentInteractions .get(params.sessionId) ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); let observation: AcpAdmittedTurnObservation | undefined; @@ -1230,9 +1271,13 @@ export class AcpSessionRegistry { 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, @@ -1253,6 +1298,8 @@ export class AcpSessionRegistry { 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, @@ -1271,7 +1318,13 @@ export class AcpSessionRegistry { }, 'Runtime Host Turn resume admission could not be established', ); - if (!observation.admission.rejected) throw admissionError; + 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) { @@ -1279,11 +1332,8 @@ export class AcpSessionRegistry { this.#removeTurnObservation(observation); attachment?.failTurn(turnId, failure); } - if (priorContext) this.#externalObservationContexts.set(params.sessionId, priorContext); - else this.#externalObservationContexts.delete(params.sessionId); - this.#attachmentInteractions - .get(params.sessionId) - ?.setClient(priorContext?.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); + restoreContext.rollback(); + restoreClient?.rollback(); if (failure instanceof RequestError) throw failure; throw requestErrorFromRuntimeHost(failure, 'turn.resume.start', { turnId }); } finally { @@ -1345,6 +1395,7 @@ export class AcpSessionRegistry { this.#sessionLoadControllers.get(params.targetSessionId)?.abort(); this.#ownedSessionIds.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; @@ -1372,6 +1423,34 @@ export class AcpSessionRegistry { ); } + #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) { @@ -1485,9 +1564,11 @@ export class AcpSessionRegistry { const generation = this.#sessionCloseGenerations.get(params.sessionId) ?? 0; const alreadyOwned = this.#ownedSessionIds.has(params.sessionId); const heldObservations = new Set(); - const previousContext = this.#externalObservationContexts.get(params.sessionId); + 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 { @@ -1524,6 +1605,7 @@ export class AcpSessionRegistry { throw unknownSessionError(); } if (previousMcp) { + previousMcpReconfigureAttempted = true; await previousMcp.reconfigure(mcpConfig, lifetime, async () => { const latest = await getRuntimeHostSession(connection, params.sessionId); if (!latest) throw unknownSessionError(); @@ -1555,8 +1637,8 @@ export class AcpSessionRegistry { } await Promise.all([...heldObservations].map((observation) => observation.holdLive())); } - this.#externalObservationContexts.set(params.sessionId, context); - this.#attachmentInteractions + 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); @@ -1576,26 +1658,18 @@ export class AcpSessionRegistry { // 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)) { - const observation = await this.#adoptTurn( - params.sessionId, - root.turnId, - attachment, - false, - root.runId, - ); + 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 (observation && !observation.finished && !this.#discardedAttachments.has(attachment)) { - const interactions = this.#attachmentInteractions.get(params.sessionId); - for (const pending of attachment.snapshot.interactions.pending) { - if (pending.turnId === root.turnId) void interactions?.pending(pending); - } - } } if (replayHistory) { const mappers = new Map(); @@ -1633,6 +1707,8 @@ export class AcpSessionRegistry { }, lifetime); await Promise.all([...mappers.values()].map((mapper) => mapper.flush())); } + restoreContext.commit(); + restoreClient?.commit(); return { configOptions }; } catch (error) { const sharedAttachment = @@ -1642,6 +1718,8 @@ export class AcpSessionRegistry { 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'); } @@ -1664,16 +1742,14 @@ export class AcpSessionRegistry { .catch(() => undefined); } else if ( previousMcp && + previousMcpReconfigureAttempted && previousMcpConfig && this.#mcps.get(params.sessionId) === previousMcp ) { await previousMcp.reconfigure(previousMcpConfig).catch(() => undefined); } - if (previousContext) this.#externalObservationContexts.set(params.sessionId, previousContext); - else this.#externalObservationContexts.delete(params.sessionId); - this.#attachmentInteractions - .get(params.sessionId) - ?.setClient(previousContext?.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); + restoreContext?.rollback(); + restoreClient?.rollback(); if (!alreadyOwned) this.#ownedSessionIds.delete(params.sessionId); if (error instanceof RequestError) throw error; throw requestErrorFromRuntimeHost(error, 'subscription.open'); @@ -1842,6 +1918,7 @@ export class AcpSessionRegistry { async #dispose(): Promise { 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(); diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index daddf15fce..4b7ed07112 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -293,6 +293,11 @@ 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; From f5380fc642806d7e52f3b95f3f0f5353a6889536 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:17:37 +0800 Subject: [PATCH 20/22] fix(acp): reconcile restore review findings Generated-by: OpenAI Codex --- .../acp-session-event-mapper.test.ts | 33 ++ .../__tests__/acp-session-registry.test.ts | 369 ++++++++++++++++-- packages/cli/src/acp/README.md | 18 +- packages/cli/src/acp/session-event-mapper.ts | 16 +- packages/cli/src/acp/session-mcp.ts | 2 +- packages/cli/src/acp/session-registry.ts | 139 ++++++- packages/cli/src/acp/turn-observation.ts | 12 +- .../client-capability-coordinator.test.ts | 76 +++- packages/runtime-host/src/protocol/index.ts | 3 +- .../server/client-capability-coordinator.ts | 98 ++++- 10 files changed, 687 insertions(+), 79 deletions(-) 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 25c53e559f..846f732001 100644 --- a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -71,6 +71,39 @@ describe('ACP Session event mapper', () => { }); } + 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-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index cb2ce91643..65b77157df 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -51,7 +51,11 @@ import { type SessionTranscriptBootstrap, type SessionTranscriptPageInput, } from '@maka/runtime-host/protocol'; -import { AcpSessionRegistry, type AcpSessionRegistryConnection } from '../acp/session-registry.js'; +import { + AcpSessionRegistry, + type AcpAttachedTurnStatus, + type AcpSessionRegistryConnection, +} from '../acp/session-registry.js'; const SESSION_REVISION = `sha256:${'a'.repeat(64)}` as const; const NEW_SESSION_REVISION = `sha256:${'b'.repeat(64)}` as const; @@ -827,6 +831,61 @@ describe('ACP Session registry', () => { } }); + 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: '/workspace', mcpServers: [] }); + await registry.resume({ sessionId, cwd: '/workspace' }, promptContext([])); + const closing = registry.close({ sessionId }); + await stopStarted.promise; + const readsBeforeLoad = catalogReads; + const loading = registry.load( + { sessionId, cwd: '/workspace', 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[] = [ @@ -872,6 +931,129 @@ describe('ACP Session registry', () => { } }); + 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: '/workspace', mcpServers: [] }, context), + ); + await registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, context); + assert.deepEqual(delivered, ['first', 'second']); + assert.equal(opens, 2); + } finally { + await registry.dispose(); + } + }); + + test('history replay omits live output already delivered before its gate', 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: '/workspace' }, promptContext(live)); + const loading = registry.load( + { sessionId, cwd: '/workspace', mcpServers: [] }, + promptContext(replay), + ); + await catalogHeld.promise; + 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 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}`; @@ -1434,7 +1616,8 @@ describe('ACP Session registry', () => { } }); - test('unsupported restored interaction stops its exact Host Turn', async () => { + 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); @@ -1458,13 +1641,17 @@ describe('ACP Session registry', () => { ); 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') return pending; + if (operation === 'interaction.query') { + queries += 1; + return pending; + } if (operation === 'turn.stop') { stops.push(input as (typeof stops)[number]); subscription.setRoot(completedTurn(sessionId, turnId)); @@ -1485,16 +1672,19 @@ describe('ACP Session registry', () => { }, }, ); - await waitFor(() => statuses.includes('observation_failed')); - await waitFor(() => stops.length === 1); - assert.deepEqual(stops, [{ sessionId, turnId, runId: turn.runId }]); + 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} stops the adopted Host Turn without answering`, async () => { + 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); @@ -1518,6 +1708,7 @@ describe('ACP Session registry', () => { ); let stops = 0, answers = 0; + let presentations = 0; const statuses: string[] = []; const registry = new AcpSessionRegistry({ connect: async () => @@ -1546,6 +1737,7 @@ describe('ACP Session registry', () => { { ...promptContext([]), notify: async () => { + presentations += 1; if (failure === 'output-failure') throw new Error('Output failed'); }, notifyTurnStatus: async (status) => { @@ -1554,6 +1746,7 @@ describe('ACP Session registry', () => { 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' } }; @@ -1562,10 +1755,13 @@ describe('ACP Session registry', () => { }, }, ); - await waitFor(() => statuses.includes('observation_failed')); - await waitFor(() => stops === 1); + 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(); } }); @@ -2356,11 +2552,13 @@ describe('ACP Session registry', () => { } }); - test('copy keeps revision conflicts and unknown dispatched target identity without retrying', async () => { + 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 () => @@ -2376,6 +2574,12 @@ describe('ACP Session registry', () => { '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', @@ -2383,10 +2587,6 @@ describe('ACP Session registry', () => { 'connection_lost', ); } - if (operation === 'session.catalog.query') - return { kind: 'session', session: catalogSession(uncertainId) }; - if (operation === 'session.configuration.update') - return { kind: 'committed', session: catalogSession(uncertainId) }; throw new Error(`Unexpected operation ${operation}`); }, }), @@ -2411,19 +2611,89 @@ describe('ACP Session registry', () => { 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(); + } + }); + + 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: '/workspace', 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: uncertainId, + sessionId: targetSessionId, configId: 'collaboration_mode', value: 'plan', }) ).configOptions.length > 0, ); - assert.equal( - operations.filter((operation) => operation === 'session.branch.create').length, - 2, - ); } finally { await registry.dispose(); } @@ -3051,6 +3321,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: '/workspace' }, + { ...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'); @@ -4173,7 +4495,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); @@ -4186,6 +4508,7 @@ describe('ACP Session registry', () => { }; } if (operation === 'turn.stop') { + assert.equal(timeoutMs, 30_000); stopInputs.push(input); throw stopFailure; } @@ -6020,7 +6343,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']; @@ -6041,10 +6364,10 @@ function fakeConnection( listener({ kind: 'connected', hostEpoch: 'host-1', connectionId: 'connection-1' }); return () => undefined; }, - request: async (operation: string, input: unknown) => + 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 () => { diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index a23a4cbf5d..90305674ef 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -69,8 +69,10 @@ or reconnection without waiting for the Host to become available. | 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. @@ -194,12 +196,12 @@ 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`; close the other -client's Session attachment before retrying the replacement. Equivalent -configurations remain attachable, including live permission restoration; Host -retains its existing provider binding. A disconnected frozen provider must -reconnect under the same authenticated identity rather than being silently -replaced by another client. This optional wire field and its typed conflict use +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 diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index 788f34a501..70182bbaf2 100644 --- a/packages/cli/src/acp/session-event-mapper.ts +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -129,6 +129,9 @@ export class AcpSessionEventMapper { 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), @@ -146,8 +149,17 @@ export class AcpSessionEventMapper { sessionUpdate: 'user_message_chunk', content: { type: 'text', text }, messageId: message.id, - ...(attachments.length - ? { _meta: { '_maka/attachments': structuredClone(attachments) } } + ...(attachments.length || message.origin + ? { + _meta: { + ...(attachments.length + ? { '_maka/attachments': structuredClone(attachments) } + : {}), + ...(message.origin + ? { '_maka/origin': structuredClone(message.origin) } + : {}), + }, + } : {}), }, }); diff --git a/packages/cli/src/acp/session-mcp.ts b/packages/cli/src/acp/session-mcp.ts index cbd0e92e32..8673703ca0 100644 --- a/packages/cli/src/acp/session-mcp.ts +++ b/packages/cli/src/acp/session-mcp.ts @@ -296,7 +296,7 @@ export class AcpSessionMcp { sessionId: this.#sessionId, code: 'session_binding_conflict', }, - 'Session MCP configuration conflicts with another provider; close its Session attachment before replacing it', + 'Session MCP configuration conflicts with an active or frozen provider; restore with the same configuration or close the active attachment', ); } if ( diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 38cca5f8cc..fa914154b2 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -94,6 +94,8 @@ 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 () => { @@ -168,6 +170,11 @@ interface AcpAttachmentConfiguration { delivery?: Promise; } +interface HistoryReplayDelivery { + readonly textByMessage: Map; + readonly otherUpdates: Set; +} + interface AcpExternalContextLease { readonly context: AcpLoadContext; previous?: AcpExternalContextLease; @@ -203,6 +210,7 @@ export class AcpSessionRegistry { 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; @@ -227,6 +235,8 @@ export class AcpSessionRegistry { 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, () => @@ -242,6 +252,8 @@ export class AcpSessionRegistry { 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, () => @@ -388,6 +400,7 @@ export class AcpSessionRegistry { ); 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); @@ -419,6 +432,7 @@ export class AcpSessionRegistry { notify: async (notification) => { if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { await context.notify(notification); + this.#recordLiveHistoryDelivery(params.sessionId, notification); } }, }); @@ -601,11 +615,11 @@ export class AcpSessionRegistry { this.#attachmentInteractions.get(root.sessionId)?.cancelTurn(root.turnId); return ( this.#connection - ?.request('turn.stop', { - sessionId: root.sessionId, - turnId: root.turnId, - runId: root.runId, - }) + ?.request( + 'turn.stop', + { sessionId: root.sessionId, turnId: root.turnId, runId: root.runId }, + TURN_STOP_TIMEOUT_MS, + ) .then(() => undefined) ?? Promise.resolve() ); } @@ -640,11 +654,11 @@ export class AcpSessionRegistry { 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; @@ -806,6 +820,10 @@ export class AcpSessionRegistry { onFailure: (pending, 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); @@ -1106,6 +1124,7 @@ export class AcpSessionRegistry { const current = this.#externalObservationContexts.get(sessionId); if (!this.#closing && this.#ownedSessionIds.has(sessionId) && current) { await current.notify(notification); + this.#recordLiveHistoryDelivery(sessionId, notification); } }, }); @@ -1139,13 +1158,17 @@ export class AcpSessionRegistry { ) return; const root = observation.terminalTurn; - if (root) { + const outcome = root ?? observation.terminalOutcome; + const runId = root?.runId ?? observation.runId; + if (outcome && runId) { await this.#externalObservationContexts.get(sessionId)?.notifyTurnStatus?.({ sessionId, turnId, - runId: root.runId, - status: root.status, - ...(root.status === 'failed' ? { failureClass: root.failureClass } : {}), + runId, + status: outcome.status, + ...(outcome.status === 'failed' + ? { failureClass: outcome.failureClass ?? 'runtime_error' } + : {}), }); } }, @@ -1364,11 +1387,21 @@ export class AcpSessionRegistry { error.dispatch === 'dispatched' && !this.#closing ) { - this.#ownedSessionIds.add(params.targetSessionId); + // 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, + }); } - throw requestErrorFromRuntimeHost(error, operation, { - targetSessionId: params.targetSessionId, - }); } if (result.kind === 'committed' && !this.#closing) { this.#ownedSessionIds.add(params.targetSessionId); @@ -1394,6 +1427,7 @@ export class AcpSessionRegistry { ); 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() ?? @@ -1554,6 +1588,13 @@ export class AcpSessionRegistry { if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== requestedGeneration) { throw unknownSessionError(); } + const replayDelivery = replayHistory + ? (this.#historyReplayDelivery.get(params.sessionId) ?? { + textByMessage: new Map(), + otherUpdates: new Set(), + }) + : undefined; + if (replayDelivery) this.#historyReplayDelivery.set(params.sessionId, replayDelivery); const loadController = new AbortController(); this.#sessionLoadControllers.set(params.sessionId, loadController); const lifetime = AbortSignal.any([ @@ -1689,7 +1730,8 @@ export class AcpSessionRegistry { if (!mapper) { mapper = new AcpSessionEventMapper({ sessionId: params.sessionId, - notify: context.notify, + notify: (notification) => + this.#deliverHistoryNotification(notification, context.notify, replayDelivery!), signal: lifetime, }); mappers.set(message.turnId, mapper); @@ -1709,6 +1751,7 @@ export class AcpSessionRegistry { } restoreContext.commit(); restoreClient?.commit(); + if (replayHistory) this.#historyReplayDelivery.delete(params.sessionId); return { configOptions }; } catch (error) { const sharedAttachment = @@ -1765,6 +1808,49 @@ export class AcpSessionRegistry { } } + #recordLiveHistoryDelivery(sessionId: string, notification: SessionNotification): void { + const delivery = this.#historyReplayDelivery.get(sessionId); + if (!delivery) return; + const chunk = historyTextChunk(notification); + if (chunk) { + delivery.textByMessage.set( + chunk.key, + (delivery.textByMessage.get(chunk.key) ?? '') + 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); @@ -1978,6 +2064,7 @@ export class AcpSessionRegistry { ...configurations.map(({ tail }) => tail), ]); this.#ownedSessionIds.clear(); + this.#historyReplayDelivery.clear(); } #closeOwnedConnection(): Promise { @@ -2283,3 +2370,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 index cc933f3634..642ba7dda2 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -38,6 +38,7 @@ export class AcpTurnObservation { 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; @@ -129,8 +130,15 @@ export class AcpTurnObservation { try { for await (const event of events) { await this.#waitForLive(); - if (event.type === 'abort') terminalStatus = 'cancelled'; - else if (event.type === 'error' && !event.recoverable) terminalStatus = 'failed'; + 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); } 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 659609bdbe..7920dfdead 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -96,7 +96,7 @@ describe('Host Client Capability coordinator', () => { } }); - test('a frozen scoped provider requires its authenticated identity to reconnect', async () => { + 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 () => {}, @@ -107,6 +107,10 @@ describe('Host Client Capability coordinator', () => { send: async () => {}, }, ); + const foreign = coordinator.attachConnection( + clientCapabilityConnectionIdentity('foreign', 'foreign', 'different-principal'), + { send: async () => {} }, + ); const input = { ...replacementInput('first', 'inspect'), sessionId: 'session-a', @@ -129,11 +133,27 @@ describe('Host Client Capability coordinator', () => { 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' }, + { + ...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 () => {} }, @@ -154,6 +174,58 @@ describe('Host Client Capability coordinator', () => { } 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) => + coordinator.handlers['client.capability.replace']( + { + ...replacementInput(registrationId, 'inspect'), + sessionId: 'session-a', + 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.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(); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b619efca6c..e10eba8ee5 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -106,7 +106,8 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 185 as const; // 185: Session MCP configuration identities fence conflicting providers across ACP clients. // 184: An opt-in Session capability replacement can require an atomic idle -// root check. Older Hosts would ignore that protection during MCP reconfiguration. +// root check. Older Hosts reject the widened exact input; the epoch prevents +// mixed-version peers from reaching that command. // 183: Jev policy snapshots, set_jev mutation and credential locator require matching peers. // 182: Executor catalogs expose structured model families and thinking variant IDs. // 181: Canonical executor models and retained provider stop reasons after cancellation. diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 4ae832e6cd..71404f5320 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -138,6 +138,7 @@ interface SessionCapabilityBinding { readonly kind: 'bound' | 'lost'; readonly providerId: string; readonly sessionId?: string; + readonly sessionConfigurationId?: string; } type SessionBindingMode = 'strict' | 'degrade'; @@ -553,7 +554,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); } @@ -568,7 +572,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); @@ -998,6 +1005,15 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } const { provider } = connection; + if (connection.superseded) { + return { + ok: false, + error: { + code: 'invalid_request', + message: 'Client Capability connection has been superseded', + }, + }; + } const sessionId = input.sessionId; if (sessionId !== undefined) { const conflicts = [...this.#providers.values()].some((other) => { @@ -1011,21 +1027,28 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ); }); // A disconnected frozen provider cannot silently be replaced either. - const lostBinding = - input.sessionConfigurationId !== undefined && - [...(this.#sessions.get(sessionId)?.sessionBindings.values() ?? [])].some( - (binding) => - binding.sessionId === sessionId && - binding.providerId !== provider.providerId && - !this.#providers.get(binding.providerId)?.sessionRegistrations.has(sessionId), - ); + const lostBinding = [...(this.#sessions.get(sessionId)?.sessionBindings ?? [])].some( + ([contractId, binding]) => + binding.sessionId === sessionId && + binding.providerId !== provider.providerId && + !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 (conflicts || lostBinding) { return { ok: false, error: { code: 'session_binding_conflict', message: - 'Session MCP configuration conflicts with another provider; close its Session attachment before replacing it', + 'Session MCP configuration conflicts with an active or frozen provider; restore with the same configuration or close the active attachment', }, }; } @@ -1043,15 +1066,6 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, }; } - if (connection.superseded && input.sessionId === undefined) { - return { - ok: false, - error: { - code: 'invalid_request', - message: 'Client Capability connection has been superseded', - }, - }; - } if (provider.registrations.has(input.registrationId)) { return { ok: false, @@ -1099,7 +1113,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); + if (previousConnection) previousConnection.superseded = true; + } 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); @@ -1535,6 +1556,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, @@ -2057,7 +2112,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; } From 14720aaf2fe20c5092a1ff7e35cdac304ad580a2 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:23:53 +0800 Subject: [PATCH 21/22] fix(runtime-host): scope provider handoff to one session Generated-by: OpenAI Codex --- .../__tests__/client-capability-coordinator.test.ts | 8 ++++++-- .../src/server/client-capability-coordinator.ts | 11 ++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) 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 7920dfdead..3882fbbea1 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -201,11 +201,11 @@ describe('Host Client Capability coordinator', () => { clientCapabilityConnectionIdentity('second-connection', 'shared-client'), { send: async () => {} }, ); - const publish = (connectionId: string, registrationId: string) => + const publish = (connectionId: string, registrationId: string, sessionId = 'session-a') => coordinator.handlers['client.capability.replace']( { ...replacementInput(registrationId, 'inspect'), - sessionId: 'session-a', + sessionId, sessionConfigurationId: `sha256:${'a'.repeat(64)}`, offers: replacementInput(registrationId, 'inspect').offers.map((offer) => ({ ...offer, @@ -220,6 +220,10 @@ describe('Host Client Capability coordinator', () => { 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, }); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 71404f5320..753dbea658 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -107,6 +107,7 @@ interface ClientProviderConnection { readonly provider: ClientProviderState; readonly sender: ClientCapabilityConnectionSender; superseded: boolean; + readonly supersededSessionIds: Set; } interface CapabilityRegistration { @@ -264,6 +265,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService provider, sender, superseded: false, + supersededSessionIds: new Set(), }); let closeTask: Promise | undefined; return { @@ -1005,7 +1007,11 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } const { provider } = connection; - if (connection.superseded) { + const sessionId = input.sessionId; + if ( + (sessionId === undefined && connection.superseded) || + (sessionId !== undefined && connection.supersededSessionIds.has(sessionId)) + ) { return { ok: false, error: { @@ -1014,7 +1020,6 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }, }; } - const sessionId = input.sessionId; if (sessionId !== undefined) { const conflicts = [...this.#providers.values()].some((other) => { if (other.providerId === provider.providerId) return false; @@ -1115,7 +1120,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService if (registration.sessionId !== undefined) { if (previous && previous.connectionId !== context.connectionId) { const previousConnection = this.#connections.get(previous.connectionId); - if (previousConnection) previousConnection.superseded = true; + previousConnection?.supersededSessionIds.add(registration.sessionId); } provider.sessionRegistrations.set(registration.sessionId, registration); if (registration.sessionConfigurationId !== undefined) { From 745430a3ce526dd655d2d074df854d634ae782fd Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:30:10 +0800 Subject: [PATCH 22/22] fix(acp): fence provider handoffs and live replay overlap Remember only live message prefixes actually delivered before history replay, including output sent before load begins. Reject changed Session configurations when a separate connection shares a provider identity, including after its old registration is lost. Preserve legacy configuration-less handoffs and make registry fixtures portable across host paths. Generated-by: OpenAI Codex --- .../__tests__/acp-session-registry.test.ts | 452 ++++++++++-------- packages/cli/src/acp/session-event-mapper.ts | 4 + packages/cli/src/acp/session-registry.ts | 26 +- packages/cli/src/acp/turn-observation.ts | 22 +- .../client-capability-coordinator.test.ts | 82 ++++ .../server/client-capability-coordinator.ts | 46 +- 6 files changed, 420 insertions(+), 212 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 65b77157df..8eb3c1371d 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -59,6 +59,7 @@ import { const SESSION_REVISION = `sha256:${'a'.repeat(64)}` as const; const NEW_SESSION_REVISION = `sha256:${'b'.repeat(64)}` as const; +const TEST_CWD = await realpath(process.cwd()); const DEFAULT_CONFIG_OPTIONS: Array> = [ { @@ -168,7 +169,7 @@ describe('ACP Session registry', () => { openSessionSubscriptionOnce: async () => subscription, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const context = { ...promptContext([]), interactions: { @@ -259,7 +260,7 @@ describe('ACP Session registry', () => { }, }; try { - await registry.resume({ sessionId, cwd: '/workspace' }, context); + 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); @@ -267,7 +268,7 @@ describe('ACP Session registry', () => { const reads = subscription.nextCalls; subscription.setRoot(successor); await waitFor(() => subscription.nextCalls > reads); - if (repeatRestore) await registry.resume({ sessionId, cwd: '/workspace' }, context); + 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)); @@ -290,7 +291,7 @@ describe('ACP Session registry', () => { subscription.seedBootstrap([ { type: 'user', id: 'history', turnId: 'old', ts: 1, text: 'old' }, ]); - const pageRead = deferred(); + let pageRead = false; const pageGate = deferred(); const abort = new AbortController(); const pending: InteractionPendingSnapshot = { @@ -350,7 +351,7 @@ describe('ACP Session registry', () => { }, requestPermission: async () => assert.fail('Unexpected permission'), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry .prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, @@ -360,16 +361,18 @@ describe('ACP Session registry', () => { try { await waitFor(() => started); subscription.transcriptPageGate = pageGate.promise; - subscription.onTranscriptPageRead = () => pageRead.resolve(); + subscription.onTranscriptPageRead = () => { + pageRead = true; + }; const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { ...promptContext([]), signal: abort.signal, interactions: client(() => (newDialogs += 1)), }, ); - await pageRead.promise; + await waitFor(() => pageRead); abort.abort(); pageGate.resolve(); await assert.rejects(loading); @@ -419,13 +422,13 @@ describe('ACP Session registry', () => { }), }); const newerContext = promptContext(newNotifications); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); try { - await registry.resume({ sessionId, cwd: '/workspace' }, promptContext(oldNotifications)); + 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: '/workspace' }, newerContext); + 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'); @@ -454,9 +457,9 @@ describe('ACP Session registry', () => { subscription.seedBootstrap([ { type: 'user', id: 'history', turnId: 'old', ts: 1, text: 'old' }, ]); - const pageRead = deferred(); + let pageRead = false; const pageGate = deferred(); - const startEntered = deferred(); + let startEntered = false; const start = deferred(); const abort = new AbortController(); const dialogs: string[] = []; @@ -506,7 +509,7 @@ describe('ACP Session registry', () => { sourceRuntimeEventHighWater: 42, }; if (operation === 'turn.resume.start') { - startEntered.resolve(); + startEntered = true; return start.promise; } if (operation === 'interaction.query') return pending; @@ -528,7 +531,7 @@ describe('ACP Session registry', () => { }, requestPermission: async () => assert.fail('Unexpected permission'), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const prompt = registry .prompt( { sessionId, prompt: [{ type: 'text', text: 'run' }] }, @@ -538,19 +541,21 @@ describe('ACP Session registry', () => { try { await waitFor(() => started); subscription.transcriptPageGate = pageGate.promise; - subscription.onTranscriptPageRead = () => pageRead.resolve(); + subscription.onTranscriptPageRead = () => { + pageRead = true; + }; const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { ...promptContext([]), signal: abort.signal, interactions: client('load') }, ); void loading.catch(() => undefined); - await pageRead.promise; + await waitFor(() => pageRead); const resuming = registry.resumeTurn( { sessionId }, { ...promptContext([]), interactions: client('resumeTurn') }, ); void resuming.catch(() => undefined); - await startEntered.promise; + await waitFor(() => startEntered); abort.abort(); pageGate.resolve(); await assert.rejects(loading); @@ -623,7 +628,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.prompt( { sessionId, prompt: [{ type: 'text', text: 'first' }] }, promptContext([]), @@ -643,7 +648,7 @@ describe('ACP Session registry', () => { requestPermission: async () => assert.fail('Unexpected permission'), }, }; - await registry[method]({ sessionId, cwd: '/workspace', mcpServers: [] }, restoreContext); + 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); @@ -655,7 +660,7 @@ describe('ACP Session registry', () => { update.content.text === 'external live output', ), ); - await registry.resume({ sessionId, cwd: '/workspace' }, restoreContext); + await registry.resume({ sessionId, cwd: TEST_CWD }, restoreContext); assert.equal(opens, 1); assert.equal(pendingPresented, 1); } finally { @@ -733,7 +738,7 @@ describe('ACP Session registry', () => { }); try { const loaded = await registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(notifications), ); assert.deepEqual(loaded.configOptions, DEFAULT_CONFIG_OPTIONS); @@ -767,7 +772,7 @@ describe('ACP Session registry', () => { ); const beforeSecondLoad = notifications.length; await registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(notifications), ); assert.deepEqual( @@ -783,8 +788,8 @@ describe('ACP Session registry', () => { ['earlier', 'answer'], ); const beforeResume = notifications.length; - await registry.resume({ sessionId, cwd: '/workspace' }, promptContext(notifications)); - await registry.resume({ sessionId, cwd: '/workspace' }, promptContext(notifications)); + 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 }); @@ -817,7 +822,7 @@ describe('ACP Session registry', () => { }); try { const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([]), ); await readStarted.promise; @@ -864,13 +869,13 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); - await registry.resume({ sessionId, cwd: '/workspace' }, promptContext([])); + 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: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([]), ); await new Promise((resolve) => setImmediate(resolve)); @@ -914,7 +919,7 @@ describe('ACP Session registry', () => { }); try { await registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(notifications), ); assert.equal(subscription.transcriptPageReads, 4); @@ -969,10 +974,8 @@ describe('ACP Session registry', () => { }, }; try { - await assert.rejects( - registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, context), - ); - await registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, context); + 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 { @@ -980,75 +983,143 @@ describe('ACP Session registry', () => { } }); - test('history replay omits live output already delivered before its gate', async () => { - const sessionId = 'session-pre-gate-replay'; + 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 subscription = new FakeSubscription(continuitySnapshot(sessionId, { rootTurn: turn })); - const catalogHeld = deferred(); - const releaseCatalog = deferred(); + 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[] = []; - 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; - } + 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: '/workspace' }, promptContext(live)); - const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, - promptContext(replay), - ); - await catalogHeld.promise; - subscription.appendText(turn.turnId, turn.runId, 'hel'); + 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 === 'hel', + update.content.text === 'lo', ), ); - 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; + subscription.publishTranscript([{ ...initial, text: 'hello' }]); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(replay)); assert.deepEqual( - replay.filter(({ update }) => update.sessionUpdate === 'agent_message_chunk'), - [], + replay.flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [], + ), + ['hello'], ); } finally { - releaseCatalog.resolve(); subscription.setRoot(completedTurn(sessionId, turn.turnId)); await registry.dispose(); } @@ -1067,7 +1138,7 @@ describe('ACP Session registry', () => { ? { kind: 'session', session: null } : { kind: 'session', - session: catalogSession(sessionId, '/workspace', { + session: catalogSession(sessionId, TEST_CWD, { isArchived: scenario === 'archived', }), }; @@ -1084,7 +1155,7 @@ describe('ACP Session registry', () => { registry.load( { sessionId, - cwd: scenario === 'cwd' ? '/other' : '/workspace', + cwd: scenario === 'cwd' ? '/other' : TEST_CWD, mcpServers: [], }, promptContext([]), @@ -1143,7 +1214,7 @@ describe('ACP Session registry', () => { }); try { const result = await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, promptContext(notifications), ); assert.deepEqual(result.configOptions, DEFAULT_CONFIG_OPTIONS); @@ -1217,7 +1288,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { signal: new AbortController().signal, notify: async ({ update }) => { @@ -1323,7 +1394,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext(notifications), interactions: { @@ -1385,7 +1456,7 @@ describe('ACP Session registry', () => { }); try { const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { signal: new AbortController().signal, notify: async ({ update }) => { @@ -1455,7 +1526,7 @@ describe('ACP Session registry', () => { }); try { const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext(notifications), ); await pageRead.promise; @@ -1520,11 +1591,11 @@ describe('ACP Session registry', () => { }); try { await assert.rejects( - registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])), + registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])), (error: unknown) => error instanceof RequestError, ); assert.equal(subscription.closeCalls, 1); - await registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); assert.equal(opens, 2); assert.equal(retrySubscription.closeCalls, 0); } finally { @@ -1556,12 +1627,12 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( - registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])), + registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])), ); assert.equal(subscription.closeCalls, 1); - await registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); assert.equal(opens, 2); assert.equal(retrySubscription.closeCalls, 0); } finally { @@ -1602,12 +1673,12 @@ describe('ACP Session registry', () => { }); try { await assert.rejects( - registry.load({ sessionId, cwd: '/workspace', mcpServers: [] }, promptContext([])), + 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: '/workspace', mcpServers: [] }, promptContext([])); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); assert.equal(opens, 2); assert.equal(retrySubscription.closeCalls, 0); assert.deepEqual(stops, []); @@ -1664,7 +1735,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), notifyTurnStatus: async (status) => { @@ -1733,7 +1804,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), notify: async () => { @@ -1819,7 +1890,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), interactions: { @@ -1904,7 +1975,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), interactions: { @@ -2029,7 +2100,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const result = await registry.resumeTurn( { sessionId }, { @@ -2104,7 +2175,7 @@ describe('ACP Session registry', () => { }, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.resumeTurn( { sessionId }, { @@ -2162,7 +2233,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.deepEqual(await registry.resumeTurn({ sessionId }, promptContext([])), { kind: 'parked', plan: { sessionId, disposition: 'parked', reason: 'resume_candidate_missing' }, @@ -2207,7 +2278,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await assert.rejects( registry.resumeTurn({ sessionId }, promptContext([])), (error: unknown) => { @@ -2314,7 +2385,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), notify: async (notification) => { @@ -2415,7 +2486,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const resuming = registry.resumeTurn({ sessionId }, promptContext([])); await started.promise; const cancelling = registry.cancel({ sessionId }); @@ -2444,7 +2515,7 @@ describe('ACP Session registry', () => { if (operation === 'session.catalog.query') return { kind: 'session', - session: catalogSession(sessionId, '/workspace', { revision }), + session: catalogSession(sessionId, TEST_CWD, { revision }), }; if (operation === 'session.turns.query') { queries.push(input); @@ -2459,7 +2530,7 @@ describe('ACP Session registry', () => { try { await assert.rejects(registry.queryCopySource(query), RequestError); assert.deepEqual(queries, []); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.deepEqual(await registry.queryCopySource(query), { sessionId, throughSequence: 20, @@ -2509,7 +2580,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const copy = { sourceSessionId, targetSessionId, @@ -2592,7 +2663,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const input = { sourceSessionId, targetSessionId: conflictedId, @@ -2672,7 +2743,7 @@ describe('ACP Session registry', () => { }), }); try { - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); assert.equal( ( await registry.branch({ @@ -2721,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', @@ -2814,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); @@ -2933,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: [] }), ), ); @@ -2960,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( @@ -3025,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' }] }, @@ -3142,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' }] }, { @@ -3281,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), @@ -3353,7 +3424,7 @@ describe('ACP Session registry', () => { }); try { await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { ...promptContext([]), notifyTurnStatus: async (status) => void statuses.push(status) }, ); first.fail(new RuntimeHostSubscriptionError('connection_closed', 'Connection was lost')); @@ -3409,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' }] }, @@ -3450,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([]), @@ -3513,9 +3584,9 @@ describe('ACP Session registry', () => { }, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const request = registry[method]( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { ...promptContext([]), signal: controller.signal }, ); const rejected = assert.rejects(request); @@ -3525,7 +3596,7 @@ describe('ACP Session registry', () => { 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: '/workspace', mcpServers: [] }, promptContext([])); + await registry.load({ sessionId, cwd: TEST_CWD, mcpServers: [] }, promptContext([])); assert.equal(opens, 2); opening.resolve(initial); transcript.resolve([]); @@ -3584,9 +3655,9 @@ describe('ACP Session registry', () => { }, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); const loading = registry.load( - { sessionId, cwd: '/workspace', mcpServers: [] }, + { sessionId, cwd: TEST_CWD, mcpServers: [] }, { ...promptContext([]), ...(cancelledMethod === 'load' ? { signal: abort.signal } : {}), @@ -3640,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( @@ -3702,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 }, @@ -3819,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 }, @@ -3940,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 }, @@ -4055,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 }, @@ -4100,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([]), @@ -4150,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([]), @@ -4205,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([]), @@ -4261,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([]), @@ -4351,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' }] }, @@ -4434,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([]), @@ -4519,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([]), @@ -4586,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([])) @@ -4643,9 +4714,9 @@ describe('ACP Session registry', () => { }, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { signal: new AbortController().signal, notify: async () => { @@ -4706,9 +4777,9 @@ describe('ACP Session registry', () => { }, }), }); - await registry.create({ cwd: '/workspace', mcpServers: [] }); + await registry.create({ cwd: TEST_CWD, mcpServers: [] }); await registry.resume( - { sessionId, cwd: '/workspace' }, + { sessionId, cwd: TEST_CWD }, { signal: new AbortController().signal, notify: async () => { @@ -4775,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([])), @@ -4828,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([]), @@ -4891,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' }] }, @@ -4963,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', }), @@ -4990,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), @@ -5052,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), @@ -5062,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, []); @@ -5106,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), @@ -5124,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(); @@ -5151,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([]), @@ -5177,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', @@ -5203,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', @@ -5222,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' }, }, }, @@ -5244,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), @@ -5314,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 }); @@ -5322,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, @@ -5352,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({ @@ -5391,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 [ [ @@ -5422,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', @@ -5446,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', @@ -5488,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', }), @@ -5499,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', @@ -5509,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', @@ -5557,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', }), @@ -5567,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', }), @@ -5578,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', @@ -5588,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({ @@ -5638,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({ @@ -5733,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({ @@ -5825,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({ @@ -5862,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 () => { @@ -5871,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', @@ -5938,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', @@ -5981,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'], }, @@ -6030,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(); } }); @@ -6743,7 +6811,7 @@ function connectionCatalogPage(thinkingLevels: readonly ThinkingLevel[]) { function catalogSession( id: string, - cwd = '/workspace', + cwd = TEST_CWD, overrides: Partial = {}, ): SessionCatalogProjection { return { diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index 060e88a599..7b194851d3 100644 --- a/packages/cli/src/acp/session-event-mapper.ts +++ b/packages/cli/src/acp/session-event-mapper.ts @@ -198,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-registry.ts b/packages/cli/src/acp/session-registry.ts index fa914154b2..e9fc3becfe 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -432,7 +432,7 @@ export class AcpSessionRegistry { notify: async (notification) => { if (!this.#closing && this.#ownedSessionIds.has(params.sessionId)) { await context.notify(notification); - this.#recordLiveHistoryDelivery(params.sessionId, notification); + this.#recordLiveHistoryDelivery(params.sessionId, notification, active); } }, }); @@ -1124,7 +1124,7 @@ export class AcpSessionRegistry { const current = this.#externalObservationContexts.get(sessionId); if (!this.#closing && this.#ownedSessionIds.has(sessionId) && current) { await current.notify(notification); - this.#recordLiveHistoryDelivery(sessionId, notification); + this.#recordLiveHistoryDelivery(sessionId, notification, observation); } }, }); @@ -1588,12 +1588,22 @@ export class AcpSessionRegistry { if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== requestedGeneration) { throw unknownSessionError(); } + const existingReplayDelivery = this.#historyReplayDelivery.get(params.sessionId); const replayDelivery = replayHistory - ? (this.#historyReplayDelivery.get(params.sessionId) ?? { + ? (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); @@ -1808,14 +1818,20 @@ export class AcpSessionRegistry { } } - #recordLiveHistoryDelivery(sessionId: string, notification: SessionNotification): void { + #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, - (delivery.textByMessage.get(chunk.key) ?? '') + chunk.text, + prefix?.startsWith(previous) ? prefix : previous + chunk.text, ); } else { delivery.otherUpdates.add(JSON.stringify(notification.update)); diff --git a/packages/cli/src/acp/turn-observation.ts b/packages/cli/src/acp/turn-observation.ts index 642ba7dda2..c0fb1d9cd1 100644 --- a/packages/cli/src/acp/turn-observation.ts +++ b/packages/cli/src/acp/turn-observation.ts @@ -20,7 +20,7 @@ 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, StopReason } from '@agentclientprotocol/sdk'; +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'; @@ -32,6 +32,7 @@ export class AcpTurnObservation { readonly turnId: string; readonly runId?: string; readonly mapper: AcpSessionEventMapper; + readonly deliveredTextByMessage = new Map(); readonly projectionAbort = new AbortController(); readonly reconciliationAbort = new AbortController(); attachment?: RuntimeHostSessionChannel; @@ -74,6 +75,25 @@ export class AcpTurnObservation { } } + 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; 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 3882fbbea1..4657a4f007 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -159,6 +159,17 @@ describe('Host Client Capability coordinator', () => { { 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']( @@ -237,6 +248,77 @@ describe('Host Client Capability coordinator', () => { } }); + 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({ diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 753dbea658..77134e1713 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -1021,6 +1021,14 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } 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); @@ -1033,21 +1041,31 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }); // A disconnected frozen provider cannot silently be replaced either. const lostBinding = [...(this.#sessions.get(sessionId)?.sessionBindings ?? [])].some( - ([contractId, binding]) => - binding.sessionId === sessionId && - binding.providerId !== provider.providerId && - !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) - ), + ([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 (conflicts || lostBinding) { + if (crossConnectionConflict || conflicts || lostBinding) { return { ok: false, error: {