Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f27344a
Add standalone ACP protocol client and session runtime
brennanb2025 Oct 3, 2026
d09c278
Protect ACP transport teardown from late stream errors
brennanb2025 Oct 3, 2026
c3aa650
Retire incoming ACP request ids before publishing responses
brennanb2025 Oct 3, 2026
cdad7d2
Narrow ACP configuration requests and transport message types
brennanb2025 Oct 3, 2026
2ae4ab5
Remove redundant ACP request handler return unions
brennanb2025 Oct 3, 2026
e30b885
Keep ACP waits caller-owned and preserve protocol extensions
brennanb2025 Oct 3, 2026
e8d37ec
Preserve open ACP decisions through prompt completion
brennanb2025 Oct 3, 2026
9d7c542
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-d1-p…
brennanb2025 Oct 5, 2026
f3f2ccc
Generate open ACP enums and check the generated schema offline
brennanb2025 Oct 5, 2026
0db8b0c
Land the ACP runtime contract the agent adapters use
brennanb2025 Oct 5, 2026
9c58983
Answer every agent request after an ACP cancel
brennanb2025 Oct 5, 2026
88490e5
Let each ACP request handler own its answer after a cancel
brennanb2025 Oct 5, 2026
7aa40a9
Register the ACP schema verify step in the PR preflight phase test
brennanb2025 Oct 5, 2026
3b7d680
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-d1-p…
brennanb2025 Oct 5, 2026
76a91ab
feat(acp): a steer's cancel asks once and never ends the agent
brennanb2025 Oct 5, 2026
58bb525
fix(acp): a repeated steer shares the cancel in flight; say what the …
brennanb2025 Oct 5, 2026
75cf269
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-d1-p…
brennanb2025 Oct 6, 2026
320f347
test(ratchet): require src/main/acp now that this PR lands it
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
294 changes: 294 additions & 0 deletions config/scripts/acp/generate-protocol.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
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 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 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)
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`, inputs.schema),
download(`${repository}/releases/download/${legacyRelease}/schema.unstable.json`, inputs.legacy),
download(
`https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`,
inputs.license
)
])
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',
'SessionModelState',
'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)

// 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()'
}
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
}

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<string & {}>((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<typeof ${name}Schema>\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 })
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')) && 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`)
6 changes: 1 addition & 5 deletions config/scripts/check-runtime-electron-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
)
Expand Down
20 changes: 5 additions & 15 deletions config/scripts/check-runtime-electron-ratchet.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,8 +9,7 @@ import {
defaultEntryPoints,
diffAgainstBaseline,
main,
readBaseline,
STRUCTURED_CHAT_LANES
readBaseline
} from './check-runtime-electron-ratchet.mjs'

describe('structured chat coverage', () => {
Expand All @@ -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 {}'
}

Expand Down Expand Up @@ -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)
}
)

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading