Files
orca/config/scripts/pr-preflight-gates.test.mjs
T
Brennan Benson 020cebeff6 Add standalone Agent Client Protocol client layer (#24990)
* Add standalone ACP protocol client and session runtime

* Protect ACP transport teardown from late stream errors

* Retire incoming ACP request ids before publishing responses

* Narrow ACP configuration requests and transport message types

* Remove redundant ACP request handler return unions

* Keep ACP waits caller-owned and preserve protocol extensions

* Preserve open ACP decisions through prompt completion

* 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.

* 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.

* 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.

* 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`.

* Register the ACP schema verify step in the PR preflight phase test

* 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.

* 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.

* test(ratchet): require src/main/acp now that this PR lands it
2026-10-05 22:25:40 -07:00

284 lines
12 KiB
JavaScript

import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const preflight = workflow.jobs.preflight
const steps = preflight.steps
const compiler = steps.find((step) => step.run === 'pnpm run typecheck')
const plan = steps.find((step) => step.id === 'unit-plan')
it('shares one setup and runs the unchanged compiler after static checks finish', () => {
expect(workflow.jobs.static_analysis).toBeUndefined()
expect(workflow.jobs.typecheck).toBeUndefined()
expect(preflight.needs).toEqual(['code_paths'])
expect(preflight['runs-on']).toBe('ubuntu-24.04-arm')
const installs = steps.filter(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installs).toHaveLength(2)
for (const install of installs) {
expect(install.with['native-runtime']).toBe('node')
expect(install.with['node-version']).toBe('24')
expect(install.with['persist-native-cache']).not.toBe('false')
}
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
expect(compiler.background).toBeUndefined()
expect(plan.background).toBe(true)
expect(plan.run.trim().split('\n')).toEqual([
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi',
'node config/scripts/ci-unit-plan.mjs'
])
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
expect(steps.indexOf(compiler)).toBeGreaterThan(
steps.findIndex((step) => step.wait?.includes('localization-extraction'))
)
})
it('requires physical preflight success before publishing shards and admitting consumers', () => {
const join = steps.findIndex((step) => step.wait === 'unit-plan')
const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7')
expect(join).toBeGreaterThan(steps.indexOf(compiler))
expect(upload).toBeGreaterThan(join)
expect(steps[upload]['continue-on-error']).toBeUndefined()
expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}')
expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
for (const job of ['test', 'package', 'package_windows']) {
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight'])
}
for (const result of ['success', 'failure', 'cancelled', 'skipped']) {
const admitted = runInNewContext(workflow.jobs.test.if, {
cancelled: () => false,
needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } }
})
expect(admitted, result).toBe(result === 'success')
}
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}')
expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe(
"${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}"
)
expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"')
expect(workflow.jobs.verify.needs).toContain('preflight')
expect(workflow.jobs.verify.needs).not.toContain('typecheck')
})
it.each([
{ name: 'both phases succeed', phases: ['true', 'true'], result: 'success', admitted: true },
{ name: 'static-only succeeds', phases: ['true', 'false'], result: 'success', admitted: true },
{ name: 'type-only succeeds', phases: ['false', 'true'], result: 'success', admitted: true },
{ name: 'static-only fails', phases: ['true', 'false'], result: 'failure', admitted: false },
{ name: 'type-only fails', phases: ['false', 'true'], result: 'failure', admitted: false },
{ name: 'both phases fail', phases: ['true', 'true'], result: 'failure', admitted: false },
{ name: 'preflight is cancelled', result: 'cancelled', admitted: false },
{ name: 'preflight is unfinished', result: undefined, admitted: false },
{ name: 'both phases unselected', phases: ['false', 'false'], result: 'skipped', admitted: true },
{ name: 'required static skips', phases: ['true', 'false'], result: 'skipped', admitted: false },
{
name: 'required compiler skips',
phases: ['false', 'true'],
result: 'skipped',
admitted: false
},
{ name: 'both required phases skip', result: 'skipped', admitted: false },
{
name: 'unselected preflight fails',
phases: ['false', 'false'],
result: 'failure',
admitted: false
},
{
name: 'static selection missing',
phases: [undefined, 'false'],
result: 'skipped',
admitted: false
},
{
name: 'compiler selection missing',
phases: ['false', undefined],
result: 'skipped',
admitted: false
},
{ name: 'selection unknown', phases: ['', 'false'], result: 'skipped', admitted: false },
{
name: 'docs have no route',
phases: ['false', 'false'],
result: 'skipped',
route: 'false',
admitted: false
},
{ name: 'source has no route', result: 'success', route: 'false', admitted: false },
{ name: 'route missing', result: 'success', route: '', admitted: false },
{ name: 'path detection fails', result: 'success', pathsResult: 'failure', admitted: false },
{ name: 'path detection skips', result: 'skipped', pathsResult: 'skipped', admitted: false },
{ name: 'workflow cancelled after success', result: 'success', cancelled: true, admitted: false },
{
name: 'workflow cancelled with unselected phases',
phases: ['false', 'false'],
result: 'skipped',
cancelled: true,
admitted: false
}
])('admits advisory E2E only after eligible preflight: $name', (scenario) => {
const [static_analysis, typecheck] = scenario.phases ?? ['true', 'true']
expect(workflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight'])
const admitted = runInNewContext(workflow.jobs.e2e.if, {
cancelled: () => scenario.cancelled ?? false,
needs: {
code_paths: {
result: scenario.pathsResult ?? 'success',
outputs: { static_analysis, typecheck, e2e_should_run: scenario.route ?? 'true' }
},
preflight: { result: scenario.result }
}
})
expect(admitted).toBe(scenario.admitted)
})
it.each(['true', 'false'])(
'preserves advisory E2E on ready-for-review only with proven required-check reuse: %s',
(reused) => {
const scope = classifyPrJobs(['src/main/runtime/orca-runtime.ts'])
const context = {
steps: {
readiness: { outputs: { reused } },
filter: {
outputs: Object.fromEntries(
Object.entries(scope).map(([key, value]) => [key, String(value)])
)
},
e2e_filter: { outputs: { should_run: 'true' } }
}
}
const outputs = Object.fromEntries(
['static_analysis', 'typecheck', 'e2e_should_run'].map((name) => [
name,
String(runInNewContext(workflow.jobs.code_paths.outputs[name].slice(3, -2), context))
])
)
expect(outputs).toEqual({
static_analysis: reused === 'true' ? 'false' : 'true',
typecheck: reused === 'true' ? 'false' : 'true',
e2e_should_run: 'true'
})
const needs = { code_paths: { result: 'success', outputs }, preflight: { result: 'skipped' } }
expect(runInNewContext(preflight.if, { needs })).toBe(reused !== 'true')
expect(runInNewContext(workflow.jobs.e2e.if, { needs, cancelled: () => false })).toBe(
reused === 'true'
)
expect(workflow.jobs.verify.needs).not.toContain('e2e')
}
)
it('pins every foreground and background step to its selected phase', () => {
const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'"
const typePhase = "needs.code_paths.outputs.typecheck == 'true'"
const foreground = steps.filter(
(step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '')
)
expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([
['Reject low-evidence patterns', staticPhase],
['Enforce type-aware code-quality baseline', staticPhase],
[
'./.github/actions/install-mobile-dependencies',
`${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'`
],
['Enforce React Doctor on changed lines', staticPhase],
['Check Zustand selector fan-out budget', staticPhase],
['Check reliability gate manifest', staticPhase],
['Enforce dead design-system classes', staticPhase],
['Check VM runtime rollback compatibility', staticPhase],
['Enforce max-lines ratchet', staticPhase],
['Enforce ts-nocheck ratchet', staticPhase],
['Enforce runtime Electron-import ratchet', staticPhase],
['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],
['Guard against project-owned .d.ts in preload/shared', staticPhase],
['Check feature wall asset budget', staticPhase],
['Verify macOS entitlements', staticPhase],
['Cache TypeScript incremental state', typePhase],
['pnpm run typecheck', typePhase],
['actions/upload-artifact@v7', typePhase]
])
expect(
steps
.filter((step) => step.background)
.map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED])
).toEqual([
['root-lint', `\${{ ${staticPhase} }}`],
['native-code-quality', `\${{ ${staticPhase} }}`],
['changed-code-quality', `\${{ ${staticPhase} }}`],
['localization-extraction', `\${{ ${staticPhase} }}`],
['localization-catalogs', `\${{ ${staticPhase} }}`],
['unit-plan', `\${{ ${typePhase} }}`]
])
})
it.each([
{ changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false },
{
changed: ['mobile/src/App.tsx'],
static_analysis: true,
typecheck: false,
mobile_dependencies: true
},
{
changed: ['cloud/package.json'],
static_analysis: false,
typecheck: false,
mobile_dependencies: false
},
{
changed: ['src/main/index.ts'],
static_analysis: true,
typecheck: true,
mobile_dependencies: false
},
{
changed: ['mobile/src/App.tsx', 'src/main/index.ts'],
static_analysis: true,
typecheck: true,
mobile_dependencies: true
},
{ changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true }
])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => {
const scope = classifyPrJobs(changed)
expect({
static_analysis: scope.static_analysis,
typecheck: scope.typecheck,
mobile_dependencies: scope.mobile_dependencies
}).toEqual(expected)
const needs = {
code_paths: {
outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)]))
}
}
expect(runInNewContext(preflight.if, { needs })).toBe(
expected.static_analysis || expected.typecheck
)
expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck)
expect(scope.test).toBe(expected.typecheck)
})
it('registers successful no-op background work when a phase is unselected or already failed', () => {
for (const step of steps.filter((step) => step.background)) {
expect(step.if).toBe('!cancelled()')
expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.')
expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}")
expect(step.run.split('\n')[0]).toBe(
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi'
)
}
})