From f27344a5220c6d3e92d84eca8a60984a984653ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:58:26 -0700 Subject: [PATCH 01/15] Add standalone ACP protocol client and session runtime --- config/scripts/acp/generate-protocol.mjs | 298 +++++++++++++ src/main/acp/acp-errors.ts | 31 ++ src/main/acp/acp-incoming-requests.ts | 91 ++++ src/main/acp/acp-json-rpc-peer.test.ts | 253 +++++++++++ src/main/acp/acp-json-rpc-peer.ts | 258 +++++++++++ src/main/acp/acp-permission-requests.ts | 88 ++++ .../acp/acp-scripted-agent.test-support.ts | 85 ++++ src/main/acp/acp-session-runtime.test.ts | 413 ++++++++++++++++++ src/main/acp/acp-session-runtime.ts | 303 +++++++++++++ src/main/acp/acp-session-setup.ts | 75 ++++ src/main/acp/acp-write-queue.ts | 119 +++++ .../acp/generated/client-capabilities.gen.ts | 113 +++++ src/main/acp/generated/diff.gen.ts | 126 ++++++ src/main/acp/generated/license.gen.ts | 197 +++++++++ src/main/acp/generated/mcp-server-acp.gen.ts | 116 +++++ .../generated/nes-context-capabilities.gen.ts | 124 ++++++ src/main/acp/generated/plan-update.gen.ts | 103 +++++ .../acp/generated/protocol-version.gen.ts | 93 ++++ src/main/acp/generated/protocol.gen.ts | 14 + .../generated/providers-capabilities.gen.ts | 87 ++++ .../generated/session-config-option.gen.ts | 113 +++++ src/main/acp/generated/session-update.gen.ts | 132 ++++++ .../set-session-model-request.gen.ts | 121 +++++ .../generated/text-resource-contents.gen.ts | 129 ++++++ .../wait-for-terminal-exit-request.gen.ts | 30 ++ 25 files changed, 3512 insertions(+) create mode 100644 config/scripts/acp/generate-protocol.mjs create mode 100644 src/main/acp/acp-errors.ts create mode 100644 src/main/acp/acp-incoming-requests.ts create mode 100644 src/main/acp/acp-json-rpc-peer.test.ts create mode 100644 src/main/acp/acp-json-rpc-peer.ts create mode 100644 src/main/acp/acp-permission-requests.ts create mode 100644 src/main/acp/acp-scripted-agent.test-support.ts create mode 100644 src/main/acp/acp-session-runtime.test.ts create mode 100644 src/main/acp/acp-session-runtime.ts create mode 100644 src/main/acp/acp-session-setup.ts create mode 100644 src/main/acp/acp-write-queue.ts create mode 100644 src/main/acp/generated/client-capabilities.gen.ts create mode 100644 src/main/acp/generated/diff.gen.ts create mode 100644 src/main/acp/generated/license.gen.ts create mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts create mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts create mode 100644 src/main/acp/generated/plan-update.gen.ts create mode 100644 src/main/acp/generated/protocol-version.gen.ts create mode 100644 src/main/acp/generated/protocol.gen.ts create mode 100644 src/main/acp/generated/providers-capabilities.gen.ts create mode 100644 src/main/acp/generated/session-config-option.gen.ts create mode 100644 src/main/acp/generated/session-update.gen.ts create mode 100644 src/main/acp/generated/set-session-model-request.gen.ts create mode 100644 src/main/acp/generated/text-resource-contents.gen.ts create mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs new file mode 100644 index 000000000000..db3de9cf5e31 --- /dev/null +++ b/config/scripts/acp/generate-protocol.mjs @@ -0,0 +1,298 @@ +import { createHash } from 'node:crypto' +import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +const release = 'schema-v1.21.0' +const legacyRelease = 'v0.11.6' +const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' +const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) +const check = process.argv.includes('--check') +const definitions = {} +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` + +async function download(url, digest) { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Download failed: ${url} (${response.status})`) + } + const text = await response.text() + if (createHash('sha256').update(text).digest('hex') !== digest) { + throw new Error(`Upstream content changed: ${url}`) + } + return text +} + +const [schema, legacy, license] = await Promise.all([ + download( + `${repository}/releases/download/${release}/schema.unstable.json`, + '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' + ), + download( + `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, + 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' + ), + download( + `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, + 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + ) +]) +Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) + +const roots = [ + 'InitializeRequest', + 'InitializeResponse', + 'AuthenticateRequest', + 'AuthenticateResponse', + 'NewSessionRequest', + 'NewSessionResponse', + 'LoadSessionRequest', + 'LoadSessionResponse', + 'ResumeSessionRequest', + 'ResumeSessionResponse', + 'PromptRequest', + 'PromptResponse', + 'CancelNotification', + 'SessionNotification', + 'RequestPermissionRequest', + 'RequestPermissionResponse', + 'SetSessionModeRequest', + 'SetSessionModeResponse', + 'SetSessionModelRequest', + 'SetSessionModelResponse', + 'SetSessionConfigOptionRequest', + 'SetSessionConfigOptionResponse', + 'ReadTextFileRequest', + 'ReadTextFileResponse', + 'WriteTextFileRequest', + 'WriteTextFileResponse', + 'CreateTerminalRequest', + 'CreateTerminalResponse', + 'TerminalOutputRequest', + 'TerminalOutputResponse', + 'ReleaseTerminalRequest', + 'ReleaseTerminalResponse', + 'WaitForTerminalExitRequest', + 'WaitForTerminalExitResponse', + 'KillTerminalRequest', + 'KillTerminalResponse' +] + +function references(value) { + if (!value || typeof value !== 'object') { + return [] + } + if (Array.isArray(value)) { + return value.flatMap(references) + } + return [ + ...(value.$ref ? [value.$ref.split('/').at(-1)] : []), + ...Object.values(value).flatMap(references) + ] +} + +const ordered = [] +const visiting = new Set() +const visited = new Set() +function visit(name) { + if (visited.has(name)) { + return + } + if (visiting.has(name)) { + throw new Error(`Recursive schema needs an explicit type: ${name}`) + } + if (!definitions[name]) { + throw new Error(`Missing definition: ${name}`) + } + visiting.add(name) + for (const dependency of references(definitions[name])) { + visit(dependency) + } + visiting.delete(name) + visited.add(name) + ordered.push(name) +} +roots.forEach(visit) + +function expression(value) { + if (value === true) { + return 'z.unknown()' + } + if (value === false) { + return 'z.never()' + } + if (value.$ref) { + return `${value.$ref.split('/').at(-1)}Schema` + } + if ('const' in value) { + return `z.literal(${JSON.stringify(value.const)})` + } + const alternatives = value.oneOf ?? value.anyOf + if (alternatives || value.allOf) { + const combined = alternatives + ? `z.union([${alternatives.map(expression).join(',')}])` + : value.allOf.map(expression).reduce((left, right) => `z.intersection(${left},${right})`) + const siblings = { ...value } + delete siblings.oneOf + delete siblings.anyOf + delete siblings.allOf + return siblings.type || siblings.properties + ? `z.intersection(${expression(siblings)},${combined})` + : combined + } + if (Array.isArray(value.type)) { + return `z.union([${value.type.map((type) => expression({ ...value, type })).join(',')}])` + } + let result + switch (value.type) { + case 'string': + result = 'z.string()' + break + case 'integer': + result = 'z.number().int()' + break + case 'number': + result = 'z.number()' + break + case 'boolean': + result = 'z.boolean()' + break + case 'null': + result = 'z.null()' + break + case 'array': + result = `z.array(${expression(value.items ?? true)})` + break + case 'object': { + const properties = Object.entries(value.properties ?? {}).map( + ([key, property]) => + `${JSON.stringify(key)}:${expression(property)}${value.required?.includes(key) ? '' : '.optional()'}` + ) + result = `z.${value.additionalProperties === false ? 'strictObject' : 'looseObject'}({${properties.join(',')}})` + if (typeof value.additionalProperties === 'object') { + result += `.catchall(${expression(value.additionalProperties)})` + } + break + } + default: + if ( + Object.keys(value).some( + (key) => !key.startsWith('x-') && !['description', 'title', 'default'].includes(key) + ) + ) { + throw new Error(`Unsupported schema: ${JSON.stringify(value)}`) + } + result = 'z.unknown()' + } + if (['integer', 'number'].includes(value.type) && typeof value.minimum === 'number') { + result += `.min(${value.minimum})` + } + if (['integer', 'number'].includes(value.type) && typeof value.maximum === 'number') { + result += `.max(${value.maximum})` + } + if (value.not) { + result += `.refine(value=>!${expression(value.not)}.safeParse(value).success)` + } + return result +} + +// Pack in dependency order so generated imports stay acyclic and each module stays small. +const groups = [] +let group = [] +let length = 0 +for (const name of ordered) { + const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` + if (length + body.length > 4300 && group.length) { + groups.push(group) + group = [] + length = 0 + } + group.push({ name, body }) + length += body.length +} +if (group.length) { + groups.push(group) +} +const filenames = new Map() +for (const entries of groups) { + const filename = entries[0].name.replace( + /[A-Z]/g, + (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` + ) + for (const { name } of entries) { + filenames.set(name, `${filename}.gen.ts`) + } +} + +const generated = new Map() +for (const entries of groups) { + const file = filenames.get(entries[0].name) + const imports = new Map() + for (const { body } of entries) { + for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { + const source = filenames.get(dependency) + if (source === file) { + continue + } + if (!imports.has(source)) { + imports.set(source, new Set()) + } + imports.get(source).add(`${dependency}Schema`) + } + } + generated.set( + file, + `${header}import { z } from 'zod'\n${[...imports] + .map( + ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + ) + .join('')}${entries.map((entry) => entry.body).join('\n')}` + ) +} +generated.set( + 'protocol.gen.ts', + `${ + header + + [...new Set(filenames.values())] + .map((file) => `export * from './${file.slice(0, -3)}'`) + .join('\n') + }\n` +) +generated.set( + 'license.gen.ts', + `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` +) + +// Use the repository formatter without spawning a platform-dependent executable shim. +const { format } = await import('oxfmt') +await mkdir(output, { recursive: true }) +for (const [file, source] of generated) { + const formatted = await format(file, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' + }) + if (formatted.errors.length) { + throw new Error(`Formatting failed for ${file}`) + } + const destination = resolve(output, file) + if (check) { + if ((await readFile(destination, 'utf8')) !== formatted.code) { + throw new Error(`Stale generated file: ${file}`) + } + } else { + await writeFile(destination, formatted.code) + } +} +for (const file of await readdir(output)) { + if (file.endsWith('.gen.ts') && !generated.has(file)) { + if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + throw new Error(`Unexpected generated file: ${file}`) + } + await unlink(resolve(output, file)) + } +} +console.log( + `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` +) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts new file mode 100644 index 000000000000..732db5c26cfc --- /dev/null +++ b/src/main/acp/acp-errors.ts @@ -0,0 +1,31 @@ +export class AcpRpcError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown + ) { + super(message) + this.name = 'AcpRpcError' + } +} + +export class AcpAuthRequiredError extends AcpRpcError { + constructor(message: string, data?: unknown) { + super(-32000, message, data) + this.name = 'AcpAuthRequiredError' + } +} + +export class AcpConnectionClosedError extends Error { + constructor(message = 'ACP connection closed') { + super(message) + this.name = 'AcpConnectionClosedError' + } +} + +export class AcpRequestTimeoutError extends Error { + constructor(readonly method: string) { + super(`ACP request timed out: ${method}`) + this.name = 'AcpRequestTimeoutError' + } +} diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts new file mode 100644 index 000000000000..5b55fb8d5835 --- /dev/null +++ b/src/main/acp/acp-incoming-requests.ts @@ -0,0 +1,91 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpPeerHandlers } from './acp-json-rpc-peer' + +type OpenRequest = { controller: AbortController; abandon: () => void } + +export class AcpIncomingRequests { + private readonly open = new Map() + + constructor( + private readonly handler: AcpPeerHandlers['onRequest'], + private readonly send: (message: object) => Promise, + private readonly onFailure: (error: Error) => void, + private readonly capacity: number, + private readonly timeoutMs: number + ) {} + + close(error: Error): void { + for (const request of this.open.values()) { + request.controller.abort(error) + request.abandon() + } + this.open.clear() + } + + handle(id: string | number | null, method: string, params: unknown): void { + if (this.open.has(id) || this.open.size >= this.capacity) { + void this.sendError( + id, + new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') + ) + return + } + const controller = new AbortController() + let timer: ReturnType | undefined + let abandon = (): void => {} + const deadline = new Promise((_resolve, reject) => { + abandon = () => reject(controller.signal.reason) + timer = setTimeout( + () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), + this.timeoutMs + ) + }) + this.open.set(id, { controller, abandon }) + void Promise.race([ + deadline, + Promise.resolve().then(() => { + if (controller.signal.aborted) { + return undefined + } + return this.handler?.(method, params, { id, signal: controller.signal }) + }) + ]) + .then(async (result) => { + if (controller.signal.aborted) { + return + } + if (result === undefined) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + await this.send({ jsonrpc: '2.0', id, result }) + }) + .catch(async (error) => { + if (controller.signal.aborted) { + return + } + await this.sendError( + id, + error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + ) + }) + .finally(() => { + clearTimeout(timer) + this.open.delete(id) + controller.abort() + }) + } + + private async sendError(id: string | number | null, error: AcpRpcError): Promise { + try { + await this.send({ + jsonrpc: '2.0', + id, + error: { code: error.code, message: error.message, data: error.data } + }) + } catch (failure) { + this.onFailure(failure instanceof Error ? failure : new Error(String(failure))) + } + } +} diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts new file mode 100644 index 000000000000..3aba7848293a --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -0,0 +1,253 @@ +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' + +const peers: AcpJsonRpcPeer[] = [] +const agents: AcpScriptedAgent[] = [] +function fixture(handlers: AcpPeerHandlers = {}, options: AcpPeerOptions = {}) { + const agent = new AcpScriptedAgent() + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, handlers, options) + agents.push(agent) + peers.push(peer) + return { peer, agent } +} +afterEach(() => { + peers.splice(0).forEach((peer) => peer.close()) + agents.splice(0).forEach((agent) => agent.close()) + vi.useRealTimers() +}) + +describe('ACP JSON-RPC peer', () => { + it('routes interleaved requests in both directions without conflating id types', async () => { + const { peer, agent } = fixture({ onRequest: (method, params) => ({ method, params }) }) + const first = peer.request('first', {}) + const second = peer.request('second', {}) + await tick() + expect(agent.frames.map((frame) => frame.id)).toEqual([1, 2]) + agent.send({ jsonrpc: '2.0', id: '1', result: 'not the numeric id' }) + const fromAgent = agent.request(1, '_question', { text: 'hello' }) + agent.reply(agent.frames[1], 'second result') + agent.reply(agent.frames[0], 'first result') + expect(await second).toBe('second result') + expect(await first).toBe('first result') + expect(await fromAgent).toMatchObject({ + id: 1, + result: { method: '_question', params: { text: 'hello' } } + }) + expect(await agent.request(null, '_null_id', {})).toMatchObject({ + id: null, + result: { method: '_null_id' } + }) + }) + + it('returns method-not-found and preserves explicit handler error objects', async () => { + const { agent } = fixture({ + onRequest: (method) => { + if (method === '_denied') { + throw new AcpRpcError(-32005, 'Denied', { reason: 'policy' }) + } + if (method === '_crash') { + throw new Error('Handler failed') + } + return undefined + } + }) + expect(await agent.request('missing', '_missing', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('denied', '_denied', {})).toMatchObject({ + error: { code: -32005, message: 'Denied', data: { reason: 'policy' } } + }) + expect(await agent.request('crash', '_crash', {})).toMatchObject({ + error: { code: -32603, message: 'Handler failed' } + }) + }) + + it('ignores malformed and oversized lines then resumes at the next newline', async () => { + const diagnostics: string[] = [] + const notified: unknown[] = [] + const { peer, agent } = fixture( + { + onDiagnostic: (message) => diagnostics.push(message), + onNotification: (_method, params) => notified.push(params) + }, + { maxLineBytes: 100 } + ) + agent.stdout.write('not json\n[]\n{"jsonrpc":"1.0","method":"bad"}\n') + for (let i = 0; i < 20; i++) { + agent.stdout.write('x'.repeat(40)) + } + agent.stdout.write('\n') + const data = Buffer.from('{"jsonrpc":"2.0","method":"notice","params":"✓"}\r\n') + const split = data.indexOf(Buffer.from('✓')) + 1 + agent.stdout.write(data.subarray(0, split)) + agent.stdout.write(data.subarray(split)) + agent.on('valid', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('valid', {})).toBe('ok') + expect(notified).toEqual(['✓']) + expect(diagnostics).toContain('Ignored ACP line: invalid-json') + expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') + }) + + it('ignores malformed response envelopes without settling pending requests', async () => { + const { peer, agent } = fixture() + const pending = peer.request('wait', {}) + agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') + expect(await pending).toBe('ok') + }) + + it('rejects all pending calls on exit and stops accepting requests', async () => { + const onClose = vi.fn() + const { peer, agent } = fixture({ onClose }) + const first = expect(peer.request('first', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('second', {})).rejects.toBeInstanceOf( + AcpConnectionClosedError + ) + agent.stdout.end() + await Promise.all([first, second]) + peer.close() + expect(onClose).toHaveBeenCalledTimes(1) + expect(agent.stdout.listenerCount('data')).toBe(0) + await expect(peer.request('late', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('bounds pending calls and frees capacity when a request times out', async () => { + vi.useFakeTimers() + const { peer, agent } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 20 }) + const timedOut = expect(peer.request('wait', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await expect(peer.request('overflow', {})).rejects.toThrow('capacity exceeded') + await vi.advanceTimersByTimeAsync(20) + await timedOut + agent.on('next', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('next', {})).toBe('ok') + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) + }) + + it('expires unanswered incoming requests and aborts their hooks', async () => { + vi.useFakeTimers() + const signals: AbortSignal[] = [] + const { agent } = fixture( + { + onRequest: (_method, _params, context) => { + signals.push(context.signal) + return new Promise(() => {}) + } + }, + { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + ) + const first = agent.request('first', '_question', {}) + await vi.advanceTimersByTimeAsync(0) + expect(await agent.request('overflow', '_question', {})).toMatchObject({ + error: { code: -32600 } + }) + await vi.advanceTimersByTimeAsync(20) + expect(await first).toMatchObject({ error: { code: -32001 } }) + expect(signals[0].aborted).toBe(true) + const next = agent.request('next', '_question', {}) + await vi.advanceTimersByTimeAsync(20) + expect(await next).toMatchObject({ error: { code: -32001 } }) + expect(signals).toHaveLength(2) + }) + + it('aborts an in-flight incoming hook on close and never writes its late response', async () => { + const entered = deferred() + const result = deferred() + const { peer, agent } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return result.promise + } + }) + void agent.request('open', '_question', {}) + const signal = await entered.promise + peer.close() + expect(signal.aborted).toBe(true) + result.resolve({ late: true }) + await tick() + expect(agent.frames).toEqual([]) + }) + + it('serializes writes through backpressure and bounds queued output', async () => { + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const input = new PassThrough() + const peer = new AcpJsonRpcPeer(input, output, {}, { maxQueuedWriteBytes: 170 }) + peers.push(peer) + const first = peer.notify('one', {}) + const second = peer.notify('two', {}) + const third = peer.notify('three', {}) + await expect(peer.notify('overflow', {})).rejects.toThrow('capacity exceeded') + expect(writes).toHaveLength(1) + callbacks[0]() + await first + expect(writes).toHaveLength(2) + callbacks[1]() + await second + callbacks[2]() + await third + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['one', 'two', 'three']) + peer.close() + input.destroy() + output.destroy() + }) + + it('rejects active and queued writes when output closes', async () => { + const input = new PassThrough() + const output = new Writable({ write(_chunk, _encoding, _callback) {} }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + const first = expect(peer.notify('one', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('two', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + output.destroy() + await Promise.all([first, second]) + input.destroy() + }) + + it('removes a timed-out queued request before it can reach the agent', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const input = new PassThrough() + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 }) + peers.push(peer) + const first = peer.notify('hold', {}) + const timedOut = expect(peer.request('must-not-arrive', {})).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(20) + await timedOut + callbacks[0]() + await first + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['hold']) + peer.close() + input.destroy() + output.destroy() + }) + + it('fails pending requests on an output stream error', async () => { + const { peer, agent } = fixture() + const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe') + agent.stdin.emit('error', new Error('Broken pipe')) + await rejected + expect(peer.closed).toBe(true) + }) +}) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts new file mode 100644 index 000000000000..0924a1f797fa --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -0,0 +1,258 @@ +import type { Readable, Writable } from 'node:stream' +import { z } from 'zod' +import { + createIncrementalNdjsonFramer, + encodeNdjson +} from '../../shared/main-process-ndjson-framer' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' +import { AcpIncomingRequests } from './acp-incoming-requests' +import { AcpWriteQueue } from './acp-write-queue' + +const idSchema = z.union([z.string(), z.number(), z.null()]) +const envelopeSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: idSchema.optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } +export type AcpPeerHandlers = { + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onNotification?: (method: string, params: unknown) => void + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +export type AcpPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + maxIncomingRequests?: number + requestTimeoutMs?: number + incomingRequestTimeoutMs?: number +} +type Pending = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export class AcpJsonRpcPeer { + private readonly pending = new Map() + private readonly incoming: AcpIncomingRequests + private readonly writer: AcpWriteQueue + private readonly framer: ReturnType + private nextId = 1 + private terminalError?: Error + private readonly maxLineBytes: number + private readonly maxPending: number + private readonly maxIncoming: number + private readonly timeoutMs: number + + constructor( + private readonly input: Readable, + private readonly output: Writable, + private readonly handlers: AcpPeerHandlers = {}, + options: AcpPeerOptions = {} + ) { + this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) + this.maxPending = bounded(options.maxPendingRequests, 128) + this.maxIncoming = bounded(options.maxIncomingRequests, 128) + this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.writer = new AcpWriteQueue( + output, + bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + (error) => this.close(error) + ) + this.incoming = new AcpIncomingRequests( + handlers.onRequest, + (message) => this.send(message), + (error) => this.close(error), + this.maxIncoming, + bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + ) + this.framer = createIncrementalNdjsonFramer( + (record) => this.dispatch(record), + (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + { maxLineBytes: this.maxLineBytes } + ) + input.setEncoding('utf8') + input.on('data', this.onData) + input.on('end', this.onEnd) + input.on('close', this.onEnd) + input.on('error', this.onError) + output.on('close', this.onEnd) + output.on('finish', this.onEnd) + output.on('error', this.onError) + if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + this.onEnd() + } + } + + get closed(): boolean { + return this.terminalError !== undefined + } + + request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (this.pending.size >= this.maxPending) { + return Promise.reject(new Error('ACP pending request capacity exceeded')) + } + const id = this.nextId++ + const controller = new AbortController() + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, + bounded(options.timeoutMs, this.timeoutMs) + ) + this.pending.set(id, { resolve, reject, timer }) + void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { + const pending = this.pending.get(id) + if (!pending) { + return + } + this.pending.delete(id) + clearTimeout(timer) + pending.reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + } + + notify(method: string, params: unknown): Promise { + return this.send({ jsonrpc: '2.0', method, params }) + } + + close(error: Error = new AcpConnectionClosedError()): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.input.removeListener('data', this.onData) + this.input.removeListener('end', this.onEnd) + this.input.removeListener('close', this.onEnd) + this.input.removeListener('error', this.onError) + this.output.removeListener('close', this.onEnd) + this.output.removeListener('finish', this.onEnd) + this.output.removeListener('error', this.onError) + this.framer.reset() + this.writer.close(error) + for (const pending of this.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + this.pending.clear() + this.incoming.close(error) + try { + this.handlers.onClose?.(error) + } catch (failure) { + this.diagnose(String(failure)) + } + } + + private readonly onData = (chunk: string): void => { + try { + this.framer.feed(chunk) + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + private readonly onEnd = (): void => this.close() + private readonly onError = (error: Error): void => this.close(error) + private diagnose(message: string): void { + try { + this.handlers.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break the transport. */ + } + } + + private send(message: object, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + try { + return this.writer.write(encodeNdjson(message, this.maxLineBytes), signal) + } catch (error) { + return Promise.reject(error) + } + } + + private dispatch(record: unknown): void { + if (this.closed) { + return + } + const parsed = envelopeSchema.safeParse(record) + if (!parsed.success) { + this.diagnose('Ignored invalid ACP JSON-RPC envelope') + return + } + const frame = parsed.data + if (frame.method !== undefined) { + if ('result' in frame || 'error' in frame) { + this.diagnose('Ignored invalid ACP request') + return + } + if (frame.id !== undefined) { + this.incoming.handle(frame.id, frame.method, frame.params) + return + } + try { + this.handlers.onNotification?.(frame.method, frame.params) + } catch (error) { + this.diagnose(`ACP notification handler failed: ${String(error)}`) + } + return + } + if ('result' in frame === 'error' in frame) { + this.diagnose('Ignored invalid ACP response') + return + } + if (typeof frame.id !== 'number') { + return + } + const pending = this.pending.get(frame.id) + if (!pending) { + return + } + this.pending.delete(frame.id) + clearTimeout(pending.timer) + if (frame.error) { + pending.reject( + frame.error.code === -32000 + ? new AcpAuthRequiredError(frame.error.message, frame.error.data) + : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) + ) + } else { + pending.resolve(frame.result) + } + } +} + +function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts new file mode 100644 index 000000000000..120f896be2b6 --- /dev/null +++ b/src/main/acp/acp-permission-requests.ts @@ -0,0 +1,88 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import { + RequestPermissionResponseSchema, + type RequestPermissionRequest, + type RequestPermissionResponse +} from './generated/protocol.gen' + +export type AcpPermissionHandler = ( + request: RequestPermissionRequest, + context: AcpRequestContext +) => RequestPermissionResponse | Promise + +const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } +type OpenPermission = { sessionId: string; cancel: () => void } + +export class AcpPermissionRequests { + private readonly open = new Set() + + cancel(sessionId: string): void { + for (const permission of this.open) { + if (permission.sessionId === sessionId) { + permission.cancel() + } + } + } + + handle( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined + ): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve, reject) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse | Error): void => { + if (settled) { + return + } + settled = true + this.open.delete(permission) + context.signal.removeEventListener('abort', permission.cancel) + controller.abort() + if (response instanceof Error) { + reject(response) + } else { + resolve(response) + } + } + const permission: OpenPermission = { + sessionId: request.sessionId, + cancel: () => finish(cancelled) + } + this.open.add(permission) + context.signal.addEventListener('abort', permission.cancel, { once: true }) + void Promise.resolve() + .then(() => { + if (controller.signal.aborted) { + return cancelled + } + return handler(request, { id: context.id, signal: controller.signal }) + }) + .then((response) => { + if (settled) { + return + } + const parsed = RequestPermissionResponseSchema.safeParse(response) + if (!parsed.success) { + finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + return + } + const outcome = parsed.data.outcome + if ( + outcome.outcome === 'selected' && + !request.options.some((option) => option.optionId === outcome.optionId) + ) { + finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + return + } + finish(parsed.data) + }) + .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) + }) + } +} diff --git a/src/main/acp/acp-scripted-agent.test-support.ts b/src/main/acp/acp-scripted-agent.test-support.ts new file mode 100644 index 000000000000..7de3fe93d2e6 --- /dev/null +++ b/src/main/acp/acp-scripted-agent.test-support.ts @@ -0,0 +1,85 @@ +import { PassThrough } from 'node:stream' +import { z } from 'zod' + +const frameSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number(), z.null()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type FakeFrame = z.infer +type Handler = (frame: FakeFrame) => void + +export class AcpScriptedAgent { + readonly stdout = new PassThrough() + readonly stdin = new PassThrough() + readonly frames: FakeFrame[] = [] + private readonly methods = new Map() + private readonly requests = new Map void>() + private suffix = '' + + constructor() { + this.stdin.setEncoding('utf8').on('data', (chunk: string) => { + this.suffix += chunk + let newline: number + while ((newline = this.suffix.indexOf('\n')) !== -1) { + const frame = frameSchema.parse(JSON.parse(this.suffix.slice(0, newline))) + this.suffix = this.suffix.slice(newline + 1) + this.frames.push(frame) + if (frame.method !== undefined) { + this.methods.get(frame.method)?.(frame) + } else if (frame.id !== undefined) { + this.requests.get(frame.id)?.(frame) + this.requests.delete(frame.id) + } + } + }) + } + + on(method: string, handler: Handler): void { + this.methods.set(method, handler) + } + reply(frame: FakeFrame, result: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, result }) + } + fail(frame: FakeFrame, code: number, message: string, data?: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, error: { code, message, data } }) + } + notify(method: string, params: unknown): void { + this.send({ jsonrpc: '2.0', method, params }) + } + request(id: string | number | null, method: string, params: unknown): Promise { + return new Promise((resolve) => { + this.requests.set(id, resolve) + this.send({ jsonrpc: '2.0', id, method, params }) + }) + } + send(frame: FakeFrame): void { + this.stdout.write(`${JSON.stringify(frame)}\n`) + } + close(): void { + this.stdout.end() + this.stdin.end() + } +} + +export function deferred(): { promise: Promise; resolve: (value: T) => void } { + let fulfill: ((value: T) => void) | undefined + const promise = new Promise((resolve) => { + fulfill = resolve + }) + return { + promise, + resolve: (value) => { + fulfill?.(value) + } + } +} + +export function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts new file mode 100644 index 000000000000..bdc1e0cd8256 --- /dev/null +++ b/src/main/acp/acp-session-runtime.test.ts @@ -0,0 +1,413 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError +} from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { + AgentCapabilities, + RequestPermissionResponse, + SessionNotification +} from './generated/protocol.gen' +import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + +const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const textPrompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(capabilities: AgentCapabilities = {}, options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: capabilities }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/load', (frame) => agent.reply(frame, {})) + agent.on('session/resume', (frame) => agent.reply(frame, {})) + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push({ runtime, agent }) + return { runtime, agent } +} +afterEach(() => { + for (const { runtime, agent } of opened.splice(0)) { + runtime.close() + agent.close() + } + vi.useRealTimers() +}) + +describe('ACP session runtime', () => { + it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { + const { runtime, agent } = fixture() + const events: SessionNotification[] = [] + const unsubscribe = runtime.subscribe((event) => events.push(event)) + const updates = [ + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, + { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }, + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + { sessionUpdate: 'tool_call', toolCallId: 'tool-1', title: 'Read file' }, + { sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'completed' }, + { + sessionUpdate: 'plan', + entries: [{ content: 'Read', priority: 'medium', status: 'completed' }] + }, + { sessionUpdate: 'usage_update', used: 12, size: 100 }, + { sessionUpdate: 'available_commands_update', availableCommands: [] }, + { sessionUpdate: 'current_mode_update', currentModeId: 'plan' }, + { sessionUpdate: 'config_option_update', configOptions: [] }, + { sessionUpdate: 'session_info_update', title: 'Test session' } + ] + agent.on('session/prompt', (frame) => { + for (const update of updates) { + agent.notify('session/update', { sessionId: 'session-1', update }) + } + agent.reply(frame, { stopReason: 'end_turn' }) + }) + await Promise.all([runtime.initialize(), runtime.initialize()]) + expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) + expect(events.map((event) => event.update)).toEqual(updates) + expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) + expect(agent.frames[0].params).toEqual({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(agent.frames.find((frame) => frame.method === 'session/new')?.params).toEqual( + startOptions + ) + unsubscribe() + agent.notify('session/update', { sessionId: 'session-1', update: updates[0] }) + expect(events).toHaveLength(updates.length) + }) + + it.each([ + [{ loadSession: true }, undefined, 'load'], + [{ sessionCapabilities: { resume: {} } }, undefined, 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, undefined, 'load'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, 'resume', 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: null } }, 'resume', 'load'] + ] as const)( + 'selects supported activation (%j, %s)', + async (capabilities, preference, expected) => { + const { runtime, agent } = fixture(capabilities) + const result = await runtime.start({ + ...startOptions, + sessionId: 'old', + resumePreference: preference + }) + expect(result).toMatchObject({ kind: expected, sessionId: 'old' }) + expect(agent.frames.at(-1)).toMatchObject({ + method: `session/${expected}`, + params: { ...startOptions, sessionId: 'old' } + }) + } + ) + + it('refuses unsupported restoration without silently creating a different session', async () => { + const { runtime, agent } = fixture({ sessionCapabilities: { resume: null } }) + await expect(runtime.start({ ...startOptions, sessionId: 'old' })).rejects.toMatchObject({ + code: -32601 + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + }) + + it('round-trips permission and vendor requests while prompt is pending', async () => { + const permitted = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (request) => { + expect(request.toolCall.toolCallId).toBe('tool-1') + return { outcome: { outcome: 'selected', optionId: 'allow' } } + }, + onRequest: (method, params) => + method === '_vendor/question' ? { answer: params } : undefined + } + ) + agent.on('session/prompt', (frame) => { + void agent.request('agent-id', 'session/request_permission', permission).then((response) => { + permitted.resolve(response.result) + agent.reply(frame, { stopReason: 'end_turn' }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + expect(await permitted.promise).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await prompt + expect(await agent.request(1, '_vendor/question', 'yes')).toMatchObject({ + id: 1, + result: { answer: 'yes' } + }) + expect(await agent.request('unknown', '_vendor/unknown', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('fs', 'fs/read_text_file', {})).toMatchObject({ + error: { code: -32601 } + }) + }) + + it('cancels open and late permissions and waits for the agent cancellation response', async () => { + const requested = deferred() + const decision = deferred() + const responses: unknown[] = [] + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return decision.promise + } + } + ) + agent.on('session/prompt', (frame) => { + void agent + .request('permission', 'session/request_permission', permission) + .then((response) => responses.push(response.result)) + agent.on('session/cancel', (cancel) => { + expect(cancel.id).toBeUndefined() + void agent + .request('late-permission', 'session/request_permission', permission) + .then((response) => { + responses.push(response.result) + agent.reply(frame, { stopReason: 'cancelled' }) + }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const signal = await requested.promise + const cancel = runtime.cancel() + expect(runtime.cancel()).toBe(cancel) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + await cancel + expect(signal.aborted).toBe(true) + expect(responses).toEqual([ + { outcome: { outcome: 'cancelled' } }, + { outcome: { outcome: 'cancelled' } } + ]) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await tick() + expect(agent.frames.filter((frame) => frame.id === 'permission')).toHaveLength(1) + }) + + it('surfaces authentication-required errors with code and data', async () => { + const { runtime, agent } = fixture() + agent.on('session/new', (frame) => + agent.fail(frame, -32000, 'Login required', { detail: 'Sign in' }) + ) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + code: -32000, + data: { detail: 'Sign in' } + }) + }) + + it('authenticates once with a configured agent method and retries session setup', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [{ id: 'login', name: 'Login' }] }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start({ ...startOptions, authMethodId: 'login' }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + expect(agent.frames[2].params).toEqual({ methodId: 'login' }) + }) + + it('chooses an advertised agent authentication method when login is required', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + expect(frame.params).toEqual({ methodId: 'agent' }) + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start(startOptions) + expect(authenticated).toBe(true) + }) + + it('does not retry authentication indefinitely or start an interactive login', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'terminal', name: 'Interactive login', type: 'terminal' }] + }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + const retry = fixture() + retry.agent.on('initialize', (frame) => + retry.agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'agent', name: 'Agent login' }] + }) + ) + retry.agent.on('authenticate', (frame) => retry.agent.reply(frame, {})) + retry.agent.on('session/new', (frame) => + retry.agent.fail(frame, -32000, 'Still requires login') + ) + await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('sends mode, model, and config changes to the active session', async () => { + const { runtime, agent } = fixture() + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => { + SetSessionConfigOptionRequestSchema.parse(frame.params) + agent.reply(frame, { configOptions: [] }) + }) + await runtime.start(startOptions) + await runtime.setMode('plan') + await runtime.setModel('model-1') + await runtime.setConfigOption('thinking', 'high') + expect(agent.frames.slice(-3).map((frame) => frame.params)).toEqual([ + { sessionId: 'session-1', modeId: 'plan' }, + { sessionId: 'session-1', modelId: 'model-1' }, + { sessionId: 'session-1', configId: 'thinking', value: 'high' } + ]) + await runtime.setConfigOption('enabled', true) + expect(agent.frames.at(-1)?.params).toEqual({ + sessionId: 'session-1', + configId: 'enabled', + value: true, + type: 'boolean' + }) + }) + + it('rejects an unsupported protocol and malformed session responses', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 2 })) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ code: -32602 }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + const malformed = fixture() + malformed.agent.on('session/new', (frame) => malformed.agent.reply(frame, {})) + await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) + }) + + it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + const { runtime, agent } = fixture( + {}, + { + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + } + ) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ + error: { code: -32602 } + }) + expect( + await agent.request('bad-selection', 'session/request_permission', permission) + ).toMatchObject({ error: { code: -32603 } }) + await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') + runtime.close() + await rejected + }) + + it('ignores invalid updates and isolates event listener failures', async () => { + const diagnostics: string[] = [] + const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) + const updates: SessionNotification[] = [] + runtime.subscribe(() => { + throw new Error('Consumer failed') + }) + runtime.subscribe((event) => updates.push(event)) + await runtime.start(startOptions) + agent.notify('session/update', { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: {} } + }) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'valid', _meta: { vendor: true } } + } + }) + expect(updates).toHaveLength(1) + expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) + expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) + }) + + it('rejects pending calls and aborts permission hooks when the agent exits', async () => { + const requested = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return new Promise(() => {}) + } + } + ) + agent.on('session/prompt', () => { + void agent.request('permission', 'session/request_permission', permission) + }) + await runtime.start(startOptions) + const pending = runtime.prompt([...textPrompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + const signal = await requested.promise + agent.stdout.end() + await rejected + expect(signal.aborted).toBe(true) + await expect(runtime.setMode('plan')).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('closes an unconfirmed cancellation rather than allowing another prompt', async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(100) + await Promise.all([rejectedPrompt, cancelled]) + await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) + }) +}) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts new file mode 100644 index 000000000000..f9296d6c82da --- /dev/null +++ b/src/main/acp/acp-session-runtime.ts @@ -0,0 +1,303 @@ +import type { Readable, Writable } from 'node:stream' +import type { z } from 'zod' +import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' +import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + setupAcpSession, + type AcpSessionStarted, + type AcpSessionStartOptions +} from './acp-session-setup' +export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { + InitializeResponseSchema, + AuthenticateResponseSchema, + PromptResponseSchema, + RequestPermissionRequestSchema, + SessionNotificationSchema, + SetSessionModeResponseSchema, + SetSessionModelResponseSchema, + SetSessionConfigOptionResponseSchema, + type InitializeRequest, + type InitializeResponse, + type AuthenticateResponse, + type PromptRequest, + type PromptResponse, + type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeResponse, + type SetSessionModelResponse +} from './generated/protocol.gen' + +export type AcpSessionRuntimeOptions = { + clientInfo?: InitializeRequest['clientInfo'] + peer?: AcpPeerOptions + promptTimeoutMs?: number + cancelTimeoutMs?: number + onPermission?: AcpPermissionHandler + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +export class AcpSessionRuntime { + private readonly peer: AcpJsonRpcPeer + private readonly permissions = new AcpPermissionRequests() + private readonly listeners = new Set<(event: SessionNotification) => void>() + private initialized?: Promise + private starting?: Promise + private started?: AcpSessionStarted + private activePrompt?: ActivePrompt + + constructor( + input: Readable, + output: Writable, + private readonly options: AcpSessionRuntimeOptions = {} + ) { + for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { + if ( + timeout !== undefined && + (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) + ) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + } + this.peer = new AcpJsonRpcPeer( + input, + output, + { + onRequest: (method, params, context) => this.handleRequest(method, params, context), + onNotification: (method, params) => this.handleNotification(method, params), + onDiagnostic: options.onDiagnostic, + onClose: options.onClose + }, + options.peer + ) + } + + subscribe(listener: (event: SessionNotification) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + initialize(): Promise { + this.initialized ??= this.call( + 'initialize', + { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + ...(this.options.clientInfo === undefined ? {} : { clientInfo: this.options.clientInfo }) + } satisfies InitializeRequest, + InitializeResponseSchema + ) + .then((response) => { + if (response.protocolVersion !== ACP_PROTOCOL_VERSION) { + const error = new AcpRpcError( + -32602, + `Unsupported ACP protocol version: ${response.protocolVersion}` + ) + this.peer.close(error) + throw error + } + return response + }) + .catch((error) => { + this.initialized = undefined + throw error + }) + return this.initialized + } + + async authenticate(methodId: string): Promise { + await this.initialize() + return this.call('authenticate', { methodId }, AuthenticateResponseSchema) + } + + start(options: AcpSessionStartOptions): Promise { + if (this.started) { + return Promise.resolve(this.started) + } + this.starting ??= this.initialize() + .then((initialized) => + setupAcpSession(initialized, options, (method, params, schema) => + this.call(method, params, schema) + ) + ) + .then((started) => { + this.started = started + return started + }) + .catch((error) => { + this.starting = undefined + throw error + }) + return this.starting + } + + prompt(prompt: PromptRequest['prompt']): Promise { + if (this.activePrompt) { + return Promise.reject(new Error('ACP prompt already in progress')) + } + const sessionId = this.sessionId() + const active: ActivePrompt = { + cancelling: false, + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { + timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 + }) + } + this.activePrompt = active + active.response = active.response.finally(() => { + this.permissions.cancel(sessionId) + if (this.activePrompt === active) { + this.activePrompt = undefined + } + }) + return active.response + } + + cancel(): Promise { + const sessionId = this.sessionId() + const active = this.activePrompt + if (!active) { + return Promise.resolve() + } + active.cancelling = true + this.permissions.cancel(sessionId) + active.cancelPromise ??= this.cancelActive(sessionId, active) + return active.cancelPromise + } + + private async cancelActive(sessionId: string, active: ActivePrompt): Promise { + const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + let timer: ReturnType | undefined + try { + await Promise.race([ + this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + this.peer.close(error) + reject(error) + }, timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } + } + + setMode(modeId: string): Promise { + return this.call( + 'session/set_mode', + { sessionId: this.sessionId(), modeId }, + SetSessionModeResponseSchema + ) + } + setModel(modelId: string): Promise { + return this.call( + 'session/set_model', + { sessionId: this.sessionId(), modelId }, + SetSessionModelResponseSchema + ) + } + setConfigOption( + configId: SetSessionConfigOptionRequest['configId'], + value: SetSessionConfigOptionRequest['value'] + ): Promise { + return this.call( + 'session/set_config_option', + { + configId, + value, + sessionId: this.sessionId(), + ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) + } satisfies SetSessionConfigOptionRequest, + SetSessionConfigOptionResponseSchema + ) + } + + close(error?: Error): void { + this.peer.close(error) + this.listeners.clear() + } + + private sessionId(): string { + if (!this.started) { + throw new Error('ACP session has not started') + } + return this.started.sessionId + } + + private async call( + method: string, + params: unknown, + schema: z.ZodType, + options?: { timeoutMs?: number } + ): Promise { + const result = await this.peer.request(method, params, options).catch((error) => { + // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. + if (error instanceof AcpRequestTimeoutError) { + this.peer.close(error) + } + throw error + }) + const parsed = schema.safeParse(result) + if (!parsed.success) { + throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + } + return parsed.data + } + + private handleRequest( + method: string, + params: unknown, + context: AcpRequestContext + ): unknown | Promise { + if (method !== 'session/request_permission') { + return this.options.onRequest?.(method, params, context) + } + const parsed = RequestPermissionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + if ( + !this.activePrompt || + this.activePrompt.cancelling || + parsed.data.sessionId !== this.started?.sessionId + ) { + return { outcome: { outcome: 'cancelled' } } + } + return this.permissions.handle(parsed.data, context, this.options.onPermission) + } + + private handleNotification(method: string, params: unknown): void { + if (method !== 'session/update') { + return + } + const parsed = SessionNotificationSchema.safeParse(params) + if (!parsed.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update') + return + } + for (const listener of this.listeners) { + try { + listener(parsed.data) + } catch (error) { + this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + } + } + } +} diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts new file mode 100644 index 000000000000..6c81f0fc78e5 --- /dev/null +++ b/src/main/acp/acp-session-setup.ts @@ -0,0 +1,75 @@ +import type { z } from 'zod' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { + NewSessionResponseSchema, + LoadSessionResponseSchema, + ResumeSessionResponseSchema, + AuthenticateResponseSchema, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type LoadSessionResponse, + type ResumeSessionResponse +} from './generated/protocol.gen' + +export type AcpSessionStarted = + | { kind: 'new'; sessionId: string; response: NewSessionResponse } + | { kind: 'load'; sessionId: string; response: LoadSessionResponse } + | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } +export type AcpSessionStartOptions = NewSessionRequest & { + sessionId?: string + resumePreference?: 'load' | 'resume' + authMethodId?: string +} +type Request = (method: string, params: unknown, schema: z.ZodType) => Promise + +export async function setupAcpSession( + initialized: InitializeResponse, + options: AcpSessionStartOptions, + request: Request +): Promise { + const setup = async (): Promise => { + const { sessionId, resumePreference, authMethodId: _auth, ...params } = options + if (sessionId === undefined) { + const response = await request('session/new', params, NewSessionResponseSchema) + return { kind: 'new', sessionId: response.sessionId, response } + } + const capabilities = initialized.agentCapabilities + const load = capabilities?.loadSession === true + const resume = capabilities?.sessionCapabilities?.resume != null + if (load && (resumePreference !== 'resume' || !resume)) { + return { + kind: 'load', + sessionId, + response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + } + } + if (resume) { + return { + kind: 'resume', + sessionId, + response: await request( + 'session/resume', + { ...params, sessionId }, + ResumeSessionResponseSchema + ) + } + } + throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') + } + try { + return await setup() + } catch (error) { + if (!(error instanceof AcpAuthRequiredError)) { + throw error + } + const method = initialized.authMethods?.find((method) => + options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' + ) + if (!method || method.type === 'terminal') { + throw error + } + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return setup() + } +} diff --git a/src/main/acp/acp-write-queue.ts b/src/main/acp/acp-write-queue.ts new file mode 100644 index 000000000000..7538fc04aa66 --- /dev/null +++ b/src/main/acp/acp-write-queue.ts @@ -0,0 +1,119 @@ +import type { Writable } from 'node:stream' +import { AcpConnectionClosedError } from './acp-errors' + +type Write = { + line: string + resolve: () => void + reject: (error: Error) => void + detachAbort?: () => void +} + +export class AcpWriteQueue { + private readonly queue: Write[] = [] + private bytes = 0 + private active?: Write + private terminalError?: Error + private detachDrain?: () => void + + constructor( + private readonly output: Writable, + private readonly maxBytes: number, + private readonly onFailure: (error: Error) => void + ) {} + + write(line: string, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + const bytes = Buffer.byteLength(line) + if (this.bytes + bytes > this.maxBytes) { + return Promise.reject(new Error('ACP write queue capacity exceeded')) + } + this.bytes += bytes + return new Promise((resolve, reject) => { + const write: Write = { line, resolve, reject } + const abort = (): void => { + const index = this.queue.indexOf(write) + if (index === -1) { + return + } + this.queue.splice(index, 1) + this.bytes -= bytes + write.detachAbort?.() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + write.detachAbort = () => signal?.removeEventListener('abort', abort) + this.queue.push(write) + this.flush() + }) + } + + close(error: Error): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.detachDrain?.() + this.active?.reject(error) + this.active = undefined + for (const write of this.queue.splice(0)) { + write.detachAbort?.() + write.reject(error) + } + this.bytes = 0 + } + + private flush(): void { + if (this.active || this.terminalError) { + return + } + const write = this.queue.shift() + if (!write) { + return + } + write.detachAbort?.() + this.active = write + if (this.output.destroyed || !this.output.writable) { + this.onFailure(new AcpConnectionClosedError('ACP output is not writable')) + return + } + let completed = false + let drained = false + let returned = false + const finish = (): void => { + if (!returned || !completed || !drained || this.terminalError) { + return + } + this.detachDrain?.() + this.active = undefined + this.bytes -= Buffer.byteLength(write.line) + write.resolve() + this.flush() + } + const onDrain = (): void => { + drained = true + finish() + } + this.output.once('drain', onDrain) + this.detachDrain = () => this.output.removeListener('drain', onDrain) + try { + const accepted = this.output.write(write.line, (error) => { + if (error) { + this.onFailure(error) + return + } + completed = true + finish() + }) + drained ||= accepted + returned = true + finish() + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } +} diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts new file mode 100644 index 000000000000..723ef2fddb15 --- /dev/null +++ b/src/main/acp/generated/client-capabilities.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + FileSystemCapabilitiesSchema, + ClientSessionCapabilitiesSchema, + PlanCapabilitiesSchema, + AuthCapabilitiesSchema, + ElicitationCapabilitiesSchema, + ClientNesCapabilitiesSchema, + PositionEncodingKindSchema, + ProtocolVersionSchema +} from './protocol-version.gen' +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts new file mode 100644 index 000000000000..21ec2dbe11ba --- /dev/null +++ b/src/main/acp/generated/diff.gen.ts @@ -0,0 +1,126 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + ContentSchema, + ToolCallIdSchema, + ToolKindSchema, + ToolCallStatusSchema +} from './text-resource-contents.gen' +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts new file mode 100644 index 000000000000..51e227cb3ce1 --- /dev/null +++ b/src/main/acp/generated/license.gen.ts @@ -0,0 +1,197 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed 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. +*/ +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts new file mode 100644 index 000000000000..105054e2a883 --- /dev/null +++ b/src/main/acp/generated/mcp-server-acp.gen.ts @@ -0,0 +1,116 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + McpServerAcpIdSchema, + McpServerHttpSchema, + McpServerSseSchema +} from './nes-context-capabilities.gen' +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts new file mode 100644 index 000000000000..3f193cd78045 --- /dev/null +++ b/src/main/acp/generated/nes-context-capabilities.gen.ts @@ -0,0 +1,124 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + NesRecentFilesCapabilitiesSchema, + NesRelatedSnippetsCapabilitiesSchema, + NesEditHistoryCapabilitiesSchema, + NesUserActionsCapabilitiesSchema, + NesOpenFilesCapabilitiesSchema, + NesDiagnosticsCapabilitiesSchema, + NesEventCapabilitiesSchema, + ProvidersCapabilitiesSchema +} from './providers-capabilities.gen' +import { + PromptCapabilitiesSchema, + McpCapabilitiesSchema, + SessionCapabilitiesSchema, + AgentAuthCapabilitiesSchema, + ImplementationSchema +} from './client-capabilities.gen' +import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts new file mode 100644 index 000000000000..eda1e7474274 --- /dev/null +++ b/src/main/acp/generated/plan-update.gen.ts @@ -0,0 +1,103 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' +import { SessionModeIdSchema } from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { ContentBlockSchema } from './text-resource-contents.gen' +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts new file mode 100644 index 000000000000..865debf5666e --- /dev/null +++ b/src/main/acp/generated/protocol-version.gen.ts @@ -0,0 +1,93 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts new file mode 100644 index 000000000000..1c1cd14183c8 --- /dev/null +++ b/src/main/acp/generated/protocol.gen.ts @@ -0,0 +1,14 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +export * from './protocol-version.gen' +export * from './client-capabilities.gen' +export * from './providers-capabilities.gen' +export * from './nes-context-capabilities.gen' +export * from './mcp-server-acp.gen' +export * from './session-config-option.gen' +export * from './text-resource-contents.gen' +export * from './diff.gen' +export * from './plan-update.gen' +export * from './session-update.gen' +export * from './set-session-model-request.gen' +export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts new file mode 100644 index 000000000000..c9864207ed26 --- /dev/null +++ b/src/main/acp/generated/providers-capabilities.gen.ts @@ -0,0 +1,87 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts new file mode 100644 index 000000000000..3d33a869a6fc --- /dev/null +++ b/src/main/acp/generated/session-config-option.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + SessionConfigIdSchema, + SessionConfigOptionCategorySchema, + SessionConfigSelectSchema, + SessionConfigBooleanSchema, + SessionIdSchema, + SessionModeStateSchema, + McpServerSchema +} from './mcp-server-acp.gen' +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts new file mode 100644 index 000000000000..7b67951065d7 --- /dev/null +++ b/src/main/acp/generated/session-update.gen.ts @@ -0,0 +1,132 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ContentChunkSchema } from './text-resource-contents.gen' +import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' +import { + PlanUpdateSchema, + PlanRemovedSchema, + AvailableCommandsUpdateSchema, + CurrentModeUpdateSchema, + ConfigOptionUpdateSchema, + SessionInfoUpdateSchema, + UsageUpdateSchema, + CompactionUpdateSchema, + CompactionSummaryChunkSchema +} from './plan-update.gen' +import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts new file mode 100644 index 000000000000..c25c1f98808e --- /dev/null +++ b/src/main/acp/generated/set-session-model-request.gen.ts @@ -0,0 +1,121 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ModelIdSchema } from './session-update.gen' +import { + SessionIdSchema, + SessionConfigIdSchema, + SessionConfigValueIdSchema, + EnvVariableSchema +} from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { TerminalIdSchema } from './diff.gen' +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts new file mode 100644 index 000000000000..c69e01fb6fb1 --- /dev/null +++ b/src/main/acp/generated/text-resource-contents.gen.ts @@ -0,0 +1,129 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + AnnotationsSchema, + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema +} from './session-config-option.gen' +import { SessionIdSchema } from './mcp-server-acp.gen' +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts new file mode 100644 index 000000000000..1f5fc01db7cc --- /dev/null +++ b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts @@ -0,0 +1,30 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { SessionIdSchema } from './mcp-server-acp.gen' +import { TerminalIdSchema } from './diff.gen' +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer From d09c278c4b46b12d0fac27488b7bb22a6e19567e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:02:23 -0700 Subject: [PATCH 02/15] Protect ACP transport teardown from late stream errors --- src/main/acp/acp-json-rpc-peer.test.ts | 15 +++++++++++++++ src/main/acp/acp-json-rpc-peer.ts | 5 +++-- src/main/acp/acp-stdio-error-boundary.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/main/acp/acp-stdio-error-boundary.ts diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 3aba7848293a..b68a09c8aec7 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -250,4 +250,19 @@ describe('ACP JSON-RPC peer', () => { await rejected expect(peer.closed).toBe(true) }) + + it('handles asynchronous write callback errors without an unhandled stream error', async () => { + const input = new PassThrough() + const output = new Writable({ + write(_chunk, _encoding, callback) { + setImmediate(() => callback(new Error('Broken pipe from write'))) + } + }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + await expect(peer.request('wait', {})).rejects.toThrow('Broken pipe from write') + await tick() + expect(peer.closed).toBe(true) + input.destroy() + }) }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0924a1f797fa..cac23f60b57e 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -12,6 +12,7 @@ import { } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' +import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) const envelopeSchema = z.looseObject({ @@ -149,10 +150,10 @@ export class AcpJsonRpcPeer { this.input.removeListener('data', this.onData) this.input.removeListener('end', this.onEnd) this.input.removeListener('close', this.onEnd) - this.input.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.input, this.onError) this.output.removeListener('close', this.onEnd) this.output.removeListener('finish', this.onEnd) - this.output.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.output, this.onError) this.framer.reset() this.writer.close(error) for (const pending of this.pending.values()) { diff --git a/src/main/acp/acp-stdio-error-boundary.ts b/src/main/acp/acp-stdio-error-boundary.ts new file mode 100644 index 000000000000..67854b6f95a2 --- /dev/null +++ b/src/main/acp/acp-stdio-error-boundary.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from 'node:stream' + +function ignoreLateError(): void {} + +export function detachAcpStreamErrorHandler( + stream: Readable | Writable, + handler: (error: Error) => void +): void { + stream.removeListener('error', handler) + if (stream.closed) { + return + } + // Node may emit the write error after its callback has already closed the peer. + stream.on('error', ignoreLateError) + stream.once('close', () => stream.removeListener('error', ignoreLateError)) +} From c3aa650d18eec279ed5090f51c400213b6110ad6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:09:09 -0700 Subject: [PATCH 03/15] Retire incoming ACP request ids before publishing responses --- src/main/acp/acp-incoming-requests.ts | 12 ++++++++++-- src/main/acp/acp-json-rpc-peer.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 5b55fb8d5835..c594e1d31e8a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -41,6 +41,12 @@ export class AcpIncomingRequests { ) }) this.open.set(id, { controller, abandon }) + const retire = (): void => { + clearTimeout(timer) + if (this.open.get(id)?.controller === controller) { + this.open.delete(id) + } + } void Promise.race([ deadline, Promise.resolve().then(() => { @@ -57,12 +63,15 @@ export class AcpIncomingRequests { if (result === undefined) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) } + // The agent may reuse the id as soon as it reads the response. + retire() await this.send({ jsonrpc: '2.0', id, result }) }) .catch(async (error) => { if (controller.signal.aborted) { return } + retire() await this.sendError( id, error instanceof AcpRpcError @@ -71,8 +80,7 @@ export class AcpIncomingRequests { ) }) .finally(() => { - clearTimeout(timer) - this.open.delete(id) + retire() controller.abort() }) } diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index b68a09c8aec7..1019049d51fb 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -65,6 +65,16 @@ describe('ACP JSON-RPC peer', () => { }) }) + it('allows the agent to reuse a request id after receiving its response', async () => { + const { agent } = fixture({ onRequest: () => ({ answer: true }) }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + }) + it('ignores malformed and oversized lines then resumes at the next newline', async () => { const diagnostics: string[] = [] const notified: unknown[] = [] From cdad7d2fb2d2163985374bdf4913ef46400c7307 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:14:08 -0700 Subject: [PATCH 04/15] Narrow ACP configuration requests and transport message types --- src/main/acp/acp-incoming-requests.ts | 4 ++-- src/main/acp/acp-json-rpc-peer.ts | 3 ++- src/main/acp/acp-session-runtime.ts | 16 ++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index c594e1d31e8a..ffdeae1a0992 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,5 +1,5 @@ import { AcpRpcError } from './acp-errors' -import type { AcpPeerHandlers } from './acp-json-rpc-peer' +import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' type OpenRequest = { controller: AbortController; abandon: () => void } @@ -8,7 +8,7 @@ export class AcpIncomingRequests { constructor( private readonly handler: AcpPeerHandlers['onRequest'], - private readonly send: (message: object) => Promise, + private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, private readonly timeoutMs: number diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index cac23f60b57e..5fda716003bb 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -25,6 +25,7 @@ const envelopeSchema = z.looseObject({ .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) .optional() }) +export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { onRequest?: ( @@ -186,7 +187,7 @@ export class AcpJsonRpcPeer { } } - private send(message: object, signal?: AbortSignal): Promise { + private send(message: AcpJsonRpcMessage, signal?: AbortSignal): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index f9296d6c82da..16f8eafe33ec 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -217,16 +217,12 @@ export class AcpSessionRuntime { configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { - return this.call( - 'session/set_config_option', - { - configId, - value, - sessionId: this.sessionId(), - ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) - } satisfies SetSessionConfigOptionRequest, - SetSessionConfigOptionResponseSchema - ) + const sessionId = this.sessionId() + const request = + typeof value === 'boolean' + ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) + : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } close(error?: Error): void { From 2ae4ab5452de296918344e8711ebd7b0e06bf72c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:18:14 -0700 Subject: [PATCH 05/15] Remove redundant ACP request handler return unions --- src/main/acp/acp-json-rpc-peer.ts | 6 +----- src/main/acp/acp-session-runtime.ts | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 5fda716003bb..f48a1b63ad01 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -28,11 +28,7 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 16f8eafe33ec..095573150ce6 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -37,11 +37,7 @@ export type AcpSessionRuntimeOptions = { promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } @@ -257,11 +253,7 @@ export class AcpSessionRuntime { return parsed.data } - private handleRequest( - method: string, - params: unknown, - context: AcpRequestContext - ): unknown | Promise { + private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { return this.options.onRequest?.(method, params, context) } From e30b885444e9ad1093d27a34aec68d974fe22015 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:12:45 -0700 Subject: [PATCH 06/15] Keep ACP waits caller-owned and preserve protocol extensions --- .gitattributes | 3 + .oxlintrc.json | 1 + config/scripts/acp/generate-protocol.mjs | 76 +- src/main/acp/acp-errors.ts | 8 +- src/main/acp/acp-incoming-requests.ts | 50 +- src/main/acp/acp-json-rpc-peer.test.ts | 40 +- src/main/acp/acp-json-rpc-peer.ts | 109 +- src/main/acp/acp-permission-requests.ts | 2 +- src/main/acp/acp-session-lifecycle.test.ts | 281 ++++ .../acp/acp-session-notifications.test.ts | 80 + src/main/acp/acp-session-runtime.test.ts | 78 +- src/main/acp/acp-session-runtime.ts | 91 +- src/main/acp/acp-session-setup.ts | 54 +- .../acp/generated/acp-protocol.generated.ts | 1390 +++++++++++++++++ .../acp/generated/client-capabilities.gen.ts | 113 -- src/main/acp/generated/diff.gen.ts | 126 -- src/main/acp/generated/license.gen.ts | 197 --- src/main/acp/generated/mcp-server-acp.gen.ts | 116 -- .../generated/nes-context-capabilities.gen.ts | 124 -- src/main/acp/generated/plan-update.gen.ts | 103 -- .../acp/generated/protocol-version.gen.ts | 93 -- src/main/acp/generated/protocol.gen.ts | 14 - .../generated/providers-capabilities.gen.ts | 87 -- .../generated/session-config-option.gen.ts | 113 -- src/main/acp/generated/session-update.gen.ts | 132 -- .../set-session-model-request.gen.ts | 121 -- .../generated/text-resource-contents.gen.ts | 129 -- .../wait-for-terminal-exit-request.gen.ts | 30 - 28 files changed, 2025 insertions(+), 1736 deletions(-) create mode 100644 src/main/acp/acp-session-lifecycle.test.ts create mode 100644 src/main/acp/acp-session-notifications.test.ts create mode 100644 src/main/acp/generated/acp-protocol.generated.ts delete mode 100644 src/main/acp/generated/client-capabilities.gen.ts delete mode 100644 src/main/acp/generated/diff.gen.ts delete mode 100644 src/main/acp/generated/license.gen.ts delete mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts delete mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts delete mode 100644 src/main/acp/generated/plan-update.gen.ts delete mode 100644 src/main/acp/generated/protocol-version.gen.ts delete mode 100644 src/main/acp/generated/protocol.gen.ts delete mode 100644 src/main/acp/generated/providers-capabilities.gen.ts delete mode 100644 src/main/acp/generated/session-config-option.gen.ts delete mode 100644 src/main/acp/generated/session-update.gen.ts delete mode 100644 src/main/acp/generated/set-session-model-request.gen.ts delete mode 100644 src/main/acp/generated/text-resource-contents.gen.ts delete mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/.gitattributes b/.gitattributes index e0e0282b69f8..d995215b26cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -88,3 +88,6 @@ /mobile/web-entry/**/*.otf -text /mobile/web-entry/**/*.woff -text /mobile/web-entry/**/*.woff2 -text + +# Generated ACP schemas are checked byte-for-byte against formatted output. +/src/main/acp/generated/*.generated.ts linguist-generated=true text eol=lf diff --git a/.oxlintrc.json b/.oxlintrc.json index 7a0753c06e27..fec10ce318ec 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -208,6 +208,7 @@ } ], "ignorePatterns": [ + "src/main/acp/generated/acp-protocol.generated.ts", "src/shared/rpc-contract/rpc-params-catalog.generated.ts", "**/node_modules", "**/dist", diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index db3de9cf5e31..9e154fde5052 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -60,6 +60,7 @@ const roots = [ 'SetSessionModeResponse', 'SetSessionModelRequest', 'SetSessionModelResponse', + 'SessionModelState', 'SetSessionConfigOptionRequest', 'SetSessionConfigOptionResponse', 'ReadTextFileRequest', @@ -196,72 +197,17 @@ function expression(value) { return result } -// Pack in dependency order so generated imports stay acyclic and each module stays small. -const groups = [] -let group = [] -let length = 0 -for (const name of ordered) { - const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - if (length + body.length > 4300 && group.length) { - groups.push(group) - group = [] - length = 0 - } - group.push({ name, body }) - length += body.length -} -if (group.length) { - groups.push(group) -} -const filenames = new Map() -for (const entries of groups) { - const filename = entries[0].name.replace( - /[A-Z]/g, - (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` - ) - for (const { name } of entries) { - filenames.set(name, `${filename}.gen.ts`) - } -} - -const generated = new Map() -for (const entries of groups) { - const file = filenames.get(entries[0].name) - const imports = new Map() - for (const { body } of entries) { - for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { - const source = filenames.get(dependency) - if (source === file) { - continue - } - if (!imports.has(source)) { - imports.set(source, new Set()) - } - imports.get(source).add(`${dependency}Schema`) - } - } - generated.set( - file, - `${header}import { z } from 'zod'\n${[...imports] +const generated = new Map([ + [ + 'acp-protocol.generated.ts', + `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered .map( - ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + (name) => + `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` ) - .join('')}${entries.map((entry) => entry.body).join('\n')}` - ) -} -generated.set( - 'protocol.gen.ts', - `${ - header + - [...new Set(filenames.values())] - .map((file) => `export * from './${file.slice(0, -3)}'`) - .join('\n') - }\n` -) -generated.set( - 'license.gen.ts', - `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` -) + .join('\n')}` + ] +]) // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') @@ -286,7 +232,7 @@ for (const [file, source] of generated) { } } for (const file of await readdir(output)) { - if (file.endsWith('.gen.ts') && !generated.has(file)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { throw new Error(`Unexpected generated file: ${file}`) } diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 732db5c26cfc..41aff9694e2e 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -1,3 +1,5 @@ +import type { AuthMethod } from './generated/acp-protocol.generated' + export class AcpRpcError extends Error { constructor( readonly code: number, @@ -10,7 +12,11 @@ export class AcpRpcError extends Error { } export class AcpAuthRequiredError extends AcpRpcError { - constructor(message: string, data?: unknown) { + constructor( + message: string, + data?: unknown, + readonly authMethods: AuthMethod[] = [] + ) { super(-32000, message, data) this.name = 'AcpAuthRequiredError' } diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index ffdeae1a0992..b565c00c207a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,7 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { controller: AbortController; abandon: () => void } +type OpenRequest = { method: string; controller: AbortController; abandon: () => void } export class AcpIncomingRequests { private readonly open = new Map() @@ -11,7 +11,7 @@ export class AcpIncomingRequests { private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, - private readonly timeoutMs: number + private readonly diagnose: (message: string) => void ) {} close(error: Error): void { @@ -22,50 +22,58 @@ export class AcpIncomingRequests { this.open.clear() } + cancel(exceptMethod?: string): void { + for (const [id, request] of this.open) { + if (request.method === exceptMethod) { + continue + } + const error = new AcpRpcError(-32800, 'Request cancelled') + this.open.delete(id) + request.controller.abort(error) + request.abandon() + void this.sendError(id, error) + } + } + handle(id: string | number | null, method: string, params: unknown): void { - if (this.open.has(id) || this.open.size >= this.capacity) { - void this.sendError( - id, - new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') - ) + if (this.open.has(id)) { + this.diagnose('Ignored duplicate ACP incoming request id') + return + } + if (this.open.size >= this.capacity) { + void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() - let timer: ReturnType | undefined let abandon = (): void => {} - const deadline = new Promise((_resolve, reject) => { + const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) - timer = setTimeout( - () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), - this.timeoutMs - ) }) - this.open.set(id, { controller, abandon }) + this.open.set(id, { method, controller, abandon }) const retire = (): void => { - clearTimeout(timer) if (this.open.get(id)?.controller === controller) { this.open.delete(id) } } void Promise.race([ - deadline, + abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { return undefined } - return this.handler?.(method, params, { id, signal: controller.signal }) + if (!this.handler) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.handler(method, params, { id, signal: controller.signal }) }) ]) .then(async (result) => { if (controller.signal.aborted) { return } - if (result === undefined) { - throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) - } // The agent may reuse the id as soon as it reads the response. retire() - await this.send({ jsonrpc: '2.0', id, result }) + await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { if (controller.signal.aborted) { diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 1019049d51fb..9ead2e2ddc0c 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -51,7 +51,7 @@ describe('ACP JSON-RPC peer', () => { if (method === '_crash') { throw new Error('Handler failed') } - return undefined + throw new AcpRpcError(-32601, 'Unknown method') } }) expect(await agent.request('missing', '_missing', {})).toMatchObject({ @@ -102,13 +102,14 @@ describe('ACP JSON-RPC peer', () => { expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) - it('ignores malformed response envelopes without settling pending requests', async () => { + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() - const pending = peer.request('wait', {}) + const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') - expect(await pending).toBe('ok') + await rejected + const contradictory = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) + agent.stdout.write('{"jsonrpc":"2.0","id":2,"result":"x","error":{"code":1,"message":"y"}}\n') + await contradictory }) it('rejects all pending calls on exit and stops accepting requests', async () => { @@ -138,30 +139,29 @@ describe('ACP JSON-RPC peer', () => { expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) }) - it('expires unanswered incoming requests and aborts their hooks', async () => { + it('bounds incoming requests without expiring user decisions', async () => { vi.useFakeTimers() - const signals: AbortSignal[] = [] + const signal = deferred() + const answer = deferred() const { agent } = fixture( { onRequest: (_method, _params, context) => { - signals.push(context.signal) - return new Promise(() => {}) + signal.resolve(context.signal) + return answer.promise } }, - { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + { maxIncomingRequests: 1, requestTimeoutMs: 20 } ) const first = agent.request('first', '_question', {}) - await vi.advanceTimersByTimeAsync(0) + const context = await signal.promise expect(await agent.request('overflow', '_question', {})).toMatchObject({ - error: { code: -32600 } + error: { code: -32603 } }) - await vi.advanceTimersByTimeAsync(20) - expect(await first).toMatchObject({ error: { code: -32001 } }) - expect(signals[0].aborted).toBe(true) - const next = agent.request('next', '_question', {}) - await vi.advanceTimersByTimeAsync(20) - expect(await next).toMatchObject({ error: { code: -32001 } }) - expect(signals).toHaveLength(2) + await vi.advanceTimersByTimeAsync(120_001) + expect(context.aborted).toBe(false) + answer.resolve({ answer: true }) + expect(await first).toMatchObject({ result: { answer: true } }) + expect(await agent.request('next', '_question', {})).toMatchObject({ result: { answer: true } }) }) it('aborts an in-flight incoming hook on close and never writes its late response', async () => { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index f48a1b63ad01..08df74ef89ae 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,33 +1,33 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' +import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { - AcpAuthRequiredError, - AcpConnectionClosedError, - AcpRequestTimeoutError, - AcpRpcError -} from './acp-errors' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) +const errorSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional() +}) const envelopeSchema = z.looseObject({ jsonrpc: z.literal('2.0'), id: idSchema.optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), - error: z - .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) - .optional() + error: z.unknown().optional() }) export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { + // Void means handled; unsupported methods must throw AcpRpcError(-32601). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void @@ -38,13 +38,13 @@ export type AcpPeerOptions = { maxQueuedWriteBytes?: number maxPendingRequests?: number maxIncomingRequests?: number - requestTimeoutMs?: number - incomingRequestTimeoutMs?: number + // Caller-selected outgoing deadlines only; omitted or null means unlimited. + requestTimeoutMs?: number | null } type Pending = { resolve: (value: unknown) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType } export class AcpJsonRpcPeer { @@ -57,7 +57,7 @@ export class AcpJsonRpcPeer { private readonly maxLineBytes: number private readonly maxPending: number private readonly maxIncoming: number - private readonly timeoutMs: number + private readonly timeoutMs: number | null constructor( private readonly input: Readable, @@ -68,7 +68,7 @@ export class AcpJsonRpcPeer { this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) this.maxPending = bounded(options.maxPendingRequests, 128) this.maxIncoming = bounded(options.maxIncomingRequests, 128) - this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.timeoutMs = requestTimeout(options.requestTimeoutMs) this.writer = new AcpWriteQueue( output, bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), @@ -79,7 +79,7 @@ export class AcpJsonRpcPeer { (message) => this.send(message), (error) => this.close(error), this.maxIncoming, - bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + (message) => this.diagnose(message) ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), @@ -103,25 +103,37 @@ export class AcpJsonRpcPeer { return this.terminalError !== undefined } - request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + request( + method: string, + params: unknown, + options: { timeoutMs?: number | null } = {} + ): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } if (this.pending.size >= this.maxPending) { return Promise.reject(new Error('ACP pending request capacity exceeded')) } + let timeoutMs: number | null + try { + timeoutMs = requestTimeout( + options.timeoutMs === undefined ? this.timeoutMs : options.timeoutMs + ) + } catch (error) { + return Promise.reject(error) + } const id = this.nextId++ const controller = new AbortController() return new Promise((resolve, reject) => { - const timer = setTimeout( - () => { - this.pending.delete(id) - const error = new AcpRequestTimeoutError(method) - controller.abort(error) - reject(error) - }, - bounded(options.timeoutMs, this.timeoutMs) - ) + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) @@ -139,6 +151,10 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } + cancelIncomingRequests(exceptMethod?: string): void { + this.incoming.cancel(exceptMethod) + } + close(error: Error = new AcpConnectionClosedError()): void { if (this.terminalError) { return @@ -194,6 +210,15 @@ export class AcpJsonRpcPeer { } } + private rejectInvalidResponse(id: number, raw: unknown): void { + const pending = this.pending.get(id) + if (pending) { + this.pending.delete(id) + clearTimeout(pending.timer) + pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + } + } + private dispatch(record: unknown): void { if (this.closed) { return @@ -201,6 +226,12 @@ export class AcpJsonRpcPeer { const parsed = envelopeSchema.safeParse(record) if (!parsed.success) { this.diagnose('Ignored invalid ACP JSON-RPC envelope') + const response = z + .object({ id: z.number(), method: z.undefined().optional() }) + .safeParse(record) + if (response.success) { + this.rejectInvalidResponse(response.data.id, record) + } return } const frame = parsed.data @@ -222,6 +253,9 @@ export class AcpJsonRpcPeer { } if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') + if (typeof frame.id === 'number') { + this.rejectInvalidResponse(frame.id, record) + } return } if (typeof frame.id !== 'number') { @@ -233,12 +267,15 @@ export class AcpJsonRpcPeer { } this.pending.delete(frame.id) clearTimeout(pending.timer) - if (frame.error) { - pending.reject( - frame.error.code === -32000 - ? new AcpAuthRequiredError(frame.error.message, frame.error.data) - : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) - ) + if ('error' in frame) { + const parsedError = errorSchema.safeParse(frame.error) + if (!parsedError.success) { + this.diagnose('Invalid ACP error response') + pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + } else { + const error = parsedError.data + pending.reject(new AcpRpcError(error.code, error.message, error.data)) + } } else { pending.resolve(frame.result) } @@ -254,3 +291,13 @@ function bounded(value: number | undefined, fallback: number): number { } return value } + +function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 120f896be2b6..4701359d11b1 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -4,7 +4,7 @@ import { RequestPermissionResponseSchema, type RequestPermissionRequest, type RequestPermissionResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' export type AcpPermissionHandler = ( request: RequestPermissionRequest, diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts new file mode 100644 index 000000000000..ec79badfbb5f --- /dev/null +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer } from './acp-json-rpc-peer' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import { AcpConnectionClosedError, AcpRequestTimeoutError } from './acp-errors' +import type { RequestPermissionResponse } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => { + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) + vi.useRealTimers() +}) + +describe('ACP caller-owned waits', () => { + it('keeps permission requests open beyond two minutes and delivers the late user decision', async () => { + vi.useFakeTimers() + const entered = deferred() + const decision = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + let answered = false + const response = agent.request('permission', 'session/request_permission', permission) + void response.then(() => { + answered = true + }) + const signal = await entered.promise + await vi.advanceTimersByTimeAsync(120_001) + expect(signal.aborted).toBe(false) + expect(answered).toBe(false) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + runtime.close() + await rejected + }) + + it('lets a streaming turn run beyond thirty minutes and keeps the session usable', async () => { + vi.useFakeTimers() + const onClose = vi.fn() + const event = vi.fn() + const { agent, runtime } = fixture({ onClose }) + runtime.subscribe(event) + agent.on('session/prompt', () => {}) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + await runtime.start(startOptions) + let finished = false + const pending = runtime.prompt([...prompt]) + void pending.then(() => { + finished = true + }) + await vi.advanceTimersByTimeAsync(30 * 60_000 + 1) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Still working' } + } + }) + expect(event).toHaveBeenCalledTimes(1) + expect(finished).toBe(false) + expect(onClose).not.toHaveBeenCalled() + expect(agent.frames.some((frame) => frame.method === 'session/cancel')).toBe(false) + await expect(runtime.setMode('plan')).resolves.toEqual({}) + const frame = agent.frames.find((frame) => frame.method === 'session/prompt') + expect(frame).toBeDefined() + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await expect(pending).resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it.each([ + { id: 'key', name: 'API key', type: 'env_var', vars: [{ name: 'X_API_KEY' }] }, + { id: 'browser', name: 'Browser login', type: 'agent' }, + { id: 'default', name: 'Default login' }, + { id: 'terminal', name: 'Terminal login', type: 'terminal' } + ])('surfaces $id authentication without choosing it for the caller', async (method) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [method] }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods: [method] + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + }) + + it.each(['prompt', 'cancel', 'setMode', 'setModel', 'setConfigOption'] as const)( + 'rejects %s before start through its promise', + async (method) => { + const { runtime } = fixture() + let pending: Promise | undefined + expect(() => { + switch (method) { + case 'prompt': + pending = runtime.prompt([...prompt]) + break + case 'cancel': + pending = runtime.cancel() + break + case 'setMode': + pending = runtime.setMode('plan') + break + case 'setModel': + pending = runtime.setModel('model') + break + case 'setConfigOption': + pending = runtime.setConfigOption('thinking', 'high') + break + } + }).not.toThrow() + await expect(pending).rejects.toThrow('ACP session has not started') + } + ) + + it('answers a handled void vendor request with null', async () => { + const handler = vi.fn() + const { agent } = fixture({ onRequest: handler }) + expect(await agent.request(7, '_vendor/ack', {})).toMatchObject({ id: 7, result: null }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('ignores duplicate incoming ids without answering the original request', async () => { + const answer = deferred() + const handler = vi.fn(() => answer.promise) + const diagnostics: string[] = [] + const { agent } = fixture({ + onRequest: handler, + onDiagnostic: (message) => diagnostics.push(message) + }) + const original = agent.request(5, '_vendor/question', {}) + agent.send({ jsonrpc: '2.0', id: 5, method: '_vendor/question', params: {} }) + await tick() + expect(agent.frames).toEqual([]) + expect(handler).toHaveBeenCalledTimes(1) + expect(diagnostics).toContain('Ignored duplicate ACP incoming request id') + answer.resolve({ answer: true }) + expect(await original).toMatchObject({ id: 5, result: { answer: true } }) + expect(agent.frames).toHaveLength(1) + }) + + it.each([{ code: 'E1', message: 'boom' }, { code: -1, message: null }, null])( + 'rejects a malformed error reply immediately with its raw error: %j', + async (error) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => { + agent.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: frame.id, error })}\n`) + }) + await expect(runtime.initialize()).rejects.toMatchObject({ code: -32603, data: error }) + } + ) + + it('maps authentication-required and retries only the explicitly chosen advertised method once', async () => { + const { agent, runtime } = fixture() + const authMethods = [{ id: 'login', name: 'Login' }] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + agent.on('authenticate', (frame) => agent.reply(frame, {})) + await expect(runtime.start({ ...startOptions, authMethodId: 'login' })).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods + }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('rejects overflowing peer timeouts and supports an explicit unlimited wait', async () => { + vi.useFakeTimers() + const agent = new AcpScriptedAgent() + opened.push(agent) + expect( + () => new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 3_000_000_000 }) + ).toThrow('timer durations') + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 20 }) + opened.push(peer) + await expect(peer.request('overflow', {}, { timeoutMs: 3_000_000_000 })).rejects.toThrow( + 'timer durations' + ) + const pending = peer.request('wait', {}, { timeoutMs: null }) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + await vi.advanceTimersByTimeAsync(3_000_000_000) + expect(peer.closed).toBe(false) + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait']) + peer.close() + await rejected + }) + + it('clamps cancellation confirmation waits to the safe timer range', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ cancelTimeoutMs: 3_000_000_000 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(1) + expect(agent.frames.at(-1)?.method).toBe('session/cancel') + await vi.advanceTimersByTimeAsync(2_147_483_646) + await Promise.all([rejected, cancelled]) + }) + + it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) + agent.on('initialize', () => {}) + const initialized = runtime.initialize() + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[0], { protocolVersion: 1 }) + await initialized + const authenticated = runtime.authenticate('caller-chosen') + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[1], {}) + await expect(authenticated).resolves.toEqual({}) + }) + + it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { + const entered = deferred() + const { agent, runtime } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return new Promise(() => {}) + } + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('question', '_vendor/question', {}) + const signal = await entered.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await response).toMatchObject({ error: { code: -32800 } }) + await pending + }) + + it('settles requests when the process owner closes on exit even if stdout stays open', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toThrow('Agent exited') + expect(agent.stdout.readableEnded).toBe(false) + runtime.close(new Error('Agent exited')) + await rejected + expect(agent.stdout.readableEnded).toBe(false) + }) +}) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts new file mode 100644 index 000000000000..181a7cb8dedc --- /dev/null +++ b/src/main/acp/acp-session-notifications.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +function fixture() { + const agent = new AcpScriptedAgent() + const diagnostic = vi.fn() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { onDiagnostic: diagnostic }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: { vendor: true } }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1', vendor: true })) + return { agent, runtime, diagnostic } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP session update compatibility', () => { + it.each([ + ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + [ + 'unrecognized', + { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + ], + ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], + ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], + [ + 'unrecognized', + { sessionUpdate: 'agent_message_chunk', content: { type: 'reasoning_summary', text: 'x' } } + ] + ] as const)( + 'delivers %s updates without losing their original fields (%j)', + async (kind, update) => { + const { agent, runtime, diagnostic } = fixture() + const events: AcpSessionEvent[] = [] + runtime.subscribe((event) => events.push(event)) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + const notification = { sessionId: 'session-1', update, vendor: 'retained' } + agent.notify('session/update', notification) + agent.notify('session/update', notification) + expect(events).toHaveLength(2) + expect(events[0]).toEqual( + kind === 'known' + ? { kind, notification } + : { kind, sessionId: 'session-1', raw: notification } + ) + expect(diagnostic).toHaveBeenCalledTimes(kind === 'known' ? 0 : 1) + } + ) + + it('rejects only envelopes missing the session id or update discriminator', async () => { + const { agent, runtime } = fixture() + const received = vi.fn() + runtime.subscribe(received) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + agent.notify('session/update', { update: { sessionUpdate: 'vendor' } }) + agent.notify('session/update', { sessionId: 'session-1', update: {} }) + expect(received).not.toHaveBeenCalled() + }) + + it('preserves extra session fields and exposes legacy model state as typed data', async () => { + const { agent, runtime } = fixture() + const models = { + currentModelId: 'model-1', + availableModels: [{ modelId: 'model-1', name: 'Model one' }] + } + agent.on('session/new', (frame) => + agent.reply(frame, { sessionId: 'session-1', models, vendor: true }) + ) + const started = await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + expect(started.response.models?.availableModels[0].modelId).toBe('model-1') + expect(started.response.vendor).toBe(true) + }) +}) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index bdc1e0cd8256..068da3f4742f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -2,16 +2,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpAuthRequiredError, AcpConnectionClosedError, - AcpRequestTimeoutError + AcpRequestTimeoutError, + AcpRpcError } from './acp-errors' -import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionEvent +} from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' import type { AgentCapabilities, - RequestPermissionResponse, - SessionNotification -} from './generated/protocol.gen' -import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + RequestPermissionResponse +} from './generated/acp-protocol.generated' +import { SetSessionConfigOptionRequestSchema } from './generated/acp-protocol.generated' const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] const startOptions = { cwd: '/runtime/project', mcpServers: [] } @@ -45,7 +49,7 @@ afterEach(() => { describe('ACP session runtime', () => { it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { const { runtime, agent } = fixture() - const events: SessionNotification[] = [] + const events: AcpSessionEvent[] = [] const unsubscribe = runtime.subscribe((event) => events.push(event)) const updates = [ { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, @@ -72,7 +76,9 @@ describe('ACP session runtime', () => { await Promise.all([runtime.initialize(), runtime.initialize()]) expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) - expect(events.map((event) => event.update)).toEqual(updates) + expect( + events.map((event) => (event.kind === 'known' ? event.notification.update : event.raw.update)) + ).toEqual(updates) expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) expect(agent.frames[0].params).toEqual({ protocolVersion: 1, @@ -127,7 +133,11 @@ describe('ACP session runtime', () => { return { outcome: { outcome: 'selected', optionId: 'allow' } } }, onRequest: (method, params) => - method === '_vendor/question' ? { answer: params } : undefined + method === '_vendor/question' + ? { answer: params } + : (() => { + throw new AcpRpcError(-32601, 'Unknown method') + })() } ) agent.on('session/prompt', (frame) => { @@ -233,30 +243,16 @@ describe('ACP session runtime', () => { expect(agent.frames[2].params).toEqual({ methodId: 'login' }) }) - it('chooses an advertised agent authentication method when login is required', async () => { + it('leaves advertised authentication methods for the caller to choose', async () => { const { runtime, agent } = fixture() - let authenticated = false - agent.on('initialize', (frame) => - agent.reply(frame, { - protocolVersion: 1, - authMethods: [ - { id: 'terminal', name: 'Interactive login', type: 'terminal' }, - { id: 'agent', name: 'Agent login' } - ] - }) - ) - agent.on('session/new', (frame) => - authenticated - ? agent.reply(frame, { sessionId: 'session-1' }) - : agent.fail(frame, -32000, 'Login required') - ) - agent.on('authenticate', (frame) => { - expect(frame.params).toEqual({ methodId: 'agent' }) - authenticated = true - agent.reply(frame, {}) - }) - await runtime.start(startOptions) - expect(authenticated).toBe(true) + const authMethods = [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ authMethods }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) }) it('does not retry authentication indefinitely or start an interactive login', async () => { @@ -281,7 +277,9 @@ describe('ACP session runtime', () => { retry.agent.on('session/new', (frame) => retry.agent.fail(frame, -32000, 'Still requires login') ) - await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect( + retry.runtime.start({ ...startOptions, authMethodId: 'agent' }) + ).rejects.toBeInstanceOf(AcpAuthRequiredError) expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ 'initialize', 'session/new', @@ -348,10 +346,10 @@ describe('ACP session runtime', () => { await rejected }) - it('ignores invalid updates and isolates event listener failures', async () => { + it('preserves unrecognized updates and isolates event listener failures', async () => { const diagnostics: string[] = [] const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) - const updates: SessionNotification[] = [] + const updates: AcpSessionEvent[] = [] runtime.subscribe(() => { throw new Error('Consumer failed') }) @@ -368,9 +366,13 @@ describe('ACP session runtime', () => { content: { type: 'text', text: 'valid', _meta: { vendor: true } } } }) - expect(updates).toHaveLength(1) - expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) - expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(updates).toHaveLength(2) + expect(updates[0]).toMatchObject({ kind: 'unrecognized' }) + expect(updates[1]).toMatchObject({ + kind: 'known', + notification: { update: { content: { _meta: { vendor: true } } } } + }) + expect(diagnostics).toContain('Forwarded unrecognized ACP session update') expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 095573150ce6..bfd0e02226d7 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,6 +1,7 @@ import type { Readable, Writable } from 'node:stream' -import type { z } from 'zod' -import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { z } from 'zod' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' import { @@ -9,7 +10,7 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { InitializeResponseSchema, AuthenticateResponseSchema, @@ -29,12 +30,19 @@ import { type SetSessionConfigOptionResponse, type SetSessionModeResponse, type SetSessionModelResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions - promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown @@ -50,24 +58,21 @@ type ActivePrompt = { export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer private readonly permissions = new AcpPermissionRequests() - private readonly listeners = new Set<(event: SessionNotification) => void>() + private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise private started?: AcpSessionStarted private activePrompt?: ActivePrompt + private reportedUpdateAnomaly = false constructor( input: Readable, output: Writable, private readonly options: AcpSessionRuntimeOptions = {} ) { - for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { - if ( - timeout !== undefined && - (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) - ) { - throw new Error('ACP timeouts must be positive finite timer durations') - } + const timeout = options.cancelTimeoutMs + if (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout <= 0)) { + throw new Error('ACP timeouts must be positive finite timer durations') } this.peer = new AcpJsonRpcPeer( input, @@ -82,7 +87,7 @@ export class AcpSessionRuntime { ) } - subscribe(listener: (event: SessionNotification) => void): () => void { + subscribe(listener: (event: AcpSessionEvent) => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) @@ -143,16 +148,14 @@ export class AcpSessionRuntime { return this.starting } - prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt']): Promise { if (this.activePrompt) { - return Promise.reject(new Error('ACP prompt already in progress')) + throw new Error('ACP prompt already in progress') } const sessionId = this.sessionId() const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { - timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 - }) + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -165,19 +168,23 @@ export class AcpSessionRuntime { } cancel(): Promise { - const sessionId = this.sessionId() + if (!this.started) { + return Promise.reject(new Error('ACP session has not started')) + } + const sessionId = this.started.sessionId const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) let timer: ReturnType | undefined try { await Promise.race([ @@ -195,21 +202,21 @@ export class AcpSessionRuntime { } } - setMode(modeId: string): Promise { + async setMode(modeId: string): Promise { return this.call( 'session/set_mode', { sessionId: this.sessionId(), modeId }, SetSessionModeResponseSchema ) } - setModel(modelId: string): Promise { + async setModel(modelId: string): Promise { return this.call( 'session/set_model', { sessionId: this.sessionId(), modelId }, SetSessionModelResponseSchema ) } - setConfigOption( + async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { @@ -221,6 +228,7 @@ export class AcpSessionRuntime { return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } + // The process owner must call close on child exit, even if descendants keep stdio open. close(error?: Error): void { this.peer.close(error) this.listeners.clear() @@ -233,16 +241,10 @@ export class AcpSessionRuntime { return this.started.sessionId } - private async call( - method: string, - params: unknown, - schema: z.ZodType, - options?: { timeoutMs?: number } - ): Promise { - const result = await this.peer.request(method, params, options).catch((error) => { - // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. - if (error instanceof AcpRequestTimeoutError) { - this.peer.close(error) + private async call(method: string, params: unknown, schema: z.ZodType): Promise { + const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { + if (error instanceof AcpRpcError && error.code === -32000) { + throw new AcpAuthRequiredError(error.message, error.data) } throw error }) @@ -255,7 +257,10 @@ export class AcpSessionRuntime { private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { - return this.options.onRequest?.(method, params, context) + if (!this.options.onRequest) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.options.onRequest(method, params, context) } const parsed = RequestPermissionRequestSchema.safeParse(params) if (!parsed.success) { @@ -275,14 +280,22 @@ export class AcpSessionRuntime { if (method !== 'session/update') { return } - const parsed = SessionNotificationSchema.safeParse(params) - if (!parsed.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update') + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') return } + const parsed = SessionNotificationSchema.safeParse(params) + const event: AcpSessionEvent = parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } + if (!parsed.success && !this.reportedUpdateAnomaly) { + this.reportedUpdateAnomaly = true + this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + } for (const listener of this.listeners) { try { - listener(parsed.data) + listener(event) } catch (error) { this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) } diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts index 6c81f0fc78e5..8296315eb269 100644 --- a/src/main/acp/acp-session-setup.ts +++ b/src/main/acp/acp-session-setup.ts @@ -5,17 +5,25 @@ import { LoadSessionResponseSchema, ResumeSessionResponseSchema, AuthenticateResponseSchema, + SessionModelStateSchema, type InitializeResponse, - type NewSessionRequest, - type NewSessionResponse, - type LoadSessionResponse, - type ResumeSessionResponse -} from './generated/protocol.gen' + type NewSessionRequest +} from './generated/acp-protocol.generated' + +const newSessionSchema = NewSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const loadSessionSchema = LoadSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const resumeSessionSchema = ResumeSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) export type AcpSessionStarted = - | { kind: 'new'; sessionId: string; response: NewSessionResponse } - | { kind: 'load'; sessionId: string; response: LoadSessionResponse } - | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } + | { kind: 'new'; sessionId: string; response: z.infer } + | { kind: 'load'; sessionId: string; response: z.infer } + | { kind: 'resume'; sessionId: string; response: z.infer } export type AcpSessionStartOptions = NewSessionRequest & { sessionId?: string resumePreference?: 'load' | 'resume' @@ -31,7 +39,7 @@ export async function setupAcpSession( const setup = async (): Promise => { const { sessionId, resumePreference, authMethodId: _auth, ...params } = options if (sessionId === undefined) { - const response = await request('session/new', params, NewSessionResponseSchema) + const response = await request('session/new', params, newSessionSchema) return { kind: 'new', sessionId: response.sessionId, response } } const capabilities = initialized.agentCapabilities @@ -41,18 +49,14 @@ export async function setupAcpSession( return { kind: 'load', sessionId, - response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + response: await request('session/load', { ...params, sessionId }, loadSessionSchema) } } if (resume) { return { kind: 'resume', sessionId, - response: await request( - 'session/resume', - { ...params, sessionId }, - ResumeSessionResponseSchema - ) + response: await request('session/resume', { ...params, sessionId }, resumeSessionSchema) } } throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') @@ -63,13 +67,19 @@ export async function setupAcpSession( if (!(error instanceof AcpAuthRequiredError)) { throw error } - const method = initialized.authMethods?.find((method) => - options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' - ) - if (!method || method.type === 'terminal') { - throw error + const required = new AcpAuthRequiredError(error.message, error.data, initialized.authMethods) + const method = initialized.authMethods?.find((method) => method.id === options.authMethodId) + if (!method) { + throw required + } + try { + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return await setup() + } catch (failure) { + if (failure instanceof AcpAuthRequiredError) { + throw new AcpAuthRequiredError(failure.message, failure.data, initialized.authMethods) + } + throw failure } - await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) - return setup() } } diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts new file mode 100644 index 000000000000..4438895c9e31 --- /dev/null +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -0,0 +1,1390 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed 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 { z } from 'zod' +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer + +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer + +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer + +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer + +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer + +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer + +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer + +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer + +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer + +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer + +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const ModelInfoSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + modelId: ModelIdSchema, + name: z.string() +}) +export type ModelInfo = z.infer + +export const SessionModelStateSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + availableModels: z.array(ModelInfoSchema), + currentModelId: ModelIdSchema +}) +export type SessionModelState = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer + +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts deleted file mode 100644 index 723ef2fddb15..000000000000 --- a/src/main/acp/generated/client-capabilities.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - FileSystemCapabilitiesSchema, - ClientSessionCapabilitiesSchema, - PlanCapabilitiesSchema, - AuthCapabilitiesSchema, - ElicitationCapabilitiesSchema, - ClientNesCapabilitiesSchema, - PositionEncodingKindSchema, - ProtocolVersionSchema -} from './protocol-version.gen' -export const ClientCapabilitiesSchema = z.looseObject({ - fs: FileSystemCapabilitiesSchema.optional(), - terminal: z.boolean().optional(), - session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), - plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), - auth: AuthCapabilitiesSchema.optional(), - elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), - nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), - positionEncodings: z.array(PositionEncodingKindSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientCapabilities = z.infer - -export const ImplementationSchema = z.looseObject({ - name: z.string(), - title: z.union([z.string(), z.null()]).optional(), - version: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Implementation = z.infer - -export const InitializeRequestSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - clientCapabilities: ClientCapabilitiesSchema.optional(), - clientInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeRequest = z.infer - -export const PromptCapabilitiesSchema = z.looseObject({ - image: z.boolean().optional(), - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptCapabilities = z.infer - -export const McpCapabilitiesSchema = z.looseObject({ - http: z.boolean().optional(), - sse: z.boolean().optional(), - acp: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpCapabilities = z.infer - -export const SessionListCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionListCapabilities = z.infer - -export const SessionDeleteCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionDeleteCapabilities = z.infer - -export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionAdditionalDirectoriesCapabilities = z.infer< - typeof SessionAdditionalDirectoriesCapabilitiesSchema -> - -export const SessionForkCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionForkCapabilities = z.infer - -export const SessionResumeCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionResumeCapabilities = z.infer - -export const SessionCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCloseCapabilities = z.infer - -export const SessionCapabilitiesSchema = z.looseObject({ - list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), - delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), - additionalDirectories: z - .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) - .optional(), - fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), - resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), - close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCapabilities = z.infer - -export const LogoutCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LogoutCapabilities = z.infer - -export const AgentAuthCapabilitiesSchema = z.looseObject({ - logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts deleted file mode 100644 index 21ec2dbe11ba..000000000000 --- a/src/main/acp/generated/diff.gen.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - ContentSchema, - ToolCallIdSchema, - ToolKindSchema, - ToolCallStatusSchema -} from './text-resource-contents.gen' -export const DiffSchema = z.looseObject({ - path: z.string(), - oldText: z.union([z.string(), z.null()]).optional(), - newText: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Diff = z.infer - -export const TerminalIdSchema = z.string() -export type TerminalId = z.infer - -export const TerminalSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Terminal = z.infer - -export const ToolCallContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), - z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), - z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) -]) -export type ToolCallContent = z.infer - -export const ToolCallLocationSchema = z.looseObject({ - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallLocation = z.infer - -export const ToolCallSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - title: z.string(), - name: z.union([z.string(), z.null()]).optional(), - kind: ToolKindSchema.optional(), - status: ToolCallStatusSchema.optional(), - content: z.array(ToolCallContentSchema).optional(), - locations: z.array(ToolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCall = z.infer - -export const ToolCallUpdateSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - kind: z.union([ToolKindSchema, z.null()]).optional(), - status: z.union([ToolCallStatusSchema, z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - name: z.union([z.string(), z.null()]).optional(), - content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), - locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallUpdate = z.infer - -export const PlanEntryPrioritySchema = z.union([ - z.literal('high'), - z.literal('medium'), - z.literal('low') -]) -export type PlanEntryPriority = z.infer - -export const PlanEntryStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed') -]) -export type PlanEntryStatus = z.infer - -export const PlanEntrySchema = z.looseObject({ - content: z.string(), - priority: PlanEntryPrioritySchema, - status: PlanEntryStatusSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanEntry = z.infer - -export const PlanSchema = z.looseObject({ - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Plan = z.infer - -export const PlanIdSchema = z.string() -export type PlanId = z.infer - -export const PlanItemsSchema = z.looseObject({ - planId: PlanIdSchema, - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanItems = z.infer - -export const PlanFileSchema = z.looseObject({ - planId: PlanIdSchema, - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanFile = z.infer - -export const PlanMarkdownSchema = z.looseObject({ - planId: PlanIdSchema, - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanMarkdown = z.infer - -export const PlanUpdateContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), - z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), - z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) -]) -export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts deleted file mode 100644 index 51e227cb3ce1..000000000000 --- a/src/main/acp/generated/license.gen.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -/* -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2025 Zed Industries, Inc. and contributors - - Licensed 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. -*/ -export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' -export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' -export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts deleted file mode 100644 index 105054e2a883..000000000000 --- a/src/main/acp/generated/mcp-server-acp.gen.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - McpServerAcpIdSchema, - McpServerHttpSchema, - McpServerSseSchema -} from './nes-context-capabilities.gen' -export const McpServerAcpSchema = z.looseObject({ - name: z.string(), - serverId: McpServerAcpIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerAcp = z.infer - -export const EnvVariableSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EnvVariable = z.infer - -export const McpServerStdioSchema = z.looseObject({ - name: z.string(), - command: z.string(), - args: z.array(z.string()), - env: z.array(EnvVariableSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerStdio = z.infer - -export const McpServerSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), - z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), - z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), - McpServerStdioSchema -]) -export type McpServer = z.infer - -export const NewSessionRequestSchema = z.looseObject({ - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionRequest = z.infer - -export const SessionIdSchema = z.string() -export type SessionId = z.infer - -export const SessionModeIdSchema = z.string() -export type SessionModeId = z.infer - -export const SessionModeSchema = z.looseObject({ - id: SessionModeIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionMode = z.infer - -export const SessionModeStateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - availableModes: z.array(SessionModeSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionModeState = z.infer - -export const SessionConfigIdSchema = z.string() -export type SessionConfigId = z.infer - -export const SessionConfigOptionCategorySchema = z.union([ - z.literal('mode'), - z.literal('model'), - z.literal('model_config'), - z.literal('thought_level'), - z.string() -]) -export type SessionConfigOptionCategory = z.infer - -export const SessionConfigValueIdSchema = z.string() -export type SessionConfigValueId = z.infer - -export const SessionConfigSelectOptionSchema = z.looseObject({ - value: SessionConfigValueIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectOption = z.infer - -export const SessionConfigGroupIdSchema = z.string() -export type SessionConfigGroupId = z.infer - -export const SessionConfigSelectGroupSchema = z.looseObject({ - group: SessionConfigGroupIdSchema, - name: z.string(), - options: z.array(SessionConfigSelectOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectGroup = z.infer - -export const SessionConfigSelectOptionsSchema = z.union([ - z.array(SessionConfigSelectOptionSchema), - z.array(SessionConfigSelectGroupSchema) -]) -export type SessionConfigSelectOptions = z.infer - -export const SessionConfigSelectSchema = z.looseObject({ - currentValue: SessionConfigValueIdSchema, - options: SessionConfigSelectOptionsSchema -}) -export type SessionConfigSelect = z.infer - -export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) -export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts deleted file mode 100644 index 3f193cd78045..000000000000 --- a/src/main/acp/generated/nes-context-capabilities.gen.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - NesRecentFilesCapabilitiesSchema, - NesRelatedSnippetsCapabilitiesSchema, - NesEditHistoryCapabilitiesSchema, - NesUserActionsCapabilitiesSchema, - NesOpenFilesCapabilitiesSchema, - NesDiagnosticsCapabilitiesSchema, - NesEventCapabilitiesSchema, - ProvidersCapabilitiesSchema -} from './providers-capabilities.gen' -import { - PromptCapabilitiesSchema, - McpCapabilitiesSchema, - SessionCapabilitiesSchema, - AgentAuthCapabilitiesSchema, - ImplementationSchema -} from './client-capabilities.gen' -import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' -export const NesContextCapabilitiesSchema = z.looseObject({ - recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), - relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), - editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), - userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), - openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), - diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesContextCapabilities = z.infer - -export const NesCapabilitiesSchema = z.looseObject({ - events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), - context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesCapabilities = z.infer - -export const AgentCapabilitiesSchema = z.looseObject({ - loadSession: z.boolean().optional(), - promptCapabilities: PromptCapabilitiesSchema.optional(), - mcpCapabilities: McpCapabilitiesSchema.optional(), - sessionCapabilities: SessionCapabilitiesSchema.optional(), - auth: AgentAuthCapabilitiesSchema.optional(), - providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), - nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), - positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentCapabilities = z.infer - -export const AuthMethodIdSchema = z.string() -export type AuthMethodId = z.infer - -export const AuthMethodTerminalSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - args: z.array(z.string()).optional(), - env: z.looseObject({}).catchall(z.string()).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodTerminal = z.infer - -export const AuthMethodAgentSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodAgent = z.infer - -export const AuthMethodSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), - AuthMethodAgentSchema -]) -export type AuthMethod = z.infer - -export const InitializeResponseSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - agentCapabilities: AgentCapabilitiesSchema.optional(), - authMethods: z.array(AuthMethodSchema).optional(), - agentInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeResponse = z.infer - -export const AuthenticateRequestSchema = z.looseObject({ - methodId: AuthMethodIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateRequest = z.infer - -export const AuthenticateResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateResponse = z.infer - -export const HttpHeaderSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type HttpHeader = z.infer - -export const McpServerHttpSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerHttp = z.infer - -export const McpServerSseSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerSse = z.infer - -export const McpServerAcpIdSchema = z.string() -export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts deleted file mode 100644 index eda1e7474274..000000000000 --- a/src/main/acp/generated/plan-update.gen.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' -import { SessionModeIdSchema } from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { ContentBlockSchema } from './text-resource-contents.gen' -export const PlanUpdateSchema = z.looseObject({ - plan: PlanUpdateContentSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanUpdate = z.infer - -export const PlanRemovedSchema = z.looseObject({ - planId: PlanIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanRemoved = z.infer - -export const UnstructuredCommandInputSchema = z.looseObject({ - hint: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UnstructuredCommandInput = z.infer - -export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) -export type AvailableCommandInput = z.infer - -export const AvailableCommandSchema = z.looseObject({ - name: z.string(), - description: z.string(), - input: z.union([AvailableCommandInputSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommand = z.infer - -export const AvailableCommandsUpdateSchema = z.looseObject({ - availableCommands: z.array(AvailableCommandSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommandsUpdate = z.infer - -export const CurrentModeUpdateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CurrentModeUpdate = z.infer - -export const ConfigOptionUpdateSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ConfigOptionUpdate = z.infer - -export const SessionInfoUpdateSchema = z.looseObject({ - title: z.union([z.string(), z.null()]).optional(), - updatedAt: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionInfoUpdate = z.infer - -export const CostSchema = z.looseObject({ - amount: z.number(), - currency: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Cost = z.infer - -export const UsageUpdateSchema = z.looseObject({ - used: z.number().int().min(0), - size: z.number().int().min(0), - cost: z.union([CostSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UsageUpdate = z.infer - -export const CompactionIdSchema = z.string() -export type CompactionId = z.infer - -export const CompactionStatusSchema = z.union([ - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), - z.literal('cancelled'), - z.string() -]) -export type CompactionStatus = z.infer - -export const CompactionUpdateSchema = z.looseObject({ - compactionId: CompactionIdSchema, - status: CompactionStatusSchema, - summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), - error: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionUpdate = z.infer - -export const CompactionSummaryChunkSchema = z.looseObject({ - compactionId: CompactionIdSchema, - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts deleted file mode 100644 index 865debf5666e..000000000000 --- a/src/main/acp/generated/protocol-version.gen.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProtocolVersionSchema = z.number().int().min(0).max(65535) -export type ProtocolVersion = z.infer - -export const FileSystemCapabilitiesSchema = z.looseObject({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type FileSystemCapabilities = z.infer - -export const CompactionCapabilitiesSchema = z.looseObject({}) -export type CompactionCapabilities = z.infer - -export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BooleanConfigOptionCapabilities = z.infer - -export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ - boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigOptionsCapabilities = z.infer< - typeof SessionConfigOptionsCapabilitiesSchema -> - -export const ClientSessionCapabilitiesSchema = z.looseObject({ - compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), - configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientSessionCapabilities = z.infer - -export const PlanCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanCapabilities = z.infer - -export const AuthCapabilitiesSchema = z.looseObject({ - terminal: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthCapabilities = z.infer - -export const ElicitationFormCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationFormCapabilities = z.infer - -export const ElicitationUrlCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationUrlCapabilities = z.infer - -export const ElicitationCapabilitiesSchema = z.looseObject({ - form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), - url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationCapabilities = z.infer - -export const NesJumpCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesJumpCapabilities = z.infer - -export const NesRenameCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRenameCapabilities = z.infer - -export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesSearchAndReplaceCapabilities = z.infer - -export const ClientNesCapabilitiesSchema = z.looseObject({ - jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), - rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), - searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientNesCapabilities = z.infer - -export const PositionEncodingKindSchema = z.union([ - z.literal('utf-16'), - z.literal('utf-32'), - z.literal('utf-8') -]) -export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts deleted file mode 100644 index 1c1cd14183c8..000000000000 --- a/src/main/acp/generated/protocol.gen.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -export * from './protocol-version.gen' -export * from './client-capabilities.gen' -export * from './providers-capabilities.gen' -export * from './nes-context-capabilities.gen' -export * from './mcp-server-acp.gen' -export * from './session-config-option.gen' -export * from './text-resource-contents.gen' -export * from './diff.gen' -export * from './plan-update.gen' -export * from './session-update.gen' -export * from './set-session-model-request.gen' -export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts deleted file mode 100644 index c9864207ed26..000000000000 --- a/src/main/acp/generated/providers-capabilities.gen.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProvidersCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ProvidersCapabilities = z.infer - -export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidOpenCapabilities = z.infer - -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) -export type TextDocumentSyncKind = z.infer - -export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ - syncKind: TextDocumentSyncKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidChangeCapabilities = z.infer< - typeof NesDocumentDidChangeCapabilitiesSchema -> - -export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidCloseCapabilities = z.infer - -export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidSaveCapabilities = z.infer - -export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidFocusCapabilities = z.infer - -export const NesDocumentEventCapabilitiesSchema = z.looseObject({ - didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), - didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), - didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), - didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), - didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentEventCapabilities = z.infer - -export const NesEventCapabilitiesSchema = z.looseObject({ - document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEventCapabilities = z.infer - -export const NesRecentFilesCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRecentFilesCapabilities = z.infer - -export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRelatedSnippetsCapabilities = z.infer - -export const NesEditHistoryCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEditHistoryCapabilities = z.infer - -export const NesUserActionsCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesUserActionsCapabilities = z.infer - -export const NesOpenFilesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesOpenFilesCapabilities = z.infer - -export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts deleted file mode 100644 index 3d33a869a6fc..000000000000 --- a/src/main/acp/generated/session-config-option.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - SessionConfigIdSchema, - SessionConfigOptionCategorySchema, - SessionConfigSelectSchema, - SessionConfigBooleanSchema, - SessionIdSchema, - SessionModeStateSchema, - McpServerSchema -} from './mcp-server-acp.gen' -export const SessionConfigOptionSchema = z.intersection( - z.looseObject({ - id: SessionConfigIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), - z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) - ]) -) -export type SessionConfigOption = z.infer - -export const NewSessionResponseSchema = z.looseObject({ - sessionId: SessionIdSchema, - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionResponse = z.infer - -export const LoadSessionRequestSchema = z.looseObject({ - mcpServers: z.array(McpServerSchema), - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionRequest = z.infer - -export const LoadSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionResponse = z.infer - -export const ResumeSessionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionRequest = z.infer - -export const ResumeSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionResponse = z.infer - -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) -export type Role = z.infer - -export const AnnotationsSchema = z.looseObject({ - audience: z.union([z.array(RoleSchema), z.null()]).optional(), - lastModified: z.union([z.string(), z.null()]).optional(), - priority: z.union([z.number(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Annotations = z.infer - -export const TextContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - text: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextContent = z.infer - -export const ImageContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - uri: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ImageContent = z.infer - -export const AudioContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AudioContent = z.infer - -export const ResourceLinkSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - description: z.union([z.string(), z.null()]).optional(), - mimeType: z.union([z.string(), z.null()]).optional(), - name: z.string(), - size: z.union([z.number().int(), z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts deleted file mode 100644 index 7b67951065d7..000000000000 --- a/src/main/acp/generated/session-update.gen.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ContentChunkSchema } from './text-resource-contents.gen' -import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' -import { - PlanUpdateSchema, - PlanRemovedSchema, - AvailableCommandsUpdateSchema, - CurrentModeUpdateSchema, - ConfigOptionUpdateSchema, - SessionInfoUpdateSchema, - UsageUpdateSchema, - CompactionUpdateSchema, - CompactionSummaryChunkSchema -} from './plan-update.gen' -import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' -export const SessionUpdateSchema = z.union([ - z.intersection( - z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), - ContentChunkSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), - ToolCallUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), - AvailableCommandsUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), - CurrentModeUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('config_option_update') }), - ConfigOptionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('session_info_update') }), - SessionInfoUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_update') }), - CompactionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), - CompactionSummaryChunkSchema - ) -]) -export type SessionUpdate = z.infer - -export const SessionNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - update: SessionUpdateSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionNotification = z.infer - -export const PermissionOptionIdSchema = z.string() -export type PermissionOptionId = z.infer - -export const PermissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always') -]) -export type PermissionOptionKind = z.infer - -export const PermissionOptionSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - name: z.string(), - kind: PermissionOptionKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PermissionOption = z.infer - -export const RequestPermissionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - toolCall: ToolCallUpdateSchema, - options: z.array(PermissionOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionRequest = z.infer - -export const SelectedPermissionOutcomeSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SelectedPermissionOutcome = z.infer - -export const RequestPermissionOutcomeSchema = z.union([ - z.looseObject({ outcome: z.literal('cancelled') }), - z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) -]) -export type RequestPermissionOutcome = z.infer - -export const RequestPermissionResponseSchema = z.looseObject({ - outcome: RequestPermissionOutcomeSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionResponse = z.infer - -export const SetSessionModeRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - modeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeRequest = z.infer - -export const SetSessionModeResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeResponse = z.infer - -export const ModelIdSchema = z.string() -export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts deleted file mode 100644 index c25c1f98808e..000000000000 --- a/src/main/acp/generated/set-session-model-request.gen.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ModelIdSchema } from './session-update.gen' -import { - SessionIdSchema, - SessionConfigIdSchema, - SessionConfigValueIdSchema, - EnvVariableSchema -} from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { TerminalIdSchema } from './diff.gen' -export const SetSessionModelRequestSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional(), - modelId: ModelIdSchema, - sessionId: SessionIdSchema -}) -export type SetSessionModelRequest = z.infer - -export const SetSessionModelResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModelResponse = z.infer - -export const SetSessionConfigOptionRequestSchema = z.intersection( - z.looseObject({ - sessionId: SessionIdSchema, - configId: SessionConfigIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), - z.looseObject({ value: SessionConfigValueIdSchema }) - ]) -) -export type SetSessionConfigOptionRequest = z.infer - -export const SetSessionConfigOptionResponseSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionConfigOptionResponse = z.infer - -export const ReadTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - limit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileRequest = z.infer - -export const ReadTextFileResponseSchema = z.looseObject({ - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileResponse = z.infer - -export const WriteTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileRequest = z.infer - -export const WriteTextFileResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileResponse = z.infer - -export const CreateTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - command: z.string(), - args: z.array(z.string()).optional(), - env: z.array(EnvVariableSchema).optional(), - cwd: z.union([z.string(), z.null()]).optional(), - outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalRequest = z.infer - -export const CreateTerminalResponseSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalResponse = z.infer - -export const TerminalOutputRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputRequest = z.infer - -export const TerminalExitStatusSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalExitStatus = z.infer - -export const TerminalOutputResponseSchema = z.looseObject({ - output: z.string(), - truncated: z.boolean(), - exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputResponse = z.infer - -export const ReleaseTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalRequest = z.infer - -export const ReleaseTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts deleted file mode 100644 index c69e01fb6fb1..000000000000 --- a/src/main/acp/generated/text-resource-contents.gen.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - AnnotationsSchema, - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - ResourceLinkSchema -} from './session-config-option.gen' -import { SessionIdSchema } from './mcp-server-acp.gen' -export const TextResourceContentsSchema = z.looseObject({ - mimeType: z.union([z.string(), z.null()]).optional(), - text: z.string(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextResourceContents = z.infer - -export const BlobResourceContentsSchema = z.looseObject({ - blob: z.string(), - mimeType: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BlobResourceContents = z.infer - -export const EmbeddedResourceResourceSchema = z.union([ - TextResourceContentsSchema, - BlobResourceContentsSchema -]) -export type EmbeddedResourceResource = z.infer - -export const EmbeddedResourceSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - resource: EmbeddedResourceResourceSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EmbeddedResource = z.infer - -export const ContentBlockSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), - z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), - z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), - z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), - z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) -]) -export type ContentBlock = z.infer - -export const PromptRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - prompt: z.array(ContentBlockSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptRequest = z.infer - -export const StopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('max_turn_requests'), - z.literal('refusal'), - z.literal('cancelled') -]) -export type StopReason = z.infer - -export const UsageSchema = z.looseObject({ - totalTokens: z.number().int().min(0), - inputTokens: z.number().int().min(0), - outputTokens: z.number().int().min(0), - thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Usage = z.infer - -export const PromptResponseSchema = z.looseObject({ - stopReason: StopReasonSchema, - usage: z.union([UsageSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptResponse = z.infer - -export const CancelNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CancelNotification = z.infer - -export const MessageIdSchema = z.string() -export type MessageId = z.infer - -export const ContentChunkSchema = z.looseObject({ - content: ContentBlockSchema, - messageId: z.union([MessageIdSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ContentChunk = z.infer - -export const ToolCallIdSchema = z.string() -export type ToolCallId = z.infer - -export const ToolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('switch_mode'), - z.literal('other') -]) -export type ToolKind = z.infer - -export const ToolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed') -]) -export type ToolCallStatus = z.infer - -export const ContentSchema = z.looseObject({ - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts deleted file mode 100644 index 1f5fc01db7cc..000000000000 --- a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { SessionIdSchema } from './mcp-server-acp.gen' -import { TerminalIdSchema } from './diff.gen' -export const WaitForTerminalExitRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitRequest = z.infer - -export const WaitForTerminalExitResponseSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitResponse = z.infer - -export const KillTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalRequest = z.infer - -export const KillTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalResponse = z.infer From e8d37ec371a5d95e43fcc1c13f75d044339bc3c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:16:11 -0700 Subject: [PATCH 07/15] Preserve open ACP decisions through prompt completion --- src/main/acp/acp-session-lifecycle.test.ts | 44 +++++++++++++++++++ .../acp/acp-session-notifications.test.ts | 21 +++++++++ src/main/acp/acp-session-runtime.ts | 29 ++++++++---- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec79badfbb5f..43f2815697da 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -278,4 +278,48 @@ describe('ACP caller-owned waits', () => { await rejected expect(agent.stdout.readableEnded).toBe(false) }) + it.each(['answer', 'cancel'] as const)( + 'keeps a permission pending after prompt completion until caller %s', + async (action) => { + const decision = deferred() + const entered = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('permission', 'session/request_permission', permission) + const signal = await entered.promise + const frame = agent.frames.find((candidate) => candidate.method === 'session/prompt') + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await pending + expect(signal.aborted).toBe(false) + if (action === 'answer') { + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + } else { + await runtime.cancel() + expect(await response).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + } + } + ) + + it('confirms cancellation when the agent settles the prompt with an error', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => { + agent.on('session/cancel', () => agent.fail(frame, -32800, 'Request cancelled')) + }) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toMatchObject({ code: -32800 }) + await expect(runtime.cancel()).resolves.toBeUndefined() + await rejected + }) }) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index 181a7cb8dedc..b13796d2ea06 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -77,4 +77,25 @@ describe('ACP session update compatibility', () => { expect(started.response.models?.availableModels[0].modelId).toBe('model-1') expect(started.response.vendor).toBe(true) }) + it('delivers updates even if diagnostic and earlier event callbacks throw', async () => { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: () => { + throw new Error('Diagnostic failed') + } + }) + opened.push(agent, runtime) + runtime.subscribe(() => { + throw new Error('Listener failed') + }) + const received = vi.fn() + runtime.subscribe(received) + const notification = { sessionId: 'session-1', update: { sessionUpdate: 'vendor' } } + agent.notify('session/update', notification) + expect(received).toHaveBeenCalledWith({ + kind: 'unrecognized', + sessionId: 'session-1', + raw: notification + }) + }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index bfd0e02226d7..77e317362d13 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -10,8 +10,8 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { + ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, @@ -159,7 +159,6 @@ export class AcpSessionRuntime { } this.activePrompt = active active.response = active.response.finally(() => { - this.permissions.cancel(sessionId) if (this.activePrompt === active) { this.activePrompt = undefined } @@ -172,13 +171,13 @@ export class AcpSessionRuntime { return Promise.reject(new Error('ACP session has not started')) } const sessionId = this.started.sessionId + this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } @@ -188,7 +187,13 @@ export class AcpSessionRuntime { let timer: ReturnType | undefined try { await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + this.peer.notify('session/cancel', { sessionId }).then(() => + active.response.catch((error) => { + if (this.peer.closed) { + throw error + } + }) + ), new Promise((_resolve, reject) => { timer = setTimeout(() => { const error = new AcpRequestTimeoutError('session/cancel') @@ -276,13 +281,21 @@ export class AcpSessionRuntime { return this.permissions.handle(parsed.data, context, this.options.onPermission) } + private diagnose(message: string): void { + try { + this.options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot prevent event delivery. */ + } + } + private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { return } const envelope = updateEnvelopeSchema.safeParse(params) if (!envelope.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') + this.diagnose('Ignored invalid ACP session update envelope') return } const parsed = SessionNotificationSchema.safeParse(params) @@ -291,13 +304,13 @@ export class AcpSessionRuntime { : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } if (!parsed.success && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true - this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + this.diagnose('Forwarded unrecognized ACP session update') } for (const listener of this.listeners) { try { listener(event) } catch (error) { - this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + this.diagnose(`ACP event listener failed: ${String(error)}`) } } } From f3f2ccc7d7db44dbf9043befae9b67b2d121200e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 08/15] Generate open ACP enums and check the generated schema offline A newer or vendor enum value (tool kind, tool status, option kind, stop reason) no longer fails the whole message: generated enums accept the known literals plus any other string, typed so callers can still narrow on the known ones. The generated header now records the pinned input digests, the generator digest and a body hash, so `verify:acp-protocol` catches a stale or hand-edited file without network access; it runs in lint and the PR workflow. --- .github/workflows/pr.yml | 4 + config/scripts/acp/generate-protocol.mjs | 138 ++++++++++++------ package.json | 4 +- .../acp/acp-session-notifications.test.ts | 11 +- .../acp/generated/acp-protocol.generated.ts | 39 +++-- 5 files changed, 136 insertions(+), 60 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ed9a25732bbe..7a9b6b876fce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -349,6 +349,10 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog + - name: Verify the generated ACP protocol schema + if: needs.code_paths.outputs.static_analysis == 'true' + run: pnpm run verify:acp-protocol + - name: Verify bundled skill guides if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index 9e154fde5052..54d3d30b8e64 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -7,9 +7,46 @@ const release = 'schema-v1.21.0' const legacyRelease = 'v0.11.6' const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) -const check = process.argv.includes('--check') +const outputFile = 'acp-protocol.generated.ts' +// --check is offline (lint/CI): the header records every input digest and the body hash. +// --check-online regenerates from the pinned downloads and compares byte-for-byte. +const checkOnline = process.argv.includes('--check-online') +const check = checkOnline || process.argv.includes('--check') const definitions = {} -const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` +const inputs = { + schema: '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708', + legacy: 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581', + license: 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' +} +const sha256 = (text) => createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex') +const generatorDigest = sha256(await readFile(import.meta.filename, 'utf8')) +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n// Inputs sha256: schema ${inputs.schema}, legacy ${inputs.legacy}, license ${inputs.license}, generator ${generatorDigest}\n` +const bodyDigestPrefix = '// Body sha256: ' + +async function checkOffline() { + const text = (await readFile(resolve(output, outputFile), 'utf8')).replace(/\r\n/g, '\n') + if (!text.startsWith(header)) { + throw new Error(`Stale generated file: ${outputFile} (inputs or generator changed; regenerate)`) + } + const rest = text.slice(header.length) + const newline = rest.indexOf('\n') + if (!rest.startsWith(bodyDigestPrefix) || newline === -1) { + throw new Error(`Stale generated file: ${outputFile} (missing body digest)`) + } + if (rest.slice(bodyDigestPrefix.length, newline) !== sha256(rest.slice(newline + 1))) { + throw new Error(`Generated file was edited by hand: ${outputFile}`) + } + for (const file of await readdir(output)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + throw new Error(`Unexpected generated file: ${file}`) + } + } + console.log(`Checked ${outputFile} against pinned inputs offline`) +} +if (check && !checkOnline) { + await checkOffline() + process.exit(0) +} async function download(url, digest) { const response = await fetch(url) @@ -24,17 +61,11 @@ async function download(url, digest) { } const [schema, legacy, license] = await Promise.all([ - download( - `${repository}/releases/download/${release}/schema.unstable.json`, - '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' - ), - download( - `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, - 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' - ), + download(`${repository}/releases/download/${release}/schema.unstable.json`, inputs.schema), + download(`${repository}/releases/download/${legacyRelease}/schema.unstable.json`, inputs.legacy), download( `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, - 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + inputs.license ) ]) Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) @@ -115,6 +146,29 @@ function visit(name) { } roots.forEach(visit) +// A named string enum: two or more string constants, optionally with an open `string` member. +function isStringEnum(value) { + const alternatives = value.oneOf ?? value.anyOf + return ( + Array.isArray(alternatives) && + alternatives.filter((alternative) => typeof alternative.const === 'string').length > 1 && + alternatives.every( + (alternative) => + typeof alternative.const === 'string' || + (alternative.type === 'string' && + Object.keys(alternative).every((key) => ['type', 'title', 'description'].includes(key))) + ) + ) +} + +// Enums stay open so a newer or vendor value reaches the caller instead of failing the message. +function openEnum(value) { + const known = (value.oneOf ?? value.anyOf).filter( + (alternative) => typeof alternative.const === 'string' + ) + return `z.union([${known.map((alternative) => `z.literal(${JSON.stringify(alternative.const)})`).join(',')},otherString])` +} + function expression(value) { if (value === true) { return 'z.unknown()' @@ -197,48 +251,44 @@ function expression(value) { return result } -const generated = new Map([ - [ - 'acp-protocol.generated.ts', - `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered - .map( - (name) => - `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - ) - .join('\n')}` - ] -]) +const source = `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n// An enum value this schema release does not name; \`string & {}\` keeps the known literals narrowable.\nconst otherString = z.custom((value) => typeof value === 'string')\n${ordered + .map( + (name) => + `export const ${name}Schema = ${isStringEnum(definitions[name]) ? openEnum(definitions[name]) : expression(definitions[name])}\nexport type ${name} = z.infer\n` + ) + .join('\n')}` // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') +const formatted = await format(outputFile, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' +}) +if (formatted.errors.length || !formatted.code.startsWith(header)) { + throw new Error(`Formatting failed for ${outputFile}`) +} +const body = formatted.code.slice(header.length) +const code = `${header}${bodyDigestPrefix}${sha256(body)}\n${body}` await mkdir(output, { recursive: true }) -for (const [file, source] of generated) { - const formatted = await format(file, source, { - singleQuote: true, - semi: false, - printWidth: 100, - trailingComma: 'none' - }) - if (formatted.errors.length) { - throw new Error(`Formatting failed for ${file}`) - } - const destination = resolve(output, file) - if (check) { - if ((await readFile(destination, 'utf8')) !== formatted.code) { - throw new Error(`Stale generated file: ${file}`) - } - } else { - await writeFile(destination, formatted.code) +const destination = resolve(output, outputFile) +if (check) { + if ((await readFile(destination, 'utf8')) !== code) { + throw new Error(`Stale generated file: ${outputFile}`) } +} else { + await writeFile(destination, code) } for (const file of await readdir(output)) { - if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { - if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + const generatedHere = (await readFile(resolve(output, file), 'utf8')).startsWith( + '// Generated by config/scripts/acp/generate-protocol.mjs' + ) + if (check || !generatedHere) { throw new Error(`Unexpected generated file: ${file}`) } await unlink(resolve(output, file)) } } -console.log( - `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` -) +console.log(`${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions`) diff --git a/package.json b/package.json index 3c1979d5ff0e..e2b900443da3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -50,6 +50,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs", "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check", + "generate:acp-protocol": "node config/scripts/acp/generate-protocol.mjs", + "verify:acp-protocol": "node config/scripts/acp/generate-protocol.mjs --check", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index b13796d2ea06..e71c3084f9ab 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -24,11 +24,16 @@ afterEach(() => describe('ACP session update compatibility', () => { it.each([ ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + // Unfamiliar enum values stay typed: the generated enums are open. + ['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }], + ['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], [ - 'unrecognized', - { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + 'known', + { + sessionUpdate: 'plan', + entries: [{ content: 'Ship', priority: 'urgent', status: 'blocked' }] + } ], - ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], [ 'unrecognized', diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts index 4438895c9e31..88b6479d41f5 100644 --- a/src/main/acp/generated/acp-protocol.generated.ts +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -1,5 +1,7 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol // ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +// Inputs sha256: schema 7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708, legacy b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581, license f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221, generator 320945b1687098a7566f15e853c4fb846d3aa0c9bf8b6b67acbd8508fc304e0b +// Body sha256: 02c2a5308636bb6383bfa67745d530680552e562f020b705430a40b4c08c2f8e /* Apache License Version 2.0, January 2004 @@ -196,6 +198,8 @@ import { z } from 'zod' export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' export const ACP_PROTOCOL_VERSION = 1 +// An enum value this schema release does not name; `string & {}` keeps the known literals narrowable. +const otherString = z.custom((value) => typeof value === 'string') export const ProtocolVersionSchema = z.number().int().min(0).max(65535) export type ProtocolVersion = z.infer @@ -283,7 +287,8 @@ export type ClientNesCapabilities = z.infer export const PositionEncodingKindSchema = z.union([ z.literal('utf-16'), z.literal('utf-32'), - z.literal('utf-8') + z.literal('utf-8'), + otherString ]) export type PositionEncodingKind = z.infer @@ -398,7 +403,11 @@ export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ }) export type NesDocumentDidOpenCapabilities = z.infer -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export const TextDocumentSyncKindSchema = z.union([ + z.literal('full'), + z.literal('incremental'), + otherString +]) export type TextDocumentSyncKind = z.infer export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ @@ -645,7 +654,7 @@ export const SessionConfigOptionCategorySchema = z.union([ z.literal('model'), z.literal('model_config'), z.literal('thought_level'), - z.string() + otherString ]) export type SessionConfigOptionCategory = z.infer @@ -741,7 +750,7 @@ export const ResumeSessionResponseSchema = z.looseObject({ }) export type ResumeSessionResponse = z.infer -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user'), otherString]) export type Role = z.infer export const AnnotationsSchema = z.looseObject({ @@ -838,7 +847,8 @@ export const StopReasonSchema = z.union([ z.literal('max_tokens'), z.literal('max_turn_requests'), z.literal('refusal'), - z.literal('cancelled') + z.literal('cancelled'), + otherString ]) export type StopReason = z.infer @@ -889,7 +899,8 @@ export const ToolKindSchema = z.union([ z.literal('think'), z.literal('fetch'), z.literal('switch_mode'), - z.literal('other') + z.literal('other'), + otherString ]) export type ToolKind = z.infer @@ -897,7 +908,8 @@ export const ToolCallStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), z.literal('completed'), - z.literal('failed') + z.literal('failed'), + otherString ]) export type ToolCallStatus = z.infer @@ -969,14 +981,16 @@ export type ToolCallUpdate = z.infer export const PlanEntryPrioritySchema = z.union([ z.literal('high'), z.literal('medium'), - z.literal('low') + z.literal('low'), + otherString ]) export type PlanEntryPriority = z.infer export const PlanEntryStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), - z.literal('completed') + z.literal('completed'), + otherString ]) export type PlanEntryStatus = z.infer @@ -1102,7 +1116,7 @@ export const CompactionStatusSchema = z.union([ z.literal('completed'), z.literal('failed'), z.literal('cancelled'), - z.string() + otherString ]) export type CompactionStatus = z.infer @@ -1185,7 +1199,8 @@ export const PermissionOptionKindSchema = z.union([ z.literal('allow_once'), z.literal('allow_always'), z.literal('reject_once'), - z.literal('reject_always') + z.literal('reject_always'), + otherString ]) export type PermissionOptionKind = z.infer From 0db8b0c8f73b8aeef5db407b1ba1f64ebe307137 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 09/15] Land the ACP runtime contract the agent adapters use - Deliver notifications other than session/update through onExtensionNotification, in arrival order with session updates. - Accept _meta on prompt, setMode, setModel, setConfigOption and cancel. - cancel() always sends session/cancel once the session runs, since the agent can be in a turn it began itself; only a successful send is shared, so a failed write is retried. - Cancel aborts each open agent request's signal and lets its handler send its own answer; -32800 only when the handler rejects. - Permission requests validate only the session, tool call id and options; unreadable fields are dropped with a diagnostic, and any answer Orca cannot send is `cancelled` instead of a JSON-RPC error. Agent-started turns may ask; whether to show it is the caller's decision. - AcpAgentError marks the agent's own errors; AcpInvalidResponseError keeps the raw answer and validation issues for answers Orca could not read. - Lines over the size limit are classified by prefix (shared with the Codex reader): the owed request fails, an oversized agent request is answered with an error, and an unattributable response closes the connection. --- src/main/acp/acp-errors.ts | 37 +++- src/main/acp/acp-incoming-requests.ts | 48 +++-- src/main/acp/acp-json-rpc-peer.test.ts | 44 ++++- src/main/acp/acp-json-rpc-peer.ts | 60 +++--- src/main/acp/acp-oversized-lines.ts | 38 ++++ src/main/acp/acp-peer-limits.ts | 21 +++ src/main/acp/acp-permission-requests.test.ts | 120 ++++++++++++ src/main/acp/acp-permission-requests.ts | 161 +++++++++++------ src/main/acp/acp-prompt-cancel.ts | 46 +++++ src/main/acp/acp-session-agent-turns.test.ts | 148 +++++++++++++++ src/main/acp/acp-session-events.ts | 26 +++ src/main/acp/acp-session-lifecycle.test.ts | 24 ++- src/main/acp/acp-session-runtime.test.ts | 23 ++- src/main/acp/acp-session-runtime.ts | 171 +++++++++--------- .../codex/codex-app-server-record-dispatch.ts | 2 +- .../json-rpc-record-prefix.ts} | 0 16 files changed, 759 insertions(+), 210 deletions(-) create mode 100644 src/main/acp/acp-oversized-lines.ts create mode 100644 src/main/acp/acp-peer-limits.ts create mode 100644 src/main/acp/acp-permission-requests.test.ts create mode 100644 src/main/acp/acp-prompt-cancel.ts create mode 100644 src/main/acp/acp-session-agent-turns.test.ts create mode 100644 src/main/acp/acp-session-events.ts rename src/{main/codex/codex-app-server-record-prefix.ts => shared/json-rpc-record-prefix.ts} (100%) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 41aff9694e2e..edcc93de077f 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -11,7 +11,16 @@ export class AcpRpcError extends Error { } } -export class AcpAuthRequiredError extends AcpRpcError { +/** The agent's own error answer to a request: it read the request and refused it. Errors Orca + * raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */ +export class AcpAgentError extends AcpRpcError { + constructor(code: number, message: string, data?: unknown) { + super(code, message, data) + this.name = 'AcpAgentError' + } +} + +export class AcpAuthRequiredError extends AcpAgentError { constructor( message: string, data?: unknown, @@ -22,6 +31,32 @@ export class AcpAuthRequiredError extends AcpRpcError { } } +/** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */ +export class AcpInvalidResponseError extends AcpRpcError { + constructor( + message: string, + raw: unknown, + readonly issues?: unknown + ) { + super(-32603, message, raw) + this.name = 'AcpInvalidResponseError' + } +} + +/** A line from the agent exceeded the framing limit, so the message it carried was never read. */ +export class AcpFrameTooLargeError extends Error { + constructor( + readonly method: string | null, + readonly observedBytes: number, + readonly maxBytes: number + ) { + super( + `ACP${method ? ` ${method}` : ''} message exceeds ${maxBytes} byte limit (${observedBytes} bytes received)` + ) + this.name = 'AcpFrameTooLargeError' + } +} + export class AcpConnectionClosedError extends Error { constructor(message = 'ACP connection closed') { super(message) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index b565c00c207a..92e50219e515 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,12 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { method: string; controller: AbortController; abandon: () => void } +type OpenRequest = { + controller: AbortController + abandon: () => void + closed: boolean + cancelled: boolean +} export class AcpIncomingRequests { private readonly open = new Map() @@ -16,22 +21,20 @@ export class AcpIncomingRequests { close(error: Error): void { for (const request of this.open.values()) { + request.closed = true request.controller.abort(error) request.abandon() } this.open.clear() } - cancel(exceptMethod?: string): void { - for (const [id, request] of this.open) { - if (request.method === exceptMethod) { - continue + // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + cancel(): void { + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) } - const error = new AcpRpcError(-32800, 'Request cancelled') - this.open.delete(id) - request.controller.abort(error) - request.abandon() - void this.sendError(id, error) } } @@ -41,7 +44,7 @@ export class AcpIncomingRequests { return } if (this.open.size >= this.capacity) { - void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) + this.refuse(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() @@ -49,9 +52,10 @@ export class AcpIncomingRequests { const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) }) - this.open.set(id, { method, controller, abandon }) + const request: OpenRequest = { controller, abandon, closed: false, cancelled: false } + this.open.set(id, request) const retire = (): void => { - if (this.open.get(id)?.controller === controller) { + if (this.open.get(id) === request) { this.open.delete(id) } } @@ -59,7 +63,7 @@ export class AcpIncomingRequests { abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { - return undefined + throw controller.signal.reason } if (!this.handler) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) @@ -68,7 +72,7 @@ export class AcpIncomingRequests { }) ]) .then(async (result) => { - if (controller.signal.aborted) { + if (request.closed) { return } // The agent may reuse the id as soon as it reads the response. @@ -76,15 +80,17 @@ export class AcpIncomingRequests { await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { - if (controller.signal.aborted) { + if (request.closed) { return } retire() await this.sendError( id, - error instanceof AcpRpcError - ? error - : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + request.cancelled + ? new AcpRpcError(-32800, 'Request cancelled') + : error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) ) }) .finally(() => { @@ -93,6 +99,10 @@ export class AcpIncomingRequests { }) } + refuse(id: string | number | null, error: AcpRpcError): void { + void this.sendError(id, error) + } + private async sendError(id: string | number | null, error: AcpRpcError): Promise { try { await this.send({ diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 9ead2e2ddc0c..3d74a9c1c7cc 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -1,7 +1,12 @@ import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpConnectionClosedError, + AcpFrameTooLargeError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' const peers: AcpJsonRpcPeer[] = [] @@ -98,10 +103,45 @@ describe('ACP JSON-RPC peer', () => { expect(await peer.request('valid', {})).toBe('ok') expect(notified).toEqual(['✓']) expect(diagnostics).toContain('Ignored ACP line: invalid-json') - expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored ACP line: line-too-long (unknown)') expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) + it('settles whoever was owed a message that exceeded the line limit', async () => { + const diagnostics: string[] = [] + const { peer, agent } = fixture( + { onDiagnostic: (message) => diagnostics.push(message), onRequest: () => 'unused' }, + { maxLineBytes: 200 } + ) + const filler = 'x'.repeat(300) + const big = peer.request('session/load', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":1,"result":{"history":"${filler}"}}\n`) + const lost = await big.catch((error: unknown) => error) + expect(lost).toBeInstanceOf(AcpFrameTooLargeError) + expect(lost).toMatchObject({ method: 'session/load', maxBytes: 200 }) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => resolve(JSON.parse(chunk))) + }) + agent.stdout.write( + `{"jsonrpc":"2.0","id":"q","method":"session/request_permission","params":{"diff":"${filler}"}}\n` + ) + expect(await answer).toMatchObject({ id: 'q', error: { code: -32600 } }) + agent.stdout.write(`{"jsonrpc":"2.0","method":"session/update","params":{"x":"${filler}"}}\n`) + agent.stdout.write(`${filler}\n`) + await tick() + expect(peer.closed).toBe(false) + expect(diagnostics).toEqual([ + 'Ignored ACP line: line-too-long (response)', + 'Ignored ACP line: line-too-long (server-request)', + 'Ignored ACP line: line-too-long (notification)', + 'Ignored ACP line: line-too-long (unknown)' + ]) + const stranded = peer.request('session/prompt', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":2,"vendor":"${filler}"}\n`) + await expect(stranded).rejects.toBeInstanceOf(AcpFrameTooLargeError) + expect(peer.closed).toBe(true) + }) + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 08df74ef89ae..2a2cb867b08c 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,12 +1,18 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' -import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpConnectionClosedError, + AcpInvalidResponseError, + AcpRequestTimeoutError +} from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' +import { bounded, requestTimeout } from './acp-peer-limits' +import { settleOversizedAcpLine } from './acp-oversized-lines' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' @@ -42,6 +48,7 @@ export type AcpPeerOptions = { requestTimeoutMs?: number | null } type Pending = { + method: string resolve: (value: unknown) => void reject: (error: Error) => void timer?: ReturnType @@ -83,7 +90,15 @@ export class AcpJsonRpcPeer { ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), - (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + (rejected) => + rejected.kind === 'line-too-long' + ? settleOversizedAcpLine(rejected, { + rejectPending: (id, error) => this.rejectPending(id, error), + refuse: (id, error) => this.incoming.refuse(id, error), + close: (error) => this.close(error), + diagnose: (message) => this.diagnose(message) + }) + : this.diagnose(`Ignored ACP line: ${rejected.kind}`), { maxLineBytes: this.maxLineBytes } ) input.setEncoding('utf8') @@ -134,7 +149,7 @@ export class AcpJsonRpcPeer { controller.abort(error) reject(error) }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + this.pending.set(id, { method, resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) if (!pending) { @@ -151,8 +166,9 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } - cancelIncomingRequests(exceptMethod?: string): void { - this.incoming.cancel(exceptMethod) + /** Aborts every open agent request's signal; each handler still sends its own answer. */ + cancelIncomingRequests(): void { + this.incoming.cancel() } close(error: Error = new AcpConnectionClosedError()): void { @@ -210,12 +226,12 @@ export class AcpJsonRpcPeer { } } - private rejectInvalidResponse(id: number, raw: unknown): void { + private rejectPending(id: number, error: (method: string) => Error): void { const pending = this.pending.get(id) if (pending) { this.pending.delete(id) clearTimeout(pending.timer) - pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + pending.reject(error(pending.method)) } } @@ -230,7 +246,7 @@ export class AcpJsonRpcPeer { .object({ id: z.number(), method: z.undefined().optional() }) .safeParse(record) if (response.success) { - this.rejectInvalidResponse(response.data.id, record) + this.rejectPending(response.data.id, invalidEnvelope(record)) } return } @@ -254,7 +270,7 @@ export class AcpJsonRpcPeer { if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') if (typeof frame.id === 'number') { - this.rejectInvalidResponse(frame.id, record) + this.rejectPending(frame.id, invalidEnvelope(record)) } return } @@ -271,10 +287,10 @@ export class AcpJsonRpcPeer { const parsedError = errorSchema.safeParse(frame.error) if (!parsedError.success) { this.diagnose('Invalid ACP error response') - pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + pending.reject(new AcpInvalidResponseError('Invalid ACP error response', frame.error)) } else { const error = parsedError.data - pending.reject(new AcpRpcError(error.code, error.message, error.data)) + pending.reject(new AcpAgentError(error.code, error.message, error.data)) } } else { pending.resolve(frame.result) @@ -282,22 +298,6 @@ export class AcpJsonRpcPeer { } } -function bounded(value: number | undefined, fallback: number): number { - if (value === undefined) { - return fallback - } - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error('ACP limits must be positive finite integers') - } - return value -} - -function requestTimeout(value: number | null | undefined): number | null { - if (value == null) { - return null - } - if (!isSafeTimerDelayMs(value) || value <= 0) { - throw new Error('ACP timeouts must be positive finite timer durations') - } - return value +function invalidEnvelope(raw: unknown): () => Error { + return () => new AcpInvalidResponseError('Invalid ACP response envelope', raw) } diff --git a/src/main/acp/acp-oversized-lines.ts b/src/main/acp/acp-oversized-lines.ts new file mode 100644 index 000000000000..a4329b847821 --- /dev/null +++ b/src/main/acp/acp-oversized-lines.ts @@ -0,0 +1,38 @@ +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' +import type { NdjsonRejectedRecord } from '../../shared/main-process-ndjson-framer' +import { AcpFrameTooLargeError, AcpRpcError } from './acp-errors' + +export type AcpOversizedLineTarget = { + rejectPending: (id: number, error: (method: string) => Error) => void + refuse: (id: string | number, error: AcpRpcError) => void + close: (error: Error) => void + diagnose: (message: string) => void +} + +/** The Codex reader's prefix classification: settle whoever was owed the message that was lost. */ +export function settleOversizedAcpLine( + rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }, + target: AcpOversizedLineTarget +): void { + const { observedBytes, maxLineBytes } = rejected + const record = classifyJsonRpcPrefix(rejected.prefix) + target.diagnose(`Ignored ACP line: line-too-long (${record.kind})`) + if (record.kind === 'server-request') { + target.refuse( + record.id, + new AcpRpcError(-32600, `ACP request exceeds ${maxLineBytes} byte limit`, { + method: record.method, + observedBytes + }) + ) + } else if (record.kind === 'response') { + target.rejectPending( + record.id, + (method) => new AcpFrameTooLargeError(method, observedBytes, maxLineBytes) + ) + } else if (record.kind === 'response-unknown') { + // A numeric id with no readable result: any pending call could be the one that never settles. + target.close(new AcpFrameTooLargeError(null, observedBytes, maxLineBytes)) + } + // A notification or non-JSON-RPC output (a stray log line) settles nothing; closing would end the session. +} diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts new file mode 100644 index 000000000000..51f98809472e --- /dev/null +++ b/src/main/acp/acp-peer-limits.ts @@ -0,0 +1,21 @@ +import { isSafeTimerDelayMs } from '../../shared/timer-delay' + +export function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} + +export function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts new file mode 100644 index 000000000000..f51a7429853c --- /dev/null +++ b/src/main/acp/acp-permission-requests.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { RequestPermissionRequest } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const allow = { optionId: 'allow', name: 'Allow', kind: 'allow_once' } +const toolCall = { toolCallId: 'tool-1', title: 'Edit file' } +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const diagnostics: string[] = [] + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: (message) => diagnostics.push(message), + ...options + }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/prompt', () => {}) + return { agent, runtime, diagnostics } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP permission requests', () => { + it.each([ + ['a newer tool kind', { ...toolCall, kind: 'web_search' }, allow], + ['a newer tool status', { ...toolCall, status: 'cancelled' }, allow], + ['a newer option kind', toolCall, { ...allow, kind: 'allow_for_session' }] + ])('delivers a permission with %s to the caller unchanged', async (_label, call, option) => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'selected' as const, optionId: option.optionId } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const params = { sessionId: 'session-1', toolCall: call, options: [option] } + expect(await agent.request('p', 'session/request_permission', params)).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: option.optionId } } + }) + expect(asked.mock.calls[0][0]).toEqual(params) + expect(diagnostics).toEqual([]) + }) + + it('delivers a permission whose other fields are unreadable, dropping only those fields', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'cancelled' as const } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const content = [{ type: 'content', content: { type: 'reasoning_summary', text: 'x' } }] + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall: { ...toolCall, content, vendor: 'kept' }, + options: [{ optionId: 'allow', kind: 'allow_once' }, { name: 'No id' }] + }) + expect(asked.mock.calls[0][0]).toEqual({ + sessionId: 'session-1', + toolCall: { ...toolCall, vendor: 'kept' }, + options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] + }) + expect(diagnostics).toEqual([ + 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + ]) + }) + + it.each([ + [ + 'the handler throws', + () => { + throw new Error('renderer gone') + }, + 'handler failed: Error: renderer gone' + ], + ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { + // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. + const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => + Reflect.apply(onPermission, undefined, [request, context]) + const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + expect( + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) + }) + + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { + const asked = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + asked.resolve(context.signal) + return new Promise(() => {}) + } + }) + await runtime.start(startOptions) + const answer = agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + const signal = await asked.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await answer).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + await tick() + expect(agent.frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 4701359d11b1..c39f4a4c9022 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -1,7 +1,10 @@ -import { AcpRpcError } from './acp-errors' +import { z } from 'zod' import type { AcpRequestContext } from './acp-json-rpc-peer' import { + PermissionOptionSchema, + RequestPermissionRequestSchema, RequestPermissionResponseSchema, + ToolCallUpdateSchema, type RequestPermissionRequest, type RequestPermissionResponse } from './generated/acp-protocol.generated' @@ -12,64 +15,112 @@ export type AcpPermissionHandler = ( ) => RequestPermissionResponse | Promise const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } -type OpenPermission = { sessionId: string; cancel: () => void } +const routingSchema = z.looseObject({ + sessionId: z.string(), + toolCall: z.looseObject({ toolCallId: z.string() }), + options: z.array(z.unknown()) +}) +const optionRoutingSchema = z.looseObject({ optionId: z.string() }) -export class AcpPermissionRequests { - private readonly open = new Set() - - cancel(sessionId: string): void { - for (const permission of this.open) { - if (permission.sessionId === sessionId) { - permission.cancel() - } +// Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. +function readableFields( + shape: Record, + value: Record, + path: string, + dropped: string[] +): Record { + const kept: Record = {} + for (const [key, field] of Object.entries(value)) { + if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + kept[key] = field + } else { + dropped.push(`${path}${key}`) } } + return kept +} - handle( - request: RequestPermissionRequest, - context: AcpRequestContext, - handler: AcpPermissionHandler | undefined - ): Promise { - if (!handler || context.signal.aborted) { - return Promise.resolve(cancelled) +/** Validates only what answering needs (session, tool call id, options with ids); null if unusable. */ +export function readAcpPermissionRequest( + params: unknown, + diagnose: (message: string) => void +): RequestPermissionRequest | null { + const strict = RequestPermissionRequestSchema.safeParse(params) + if (strict.success) { + return strict.data + } + const routing = routingSchema.safeParse(params) + if (!routing.success) { + return null + } + const { toolCall, options: offered, ...rest } = routing.data + const dropped: string[] = [] + const options = offered.flatMap((option, index) => { + const ids = optionRoutingSchema.safeParse(option) + if (!ids.success) { + dropped.push(`options.${index}`) + return [] } - return new Promise((resolve, reject) => { - const controller = new AbortController() - let settled = false - const finish = (response: RequestPermissionResponse | Error): void => { - if (settled) { - return - } - settled = true - this.open.delete(permission) - context.signal.removeEventListener('abort', permission.cancel) - controller.abort() - if (response instanceof Error) { - reject(response) - } else { - resolve(response) - } + const named = { + ...ids.data, + name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId + } + const parsed = PermissionOptionSchema.safeParse(named) + if (!parsed.success) { + dropped.push(`options.${index}`) + return [] + } + return [parsed.data] + }) + if (options.length === 0) { + return null + } + const request = RequestPermissionRequestSchema.safeParse({ + ...readableFields(RequestPermissionRequestSchema.shape, rest, '', dropped), + toolCall: readableFields(ToolCallUpdateSchema.shape, toolCall, 'toolCall.', dropped), + options + }) + if (!request.success) { + return null + } + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + return request.data +} + +/** Asks the caller; any answer Orca cannot send (a throw, a bad reply, an unoffered option) is `cancelled`. */ +export function answerAcpPermission( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined, + diagnose: (message: string) => void +): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse, problem?: string): void => { + if (settled) { + return } - const permission: OpenPermission = { - sessionId: request.sessionId, - cancel: () => finish(cancelled) + settled = true + context.signal.removeEventListener('abort', onAbort) + controller.abort() + if (problem) { + diagnose(`Answered ACP permission request cancelled: ${problem}`) } - this.open.add(permission) - context.signal.addEventListener('abort', permission.cancel, { once: true }) - void Promise.resolve() - .then(() => { - if (controller.signal.aborted) { - return cancelled - } - return handler(request, { id: context.id, signal: controller.signal }) - }) - .then((response) => { - if (settled) { - return - } + resolve(response) + } + const onAbort = (): void => finish(cancelled) + context.signal.addEventListener('abort', onAbort, { once: true }) + void Promise.resolve() + .then(() => handler(request, { id: context.id, signal: controller.signal })) + .then( + (response) => { const parsed = RequestPermissionResponseSchema.safeParse(response) if (!parsed.success) { - finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + finish(cancelled, 'invalid handler response') return } const outcome = parsed.data.outcome @@ -77,12 +128,12 @@ export class AcpPermissionRequests { outcome.outcome === 'selected' && !request.options.some((option) => option.optionId === outcome.optionId) ) { - finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + finish(cancelled, 'handler selected an unavailable option') return } finish(parsed.data) - }) - .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) - }) - } + }, + (error) => finish(cancelled, `handler failed: ${String(error)}`) + ) + }) } diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts new file mode 100644 index 000000000000..5208c6df1465 --- /dev/null +++ b/src/main/acp/acp-prompt-cancel.ts @@ -0,0 +1,46 @@ +import { AcpRequestTimeoutError } from './acp-errors' +import type { PromptResponse } from './generated/acp-protocol.generated' + +export type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +/** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ +export async function confirmAcpPromptCancel( + active: ActivePrompt, + connection: { + send: () => Promise + close: (error: Error) => void + closed: () => boolean + timeoutMs: number + } +): Promise { + let timer: ReturnType | undefined + const unconfirmed = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + connection.close(error) + reject(error) + }, connection.timeoutMs) + }) + try { + await Promise.race([connection.send(), unconfirmed]).catch((error) => { + // Only a cancel that reached the agent is shared; a failed write is retried next call. + active.cancelPromise = undefined + active.cancelling = false + throw error + }) + await Promise.race([ + active.response.catch((error) => { + if (connection.closed()) { + throw error + } + }), + unconfirmed + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/acp/acp-session-agent-turns.test.ts b/src/main/acp/acp-session-agent-turns.test.ts new file mode 100644 index 000000000000..474ba298184b --- /dev/null +++ b/src/main/acp/acp-session-agent-turns.test.ts @@ -0,0 +1,148 @@ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { AcpAgentError, AcpInvalidResponseError } from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, type FakeFrame, tick } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP turns the agent begins and the runtime contract around them', () => { + it('delivers extension notifications in arrival order with session updates', async () => { + const order: string[] = [] + const { agent, runtime } = fixture({ + onExtensionNotification: (method, params) => order.push(`${method} ${JSON.stringify(params)}`) + }) + runtime.subscribe((event) => order.push(`update ${event.kind}`)) + await runtime.start(startOptions) + const completed = { sessionId: 'session-1', update: { type: 'turn_completed', prompt_id: 'p' } } + agent.stdout.write( + [ + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: { started: true } }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + }, + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: completed } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + await tick() + expect(order).toEqual([ + '_x.ai/session_notification {"started":true}', + 'update known', + `_x.ai/session_notification ${JSON.stringify(completed)}` + ]) + }) + + it('sends session/cancel with no prompt of Orca running, and _meta on every session call', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + await runtime.start(startOptions) + const meta = { traceId: 't' } + await runtime.prompt([...prompt], meta) + await runtime.setMode('plan', meta) + await runtime.setModel('model-1', meta) + await runtime.setConfigOption('effort', 'high', meta) + await runtime.cancel({ meta }) + await tick() + const sent = agent.frames.filter((frame) => frame.method?.startsWith('session/')) + expect(sent.map((frame) => frame.method)).toEqual([ + 'session/new', + 'session/prompt', + 'session/set_mode', + 'session/set_model', + 'session/set_config_option', + 'session/cancel' + ]) + for (const frame of sent.slice(1)) { + expect(frame.params).toMatchObject({ sessionId: 'session-1', _meta: meta }) + } + }) + + it("separates the agent's own errors from answers Orca could not read", async () => { + const { agent, runtime } = fixture() + await runtime.start(startOptions) + agent.on('session/set_mode', (frame) => agent.fail(frame, -32603, 'Agent broke', { why: 1 })) + const refused = await runtime.setMode('plan').catch((error: unknown) => error) + expect(refused).toBeInstanceOf(AcpAgentError) + expect(refused).toMatchObject({ code: -32603, data: { why: 1 } }) + agent.on('session/set_mode', (frame) => agent.reply(frame, 'not-an-object')) + const unreadable = await runtime.setMode('plan').catch((error: unknown) => error) + expect(unreadable).toBeInstanceOf(AcpInvalidResponseError) + expect(unreadable).not.toBeInstanceOf(AcpAgentError) + expect(unreadable).toMatchObject({ code: -32603, data: 'not-an-object' }) + expect(unreadable instanceof AcpInvalidResponseError && unreadable.issues).toBeTruthy() + }) + + it('completes a turn whose stop reason is newer than this schema', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'context_exhausted' })) + await runtime.start(startOptions) + expect(await runtime.prompt([...prompt])).toEqual({ stopReason: 'context_exhausted' }) + }) + + it('retries a cancel whose write failed instead of returning the stale failure', async () => { + const agent = new AcpScriptedAgent() + const stdin = new PassThrough({ highWaterMark: 1 }) + const runtime = new AcpSessionRuntime(agent.stdout, stdin, { + peer: { maxQueuedWriteBytes: 1000 } + }) + opened.push(agent, runtime) + const frames: FakeFrame[] = [] + let promptFrame: FakeFrame | undefined + let buffer = '' + stdin.setEncoding('utf8').on('data', (chunk: string) => { + buffer += chunk + let newline: number + while ((newline = buffer.indexOf('\n')) !== -1) { + const frame: FakeFrame = JSON.parse(buffer.slice(0, newline)) + buffer = buffer.slice(newline + 1) + frames.push(frame) + if (frame.method === 'initialize') { + agent.reply(frame, { protocolVersion: 1 }) + } else if (frame.method === 'session/new') { + agent.reply(frame, { sessionId: 'session-1' }) + } else if (frame.method === 'session/prompt') { + promptFrame = frame + stdin.pause() + } else if (frame.method === 'session/cancel' && promptFrame) { + agent.reply(promptFrame, { stopReason: 'cancelled' }) + } + } + }) + await runtime.start(startOptions) + const turn = runtime.prompt([...prompt]) + await tick() + void runtime.setMode('y'.repeat(850)).catch(() => {}) + await expect(runtime.cancel()).rejects.toThrow(/capacity/) + stdin.resume() + await tick() + await runtime.cancel() + expect(await turn).toEqual({ stopReason: 'cancelled' }) + expect(frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-session-events.ts b/src/main/acp/acp-session-events.ts new file mode 100644 index 000000000000..47dd54c57a88 --- /dev/null +++ b/src/main/acp/acp-session-events.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { + SessionNotificationSchema, + type SessionNotification +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) + +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } + +/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */ +export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null { + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + return null + } + const parsed = SessionNotificationSchema.safeParse(params) + return parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } +} diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 43f2815697da..092ed11e186c 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,24 +247,28 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { - const entered = deferred() + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { const { agent, runtime } = fixture({ - onRequest: (_method, _params, context) => { - entered.resolve(context.signal) - return new Promise(() => {}) - } + onRequest: (method, _params, context) => + new Promise((resolve, reject) => + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + ) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) ) await runtime.start(startOptions) const pending = runtime.prompt([...prompt]) - const response = agent.request('question', '_vendor/question', {}) - const signal = await entered.promise + const question = agent.request('question', '_vendor/question', {}) + const plan = agent.request('plan', '_vendor/plan', {}) + await tick() await runtime.cancel() - expect(signal.aborted).toBe(true) - expect(await response).toMatchObject({ error: { code: -32800 } }) + expect(await question).toMatchObject({ error: { code: -32800 } }) + expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) await pending }) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 068da3f4742f..b89e0b58059f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -324,26 +324,35 @@ describe('ACP session runtime', () => { await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) }) - it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + it('rejects unroutable permission requests and answers an unavailable selection cancelled', async () => { + const diagnostics: string[] = [] const { runtime, agent } = fixture( {}, { - onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }), + onDiagnostic: (message) => diagnostics.push(message) } ) agent.on('session/prompt', () => {}) await runtime.start(startOptions) - const prompt = runtime.prompt([...textPrompt]) - const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + void runtime.prompt([...textPrompt]).catch(() => {}) expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ error: { code: -32602 } }) + expect( + await agent.request('no-options', 'session/request_permission', { + ...permission, + options: [{ name: 'No id', kind: 'allow_once' }] + }) + ).toMatchObject({ error: { code: -32602 } }) expect( await agent.request('bad-selection', 'session/request_permission', permission) - ).toMatchObject({ error: { code: -32603 } }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toContain( + 'Answered ACP permission request cancelled: handler selected an unavailable option' + ) + // The session stays usable: a local permission failure is not a protocol failure. await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') - runtime.close() - await rejected }) it('preserves unrecognized updates and isolates event listener failures', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 77e317362d13..7d185c108c4d 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,44 +1,51 @@ import type { Readable, Writable } from 'node:stream' -import { z } from 'zod' +import type { z } from 'zod' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' -import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpAuthRequiredError, + AcpInvalidResponseError, + AcpRpcError +} from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' -import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + answerAcpPermission, + readAcpPermissionRequest, + type AcpPermissionHandler +} from './acp-permission-requests' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' +import { confirmAcpPromptCancel, type ActivePrompt } from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +export type { AcpSessionEvent } from './acp-session-events' import { ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, - RequestPermissionRequestSchema, - SessionNotificationSchema, SetSessionModeResponseSchema, SetSessionModelResponseSchema, SetSessionConfigOptionResponseSchema, type InitializeRequest, type InitializeResponse, type AuthenticateResponse, + type CancelNotification, type PromptRequest, type PromptResponse, - type SessionNotification, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, + type SetSessionModeRequest, type SetSessionModeResponse, + type SetSessionModelRequest, type SetSessionModelResponse } from './generated/acp-protocol.generated' -const updateEnvelopeSchema = z.looseObject({ - sessionId: z.string(), - update: z.looseObject({ sessionUpdate: z.string() }) -}) -export type AcpSessionEvent = - | { kind: 'known'; notification: SessionNotification } - | { kind: 'unrecognized'; sessionId: string; raw: z.infer } +type Meta = PromptRequest['_meta'] +const withMeta = (meta: Meta): { _meta?: Meta } => (meta ? { _meta: meta } : {}) export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] @@ -46,18 +53,15 @@ export type AcpSessionRuntimeOptions = { cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown + /** Agent notifications other than `session/update` (protocol extensions), delivered + * synchronously in arrival order with the `subscribe` events. */ + onExtensionNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } -type ActivePrompt = { - response: Promise - cancelling: boolean - cancelPromise?: Promise -} export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer - private readonly permissions = new AcpPermissionRequests() private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise @@ -148,14 +152,14 @@ export class AcpSessionRuntime { return this.starting } - async prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt'], meta?: Meta): Promise { if (this.activePrompt) { throw new Error('ACP prompt already in progress') } - const sessionId = this.sessionId() + const params: PromptRequest = { sessionId: this.sessionId(), prompt, ...withMeta(meta) } const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) + response: this.call('session/prompt', params, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -166,70 +170,64 @@ export class AcpSessionRuntime { return active.response } - cancel(): Promise { + /** Sends `session/cancel` whenever the session runs: the agent may be in a turn it began itself. + * With Orca's prompt running, also waits (bounded) for that prompt to settle. */ + cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { if (!this.started) { return Promise.reject(new Error('ACP session has not started')) } - const sessionId = this.started.sessionId - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') + const params: CancelNotification = { + sessionId: this.started.sessionId, + ...withMeta(options.meta) + } + this.peer.cancelIncomingRequests() const active = this.activePrompt if (!active) { - return Promise.resolve() + return this.peer.notify('session/cancel', params) } active.cancelling = true - active.cancelPromise ??= this.cancelActive(sessionId, active) + active.cancelPromise ??= confirmAcpPromptCancel(active, { + send: () => this.peer.notify('session/cancel', params), + close: (error) => this.peer.close(error), + closed: () => this.peer.closed, + timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) + }) return active.cancelPromise } - private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - let timer: ReturnType | undefined - try { - await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => - active.response.catch((error) => { - if (this.peer.closed) { - throw error - } - }) - ), - new Promise((_resolve, reject) => { - timer = setTimeout(() => { - const error = new AcpRequestTimeoutError('session/cancel') - this.peer.close(error) - reject(error) - }, timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } - } - - async setMode(modeId: string): Promise { - return this.call( - 'session/set_mode', - { sessionId: this.sessionId(), modeId }, - SetSessionModeResponseSchema - ) + async setMode(modeId: string, meta?: Meta): Promise { + const params: SetSessionModeRequest = { sessionId: this.sessionId(), modeId, ...withMeta(meta) } + return this.call('session/set_mode', params, SetSessionModeResponseSchema) } - async setModel(modelId: string): Promise { - return this.call( - 'session/set_model', - { sessionId: this.sessionId(), modelId }, - SetSessionModelResponseSchema - ) + async setModel(modelId: string, meta?: Meta): Promise { + const params: SetSessionModelRequest = { + sessionId: this.sessionId(), + modelId, + ...withMeta(meta) + } + return this.call('session/set_model', params, SetSessionModelResponseSchema) } async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], - value: SetSessionConfigOptionRequest['value'] + value: SetSessionConfigOptionRequest['value'], + meta?: Meta ): Promise { const sessionId = this.sessionId() const request = typeof value === 'boolean' - ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) - : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + ? ({ + configId, + value, + sessionId, + type: 'boolean', + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) + : ({ + configId, + value, + sessionId, + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } @@ -248,14 +246,18 @@ export class AcpSessionRuntime { private async call(method: string, params: unknown, schema: z.ZodType): Promise { const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { - if (error instanceof AcpRpcError && error.code === -32000) { + if (error instanceof AcpAgentError && error.code === -32000) { throw new AcpAuthRequiredError(error.message, error.data) } throw error }) const parsed = schema.safeParse(result) if (!parsed.success) { - throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + throw new AcpInvalidResponseError( + `Invalid ACP response: ${method}`, + result, + parsed.error.issues + ) } return parsed.data } @@ -267,18 +269,16 @@ export class AcpSessionRuntime { } return this.options.onRequest(method, params, context) } - const parsed = RequestPermissionRequestSchema.safeParse(params) - if (!parsed.success) { + const diagnose = (message: string): void => this.diagnose(message) + const request = readAcpPermissionRequest(params, diagnose) + if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - if ( - !this.activePrompt || - this.activePrompt.cancelling || - parsed.data.sessionId !== this.started?.sessionId - ) { + // Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it. + if (this.activePrompt?.cancelling || request.sessionId !== this.started?.sessionId) { return { outcome: { outcome: 'cancelled' } } } - return this.permissions.handle(parsed.data, context, this.options.onPermission) + return answerAcpPermission(request, context, this.options.onPermission, diagnose) } private diagnose(message: string): void { @@ -291,18 +291,19 @@ export class AcpSessionRuntime { private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { + try { + this.options.onExtensionNotification?.(method, params) + } catch (error) { + this.diagnose(`ACP extension listener failed: ${String(error)}`) + } return } - const envelope = updateEnvelopeSchema.safeParse(params) - if (!envelope.success) { + const event = readAcpSessionEvent(params) + if (!event) { this.diagnose('Ignored invalid ACP session update envelope') return } - const parsed = SessionNotificationSchema.safeParse(params) - const event: AcpSessionEvent = parsed.success - ? { kind: 'known', notification: parsed.data } - : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } - if (!parsed.success && !this.reportedUpdateAnomaly) { + if (event.kind === 'unrecognized' && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true this.diagnose('Forwarded unrecognized ACP session update') } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index cbac80f3643e..078d672293ea 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -7,7 +7,7 @@ import { CodexAppServerUnsupportedError, isCodexMethodNotFoundError } from './codex-app-server-session' -import { classifyJsonRpcPrefix } from './codex-app-server-record-prefix' +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' const OVERSIZED_REQUEST_ERROR_CODE = -32001 const MAX_REMEMBERED_TIMEOUTS = 64 diff --git a/src/main/codex/codex-app-server-record-prefix.ts b/src/shared/json-rpc-record-prefix.ts similarity index 100% rename from src/main/codex/codex-app-server-record-prefix.ts rename to src/shared/json-rpc-record-prefix.ts From 9c58983e39922358fca2565c180f6866b4991495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:14 -0700 Subject: [PATCH 10/15] Answer every agent request after an ACP cancel A cancel that lands before a permission handler starts now still runs the permission path, so the agent gets the `cancelled` outcome rather than a request-cancelled error. A handler that ignores the abort no longer leaves the agent waiting: once the abort has run through, any request still unanswered gets request-cancelled. Handlers that answer on abort keep their own reply. Also renames a lint-rejected helper parameter, replaces a Reflect.apply in a test, and stops the permission diagnostic from firing with an empty list. --- src/main/acp/acp-incoming-requests.ts | 27 +++++++--- src/main/acp/acp-json-rpc-peer.ts | 3 +- src/main/acp/acp-permission-requests.test.ts | 54 +++++++++++++++++--- src/main/acp/acp-permission-requests.ts | 11 ++-- src/main/acp/acp-session-lifecycle.test.ts | 23 ++++++--- src/main/acp/acp-session-runtime.ts | 2 + 6 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 92e50219e515..6b8d21124eb2 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,13 +28,25 @@ export class AcpIncomingRequests { this.open.clear() } - // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if + // it is still silent once the abort has run through (the next event-loop turn). cancel(): void { - for (const request of this.open.values()) { - if (!request.cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } + const cancelled = [...this.open].filter(([, request]) => !request.cancelled) + for (const [, request] of cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } + if (cancelled.length > 0) { + setImmediate(() => { + for (const [id, request] of cancelled) { + if (this.open.get(id) === request) { + this.open.delete(id) + request.closed = true + request.abandon() + void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) + } + } + }) } } @@ -62,7 +74,8 @@ export class AcpIncomingRequests { void Promise.race([ abandoned, Promise.resolve().then(() => { - if (controller.signal.aborted) { + // A cancelled request still reaches its handler, so a permission can answer `cancelled`. + if (request.closed) { throw controller.signal.reason } if (!this.handler) { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 2a2cb867b08c..0cf0b1fab377 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,7 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). + // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, + // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index f51a7429853c..d2f15611725b 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { AcpPermissionHandler } from './acp-permission-requests' import type { RequestPermissionRequest } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -66,11 +67,11 @@ describe('ACP permission requests', () => { options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] }) expect(diagnostics).toEqual([ - 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + 'Delivered ACP permission request without unreadable fields: options.0.name, options.1, toolCall.content' ]) }) - it.each([ + it.each<[string, AcpPermissionHandler, string]>([ [ 'the handler throws', () => { @@ -78,12 +79,14 @@ describe('ACP permission requests', () => { }, 'handler failed: Error: renderer gone' ], - ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + // JSON.parse yields an untyped value, so a deliberately malformed reply needs no assertion. + [ + 'the handler answers nonsense', + () => JSON.parse('{"outcome":"yes"}'), + 'invalid handler response' + ] ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { - // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. - const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => - Reflect.apply(onPermission, undefined, [request, context]) - const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + const { agent, runtime, diagnostics } = fixture({ onPermission }) await runtime.start(startOptions) void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) expect( @@ -96,6 +99,43 @@ describe('ACP permission requests', () => { expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) }) + it('answers cancelled when the cancel lands before the permission handler starts', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => new Promise(() => {})) + const { agent, runtime } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + runtime.subscribe(() => void runtime.cancel()) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => { + if (chunk.includes('"id":5')) { + resolve(JSON.parse(chunk)) + } + }) + }) + // One chunk: the permission, then an update whose listener cancels before the handler runs. + agent.stdout.write( + [ + { + jsonrpc: '2.0', + id: 5, + method: 'session/request_permission', + params: { sessionId: 'session-1', toolCall, options: [allow] } + }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + expect(await answer).toMatchObject({ id: 5, result: { outcome: { outcome: 'cancelled' } } }) + expect(asked).not.toHaveBeenCalled() + }) + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { const asked = deferred() const { agent, runtime } = fixture({ diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index c39f4a4c9022..4c4aa0b8f025 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -24,14 +24,14 @@ const optionRoutingSchema = z.looseObject({ optionId: z.string() }) // Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. function readableFields( - shape: Record, + knownFields: Record, value: Record, path: string, dropped: string[] ): Record { const kept: Record = {} for (const [key, field] of Object.entries(value)) { - if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + if (!Object.hasOwn(knownFields, key) || knownFields[key].safeParse(field).success) { kept[key] = field } else { dropped.push(`${path}${key}`) @@ -61,6 +61,9 @@ export function readAcpPermissionRequest( dropped.push(`options.${index}`) return [] } + if (typeof ids.data.name !== 'string') { + dropped.push(`options.${index}.name`) + } const named = { ...ids.data, name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId @@ -83,7 +86,9 @@ export function readAcpPermissionRequest( if (!request.success) { return null } - diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + if (dropped.length > 0) { + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + } return request.data } diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 092ed11e186c..ec36afe471d5 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,16 +247,19 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => - new Promise((resolve, reject) => - context.signal.addEventListener('abort', () => - method === '_vendor/plan' - ? resolve({ outcome: 'abandoned' }) - : reject(new Error('stop')) - ) - ) + new Promise((resolve, reject) => { + // '_vendor/silent' ignores the abort: the runtime still answers the agent. + if (method !== '_vendor/silent') { + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + } + }) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) @@ -265,11 +268,15 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) + const silent = agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) + expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 7d185c108c4d..718fd2c716e4 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,6 +52,8 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler + /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with + * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 88490e58cf3aefd9276083912c9a0e3746779332 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:04:37 -0700 Subject: [PATCH 11/15] Let each ACP request handler own its answer after a cancel Removes the next-event-loop-turn fallback that answered request-cancelled for any handler still silent after a cancel. It raced answers that were still being saved (an approval mid-journal-write reached the agent as an error) and made the outcome depend on event-loop timing. The handler that owns an agent request now always sends its answer, or throws for request-cancelled; a request it never answers ends when the connection closes. A permission whose handler had not started still answers `cancelled`. --- src/main/acp/acp-incoming-requests.ts | 25 ++++-------- src/main/acp/acp-json-rpc-peer.ts | 4 +- src/main/acp/acp-session-lifecycle.test.ts | 45 ++++++++++++++++++++-- src/main/acp/acp-session-runtime.ts | 5 ++- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 6b8d21124eb2..25c473f6bb59 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,25 +28,14 @@ export class AcpIncomingRequests { this.open.clear() } - // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if - // it is still silent once the abort has run through (the next event-loop turn). + // Each handler answers its own request (a permission answers `cancelled`); -32800 only if it + // throws. The runtime never answers for a live handler: an answer still being saved must win. cancel(): void { - const cancelled = [...this.open].filter(([, request]) => !request.cancelled) - for (const [, request] of cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } - if (cancelled.length > 0) { - setImmediate(() => { - for (const [id, request] of cancelled) { - if (this.open.get(id) === request) { - this.open.delete(id) - request.closed = true - request.abandon() - void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) - } - } - }) + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } } } diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0cf0b1fab377..b4d57e246cd5 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,8 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, - // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. + // Void means handled; unsupported methods must throw AcpRpcError(-32601). The handler owns its + // request: once the signal aborts it still answers, or throws (-32800); unanswered ends at close(). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec36afe471d5..7230870a8c02 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' @@ -247,11 +248,11 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it throws', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => new Promise((resolve, reject) => { - // '_vendor/silent' ignores the abort: the runtime still answers the agent. + // '_vendor/silent' ignores the abort: the runtime never answers for it. if (method !== '_vendor/silent') { context.signal.addEventListener('abort', () => method === '_vendor/plan' @@ -268,15 +269,51 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) - const silent = agent.request('silent', '_vendor/silent', {}) + void agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) - expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending await tick() expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.some((frame) => frame.id === 'silent')).toBe(false) + }) + + it('keeps a handler answer that finishes saving after the cancel', async () => { + // A real I/O hop, the shape of a journal write the handler commits before replying. + const save = (): Promise => readFile(import.meta.filename).then(() => undefined) + const userAnswer = deferred() + const { agent, runtime } = fixture({ + onRequest: (method, _params, context) => + new Promise((resolve) => { + if (method === '_vendor/plan') { + // The user already approved; the save started before the stop and replies after it. + void userAnswer.promise.then(save).then(() => resolve({ outcome: 'approved' })) + } else { + context.signal.addEventListener( + 'abort', + () => void save().then(() => resolve({ outcome: 'abandoned' })) + ) + } + }) + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const plan = agent.request('plan', '_vendor/plan', {}) + const question = agent.request('question', '_vendor/question', {}) + await tick() + userAnswer.resolve() + await runtime.cancel() + expect(await plan).toMatchObject({ result: { outcome: 'approved' } }) + expect(await question).toMatchObject({ result: { outcome: 'abandoned' } }) + await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.filter((frame) => frame.id === 'question')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 718fd2c716e4..1c668fd49bb9 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,8 +52,9 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with - * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ + /** Agent requests other than permissions. The handler owns its request: once `context.signal` + * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw + * (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 7aa40a99e5e0d0e2a03508767b22bd6071c817c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:26:45 -0700 Subject: [PATCH 12/15] Register the ACP schema verify step in the PR preflight phase test --- config/scripts/pr-preflight-gates.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 4be1aa2b740c..7ba8a881d8f3 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -200,6 +200,7 @@ it('pins every foreground and background step to its selected phase', () => { ['Check Node runtime pin', staticPhase], ['Boot orcad and round-trip a terminal', staticPhase], ['Verify the generated RPC params catalog', staticPhase], + ['Verify the generated ACP protocol schema', staticPhase], ['Verify bundled skill guides', staticPhase], ['Verify skill freshness manifest', staticPhase], ['Verify localization coverage', staticPhase], From 76a91abf6c2c43be69160416790e8e73401a4293 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:33:53 -0700 Subject: [PATCH 13/15] feat(acp): a steer's cancel asks once and never ends the agent The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle, then close the connection, which ends the agent. A steer used it too, so a slow agent lost its process just because the person added a message. requestSteerCancel() now sends session/cancel once per prompt, cancels the agent's open requests and answers later permissions cancelled, and never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel() stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel paths move into acp-prompt-cancel.ts over one cancel channel. --- src/main/acp/acp-prompt-cancel.ts | 54 ++++++++++++++++++++--- src/main/acp/acp-session-runtime.test.ts | 56 ++++++++++++++++++++++++ src/main/acp/acp-session-runtime.ts | 54 +++++++++++++---------- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts index 5208c6df1465..206b1b3fa77e 100644 --- a/src/main/acp/acp-prompt-cancel.ts +++ b/src/main/acp/acp-prompt-cancel.ts @@ -7,15 +7,36 @@ export type ActivePrompt = { cancelPromise?: Promise } +/** How a cancel reaches the agent: the `session/cancel` write and the connection it rides on. */ +export type AcpCancelChannel = { + send: () => Promise + /** Cancels the agent's open requests (permissions, vendor hooks) so each gets its answer. */ + cancelIncomingRequests: () => void + close: (error: Error) => void + closed: () => boolean + timeoutMs: number +} + +/** A Stop's cancel. Sends `session/cancel` whenever the session runs: the agent may be in a turn it + * began itself. With Orca's prompt running, also waits (bounded) for that prompt to settle, and + * past the bound closes the connection, which ends the agent. Repeated asks share one wait. */ +export function cancelAcpPromptForStop( + active: ActivePrompt | undefined, + channel: AcpCancelChannel +): Promise { + channel.cancelIncomingRequests() + if (!active) { + return channel.send() + } + active.cancelling = true + active.cancelPromise ??= confirmAcpPromptCancel(active, channel) + return active.cancelPromise +} + /** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ -export async function confirmAcpPromptCancel( +async function confirmAcpPromptCancel( active: ActivePrompt, - connection: { - send: () => Promise - close: (error: Error) => void - closed: () => boolean - timeoutMs: number - } + connection: AcpCancelChannel ): Promise { let timer: ReturnType | undefined const unconfirmed = new Promise((_resolve, reject) => { @@ -44,3 +65,22 @@ export async function confirmAcpPromptCancel( clearTimeout(timer) } } + +/** A steer's cancel: asks the agent once to end Orca's running prompt so the steer can follow it, + * and resolves when the notification is written. Never bounded and never closes: the prompt's own + * reply ends it, and a later Stop still bounds. With no prompt of Orca's there is nothing to end. */ +export function requestAcpSteerCancel( + active: ActivePrompt | undefined, + channel: Pick +): Promise { + if (!active || active.cancelling) { + return Promise.resolve() + } + active.cancelling = true + channel.cancelIncomingRequests() + return channel.send().catch((error: unknown) => { + // Only a cancel that reached the agent counts; a failed write may be asked again. + active.cancelling = false + throw error + }) +} diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index b89e0b58059f..210fbe09fe69 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -421,4 +421,60 @@ describe('ACP session runtime', () => { await Promise.all([rejectedPrompt, cancelled]) await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) }) + + describe("a steer's cancel", () => { + const cancels = (agent: AcpScriptedAgent) => + agent.frames.filter((frame) => frame.method === 'session/cancel') + + it("asks once, never times out or closes, and the prompt's own reply ends it", async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + const promptFrame = deferred[0]>() + agent.on('session/prompt', (frame) => promptFrame.resolve(frame)) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const frame = await promptFrame.promise + + await runtime.requestSteerCancel() + await runtime.requestSteerCancel() + await vi.advanceTimersByTimeAsync(1_000) + + expect(cancels(agent)).toHaveLength(1) + // The agent's late permission is answered cancelled while its prompt winds down. + expect(await agent.request('late', 'session/request_permission', permission)).toMatchObject({ + result: { outcome: { outcome: 'cancelled' } } + }) + agent.reply(frame, { stopReason: 'cancelled' }) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + // The connection is still open, so the steer's own prompt follows. + agent.on('session/prompt', (next) => agent.reply(next, { stopReason: 'end_turn' })) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) + }) + + it('leaves a later Stop bounded, closing the connection when the prompt never settles', async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) + + await runtime.requestSteerCancel() + const stopped = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(100) + + await Promise.all([rejectedPrompt, stopped]) + expect(cancels(agent)).toHaveLength(2) + await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) + }) + + it("writes nothing with no prompt of Orca's running", async () => { + const { runtime, agent } = fixture() + await expect(runtime.requestSteerCancel()).resolves.toBeUndefined() + await runtime.start(startOptions) + await runtime.requestSteerCancel() + await tick() + expect(cancels(agent)).toHaveLength(0) + }) + }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 1c668fd49bb9..cf10c198586f 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -14,7 +14,12 @@ import { type AcpPermissionHandler } from './acp-permission-requests' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' -import { confirmAcpPromptCancel, type ActivePrompt } from './acp-prompt-cancel' +import { + cancelAcpPromptForStop, + requestAcpSteerCancel, + type AcpCancelChannel, + type ActivePrompt +} from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, @@ -173,29 +178,18 @@ export class AcpSessionRuntime { return active.response } - /** Sends `session/cancel` whenever the session runs: the agent may be in a turn it began itself. - * With Orca's prompt running, also waits (bounded) for that prompt to settle. */ + /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. */ cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { - if (!this.started) { - return Promise.reject(new Error('ACP session has not started')) - } - const params: CancelNotification = { - sessionId: this.started.sessionId, - ...withMeta(options.meta) - } - this.peer.cancelIncomingRequests() - const active = this.activePrompt - if (!active) { - return this.peer.notify('session/cancel', params) - } - active.cancelling = true - active.cancelPromise ??= confirmAcpPromptCancel(active, { - send: () => this.peer.notify('session/cancel', params), - close: (error) => this.peer.close(error), - closed: () => this.peer.closed, - timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - }) - return active.cancelPromise + const channel = this.cancelChannel(options.meta) + return channel + ? cancelAcpPromptForStop(this.activePrompt, channel) + : Promise.reject(new Error('ACP session has not started')) + } + + /** A steer's cancel (`requestAcpSteerCancel`): never bounded and never closes. */ + requestSteerCancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { + const channel = this.cancelChannel(options.meta) + return channel ? requestAcpSteerCancel(this.activePrompt, channel) : Promise.resolve() } async setMode(modeId: string, meta?: Meta): Promise { @@ -240,6 +234,20 @@ export class AcpSessionRuntime { this.listeners.clear() } + private cancelChannel(meta: CancelNotification['_meta']): AcpCancelChannel | null { + if (!this.started) { + return null + } + const params: CancelNotification = { sessionId: this.started.sessionId, ...withMeta(meta) } + return { + send: () => this.peer.notify('session/cancel', params), + cancelIncomingRequests: () => this.peer.cancelIncomingRequests(), + close: (error) => this.peer.close(error), + closed: () => this.peer.closed, + timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) + } + } + private sessionId(): string { if (!this.started) { throw new Error('ACP session has not started') From 58bb525f8282c89db927e38d5d4d70940b6851d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:41:22 -0700 Subject: [PATCH 14/15] fix(acp): a repeated steer shares the cancel in flight; say what the caller owns Per review: a second steer before the first write lands returns that write instead of resolving early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that fails instead must not take the steer until the caller rebuilds the session; the Stop's says a prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives the runtime a handler that would allow: the open permission's signal aborts and the late one never reaches it. --- src/main/acp/acp-prompt-cancel.ts | 14 +++++++++++--- src/main/acp/acp-session-runtime.test.ts | 24 ++++++++++++++++++++---- src/main/acp/acp-session-runtime.ts | 3 ++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts index 206b1b3fa77e..8938d10aea3d 100644 --- a/src/main/acp/acp-prompt-cancel.ts +++ b/src/main/acp/acp-prompt-cancel.ts @@ -5,6 +5,7 @@ export type ActivePrompt = { response: Promise cancelling: boolean cancelPromise?: Promise + steerCancel?: Promise } /** How a cancel reaches the agent: the `session/cancel` write and the connection it rides on. */ @@ -68,19 +69,26 @@ async function confirmAcpPromptCancel( /** A steer's cancel: asks the agent once to end Orca's running prompt so the steer can follow it, * and resolves when the notification is written. Never bounded and never closes: the prompt's own - * reply ends it, and a later Stop still bounds. With no prompt of Orca's there is nothing to end. */ + * reply ends it, however long that takes, and a later Stop still bounds. A prompt that fails + * instead leaves a session the steer must not be sent into until the caller rebuilds it. With no + * prompt of Orca's there is nothing to end; a Stop already cancelling it owns that cancel. */ export function requestAcpSteerCancel( active: ActivePrompt | undefined, channel: Pick ): Promise { - if (!active || active.cancelling) { + if (!active) { return Promise.resolve() } + if (active.steerCancel || active.cancelling) { + return active.steerCancel ?? Promise.resolve() + } active.cancelling = true channel.cancelIncomingRequests() - return channel.send().catch((error: unknown) => { + active.steerCancel = channel.send().catch((error: unknown) => { // Only a cancel that reached the agent counts; a failed write may be asked again. active.cancelling = false + active.steerCancel = undefined throw error }) + return active.steerCancel } diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 210fbe09fe69..ef5369e8589f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -428,22 +428,38 @@ describe('ACP session runtime', () => { it("asks once, never times out or closes, and the prompt's own reply ends it", async () => { vi.useFakeTimers() - const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + const signals: AbortSignal[] = [] + const { runtime, agent } = fixture( + {}, + { + cancelTimeoutMs: 100, + // Would allow every request it is asked; a steer's cancel keeps late ones from it. + onPermission: (_request, context) => { + signals.push(context.signal) + return new Promise(() => {}) + } + } + ) const promptFrame = deferred[0]>() agent.on('session/prompt', (frame) => promptFrame.resolve(frame)) await runtime.start(startOptions) const prompt = runtime.prompt([...textPrompt]) const frame = await promptFrame.promise + const open = agent.request('open', 'session/request_permission', permission) + await vi.waitFor(() => expect(signals).toHaveLength(1)) - await runtime.requestSteerCancel() - await runtime.requestSteerCancel() + const steer = runtime.requestSteerCancel() + expect(runtime.requestSteerCancel()).toBe(steer) + await steer await vi.advanceTimersByTimeAsync(1_000) expect(cancels(agent)).toHaveLength(1) - // The agent's late permission is answered cancelled while its prompt winds down. + expect(signals[0]?.aborted).toBe(true) + expect(await open).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) expect(await agent.request('late', 'session/request_permission', permission)).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(signals).toHaveLength(1) agent.reply(frame, { stopReason: 'cancelled' }) expect(await prompt).toEqual({ stopReason: 'cancelled' }) // The connection is still open, so the steer's own prompt follows. diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index cf10c198586f..eb6ceb821947 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -178,7 +178,8 @@ export class AcpSessionRuntime { return active.response } - /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. */ + /** A Stop's cancel (`cancelAcpPromptForStop`): bounded, and past the bound it closes. A prompt + * that settles in time leaves the agent running; the Stop's owner ends its process. */ cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { const channel = this.cancelChannel(options.meta) return channel From 320f347e59f3ccc18e5f632f839a026ce935105e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:53:16 -0700 Subject: [PATCH 15/15] test(ratchet): require src/main/acp now that this PR lands it --- .../check-runtime-electron-ratchet.mjs | 6 +----- .../check-runtime-electron-ratchet.test.mjs | 20 +++++-------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 3e04b8c46f25..71a8e3a8aef7 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,17 +49,13 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true } + { directory: ['src', 'main', 'acp'] } ] export function collectStructuredChatEntryPoints(root = ROOT) { return STRUCTURED_CHAT_LANES.flatMap((lane) => { const directory = path.join(root, ...lane.directory) if (!existsSync(directory)) { - if (lane.mayBeAbsent) { - return [] - } throw new Error( `[runtime-electron-ratchet] ${lane.directory.join('/')} is missing. If it moved, update STRUCTURED_CHAT_LANES; otherwise the gate would silently check nothing there.` ) diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index a8985f0c014f..2e46362390eb 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -9,8 +9,7 @@ import { defaultEntryPoints, diffAgainstBaseline, main, - readBaseline, - STRUCTURED_CHAT_LANES + readBaseline } from './check-runtime-electron-ratchet.mjs' describe('structured chat coverage', () => { @@ -32,13 +31,14 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ may be absent until it lands. + // Every lane that must exist. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', + 'src/main/acp/acp-structured-session-adapter.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -90,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) } ) @@ -128,16 +128,6 @@ describe('the default entry points', () => { expect(entries.some((file) => file.startsWith(lane))).toBe(true) } }) - - // Retires the temporary flag: the PR that adds acp/ must make it required. - it('lets only directories that have not landed yet be absent', () => { - for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { - expect( - existsSync(path.join(process.cwd(), ...lane.directory)), - lane.directory.join('/') - ).toBe(false) - } - }) }) // Why `main`: it is what `pnpm lint` and CI run, so these fail if its entry list drops the lanes.