mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-launch-host-prompt-delivery
This commit is contained in:
@@ -42,10 +42,16 @@ outputs:
|
||||
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
native-cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
value: ${{ steps.native-runtime.outputs.cache-scope }}
|
||||
native-cache-key:
|
||||
description: Exact native cache key requested after installation.
|
||||
value: ${{ steps.native-runtime.outputs.cache-key }}
|
||||
native-cache-path:
|
||||
description: Native build directories covered by the requested cache.
|
||||
value: ${{ steps.native-runtime.outputs.cache-path }}
|
||||
native-cache-hit:
|
||||
description: Whether the compiled native module cache was restored.
|
||||
value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
value: ${{ steps.native-runtime.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -203,68 +209,11 @@ runs:
|
||||
path: ${{ steps.electron-package-cache.outputs.cache-root }}
|
||||
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
|
||||
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
|
||||
# Native addons built on a newer Linux image can require glibc symbols
|
||||
# missing from an older runner/container. ImageOS distinguishes hosted
|
||||
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
echo "scope=$scope" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore compiled native modules
|
||||
id: native-cache-restore
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
id: native-cache-restore-only
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }}
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable; Electron rebuild uses its own node-gyp API.
|
||||
- name: Use external node-gyp
|
||||
if: runner.os == 'Linux' && inputs.native-runtime == 'node'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NATIVE_CACHE_HIT: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
run: |
|
||||
# A cache hit can contain unusable addons; probe before skipping the rebuild toolchain.
|
||||
if [ "$NATIVE_RUNTIME" = node ] && [ "$NATIVE_CACHE_HIT" = true ] &&
|
||||
node config/scripts/ensure-native-runtime.mjs --check-only; then
|
||||
exit 0
|
||||
fi
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare native runtime
|
||||
id: native-runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
uses: ./.github/actions/prepare-native-runtime
|
||||
with:
|
||||
native-runtime: ${{ inputs.native-runtime }}
|
||||
node-version: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
persist-native-cache: ${{ inputs.persist-native-cache }}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Prepare native runtime
|
||||
description: Restore and probe source-built native modules for the requested runtime.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
description: Native runtime to prepare (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
node-version:
|
||||
description: Resolved Node.js version provided by setup-node.
|
||||
required: true
|
||||
persist-native-cache:
|
||||
description: Save native modules at job end unless a later step changes their ABI.
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
outputs:
|
||||
cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
cache-key:
|
||||
description: Exact key requested for this runtime, independent of whether restoration succeeds.
|
||||
value: ${{ steps.native-cache-scope.outputs.key }}
|
||||
cache-path:
|
||||
description: Native build directories covered by the requested cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.path }}
|
||||
cache-hit:
|
||||
description: Whether compiled native modules were restored under the exact requested key.
|
||||
value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NODE_VERSION: ${{ inputs.node-version }}
|
||||
run: |
|
||||
case "$NATIVE_RUNTIME" in
|
||||
none) ;;
|
||||
node|electron) test -n "$NODE_VERSION" ;;
|
||||
*)
|
||||
echo "::error::native-runtime must be none, node, or electron"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Exact keys separate runtime, architecture and host libc; every restored addon is probed.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NODE_VERSION: ${{ inputs.node-version }}
|
||||
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js') }}
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
pnpm_version="$(pnpm --version)"
|
||||
test -n "$pnpm_version"
|
||||
printf 'scope=%s\n' "$scope" >> "$GITHUB_OUTPUT"
|
||||
printf 'key=native-modules-%s-%s-%s-%s-node%s-pnpm%s-%s\n' "$RUNNER_OS" "$scope" "$RUNNER_ARCH" "$NATIVE_RUNTIME" "$NODE_VERSION" "$pnpm_version" "$NATIVE_SOURCE_HASH" >> "$GITHUB_OUTPUT"
|
||||
cat >> "$GITHUB_OUTPUT" <<'ORCA_NATIVE_CACHE_PATHS'
|
||||
path<<ORCA_NATIVE_BUILD_DIRECTORIES
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
ORCA_NATIVE_BUILD_DIRECTORIES
|
||||
ORCA_NATIVE_CACHE_PATHS
|
||||
|
||||
- name: Restore compiled native modules
|
||||
id: native-cache-restore
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
# Post-job saving cannot resolve nested composite step outputs.
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: ${{ steps.native-cache-scope.outputs.key }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
id: native-cache-restore-only
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: ${{ steps.native-cache-scope.outputs.key }}
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable; Electron rebuild uses its own node-gyp API.
|
||||
- name: Use external node-gyp
|
||||
if: runner.os == 'Linux' && inputs.native-runtime == 'node'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NATIVE_CACHE_HIT: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
run: |
|
||||
# A cache hit can contain unusable addons; probe before skipping the rebuild toolchain.
|
||||
if [ "$NATIVE_RUNTIME" = node ] && [ "$NATIVE_CACHE_HIT" = true ] &&
|
||||
node config/scripts/ensure-native-runtime.mjs --check-only; then
|
||||
exit 0
|
||||
fi
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
paths:
|
||||
- '.github/workflows/ci-cache-warmup.yml'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-linux-package-fixture/**'
|
||||
- 'config/docker/headless-serve-shutdown/**'
|
||||
@@ -17,9 +18,21 @@ on:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- 'config/tsconfig*.json'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/node-pty-job-ownership.cjs'
|
||||
- 'config/scripts/windows-pe-machine.cjs'
|
||||
- 'config/scripts/windows-process-tree-gyp-rebuild.mjs'
|
||||
- 'config/scripts/windows-process-tree-creation-time.cjs'
|
||||
- 'config/scripts/install-electron-package-binary.mjs'
|
||||
- 'config/scripts/electron-platform-path.mjs'
|
||||
- 'config/scripts/zip-extractor-command.mjs'
|
||||
- 'src/shared/zip-extractor-command.ts'
|
||||
- 'config/scripts/shared-electron-dist-cache.mjs'
|
||||
- 'config/scripts/space-sharing-copy.mjs'
|
||||
- 'config/patches/node-pty@1.1.0.patch'
|
||||
- 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
- 'native/windows-registry/**'
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/workflows/node-server-tests.yml'
|
||||
# The pull request qualifies one platform for an unflavoured change; this is where all six
|
||||
# are re-qualified, so a platform break surfaces minutes after merge instead of next cron.
|
||||
@@ -34,6 +35,7 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/workflows/node-server-tests.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -860,21 +860,10 @@ jobs:
|
||||
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
|
||||
pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE"
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
# Runs the whole directory so a new file there cannot be left out; an empty match
|
||||
# exits 1 ("No test files found"), so this lane cannot pass while running zero tests.
|
||||
- name: Old/new client and server compatibility journeys
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
tests/e2e/cross-version-wire/release-checkout.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
|
||||
tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
@@ -1102,11 +1091,8 @@ jobs:
|
||||
if: steps.deps.outputs.native-cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }}
|
||||
path: ${{ steps.deps.outputs.native-cache-path }}
|
||||
key: ${{ steps.deps.outputs.native-cache-key }}
|
||||
|
||||
# vitest runs here directly rather than through `pnpm test`, so the addon
|
||||
# assertions only hold once install-node-dependencies has rebuilt natives.
|
||||
@@ -1206,17 +1192,11 @@ jobs:
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Restore compiled Electron native modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
native/windows-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
uses: ./.github/actions/prepare-native-runtime
|
||||
with:
|
||||
native-runtime: electron
|
||||
node-version: ${{ steps.deps.outputs.node-version }}
|
||||
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
|
||||
@@ -8,6 +8,21 @@ const readWorkflow = (name) =>
|
||||
const workflow = readWorkflow('ci-cache-warmup')
|
||||
const steps = workflow.jobs.warm.steps
|
||||
|
||||
it('seeds new native keys on main when any declared build or probe input changes', () => {
|
||||
const native = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8'))
|
||||
const hash = native.runs.steps.find((step) => step.id === 'native-cache-scope').env
|
||||
.NATIVE_SOURCE_HASH
|
||||
const files = [...hash.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
for (const file of files) {
|
||||
expect(
|
||||
workflow.on.push.paths.some((pattern) =>
|
||||
pattern.endsWith('/**') ? file.startsWith(pattern.slice(0, -2)) : file === pattern
|
||||
),
|
||||
file
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('warms the same Linux Node runtime the PR shards restore', () => {
|
||||
const arm = workflow.jobs['warm-linux-arm']
|
||||
const install = arm.steps.find(
|
||||
@@ -56,6 +71,7 @@ it('bounds warming to the required platforms and validates changes without grant
|
||||
expect(workflow.jobs.warm['timeout-minutes']).toBeLessThanOrEqual(10)
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(workflow.on.push.branches).toEqual(['main'])
|
||||
expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**')
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }])
|
||||
expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml')
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const installerPath = '.github/actions/install-node-dependencies/action.yml'
|
||||
const nativePath = '.github/actions/prepare-native-runtime/action.yml'
|
||||
const installer = parse(readFileSync(installerPath, 'utf8'))
|
||||
const native = parse(readFileSync(nativePath, 'utf8'))
|
||||
const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope')
|
||||
const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
const source = new Map(
|
||||
[...inputs, installerPath].map((file) => [file, existsSync(file) ? readFileSync(file) : null])
|
||||
)
|
||||
const paths = [
|
||||
'node_modules/.pnpm/node-pty@*/node_modules/node-pty/build',
|
||||
'native/windows-registry/build',
|
||||
'node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build'
|
||||
]
|
||||
|
||||
function sourceHash(files = source) {
|
||||
const digest = createHash('sha256')
|
||||
for (const file of inputs.toSorted()) {
|
||||
const contents = files.get(file)
|
||||
if (contents !== null) {
|
||||
digest.update(createHash('sha256').update(contents).digest())
|
||||
}
|
||||
}
|
||||
return digest.digest('hex')
|
||||
}
|
||||
|
||||
function resolveIdentity({
|
||||
runtime = 'node',
|
||||
node = 'v24.21.0',
|
||||
pnpm = '12.0.0',
|
||||
os = 'Windows',
|
||||
arch = 'X64',
|
||||
image = 'win22',
|
||||
libc = null,
|
||||
files = source
|
||||
} = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-native-identity-'))
|
||||
try {
|
||||
const output = join(directory, 'output')
|
||||
const osRelease = join(directory, 'os-release')
|
||||
if (libc) {
|
||||
writeFileSync(osRelease, `ID=${libc.id}\nVERSION_ID=${libc.version}\n`)
|
||||
}
|
||||
// Isolate the container file lookup while running the production identity commands.
|
||||
const script = `pnpm() { printf "%s\\n" "$TEST_PNPM_VERSION"; }\n${identity.run.replaceAll('/etc/os-release', '"$TEST_OS_RELEASE"')}`
|
||||
const execution = runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-o', 'pipefail', '-c', script],
|
||||
env: {
|
||||
...process.env,
|
||||
NATIVE_RUNTIME: runtime,
|
||||
NODE_VERSION: node,
|
||||
RUNNER_OS: os,
|
||||
RUNNER_ARCH: arch,
|
||||
ImageOS: image,
|
||||
NATIVE_SOURCE_HASH: sourceHash(files),
|
||||
TEST_PNPM_VERSION: pnpm,
|
||||
TEST_OS_RELEASE: osRelease,
|
||||
GITHUB_OUTPUT: output
|
||||
}
|
||||
})
|
||||
expect(execution.code, execution.stderr).toBe(0)
|
||||
const result = readFileSync(output, 'utf8')
|
||||
return {
|
||||
key: /^key=(.+)$/m.exec(result)?.[1],
|
||||
scope: /^scope=(.+)$/m.exec(result)?.[1],
|
||||
path: /path<<ORCA_NATIVE_BUILD_DIRECTORIES\n([\s\S]+?)\nORCA_NATIVE_BUILD_DIRECTORIES/.exec(
|
||||
result
|
||||
)?.[1]
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('CI native cache ownership', () => {
|
||||
it('hashes the complete native preparation policy and build/probe source closure', () => {
|
||||
expect(inputs).toEqual([
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
nativePath,
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/scripts/node-pty-job-ownership.cjs',
|
||||
'config/scripts/windows-pe-machine.cjs',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild.mjs',
|
||||
'config/scripts/windows-process-tree-creation-time.cjs',
|
||||
'config/scripts/install-electron-package-binary.mjs',
|
||||
'config/scripts/electron-platform-path.mjs',
|
||||
'config/scripts/zip-extractor-command.mjs',
|
||||
'src/shared/zip-extractor-command.ts',
|
||||
'config/scripts/shared-electron-dist-cache.mjs',
|
||||
'config/scripts/space-sharing-copy.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch',
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'native/windows-registry/binding.gyp',
|
||||
'native/windows-registry/package.json',
|
||||
'native/windows-registry/index.js'
|
||||
])
|
||||
expect(inputs).not.toContain(installerPath)
|
||||
for (const file of inputs) {
|
||||
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps a pnpm-only installer edit on the same native key and paths',
|
||||
() => {
|
||||
const changed = new Map(source)
|
||||
changed.set(
|
||||
installerPath,
|
||||
Buffer.from(
|
||||
readFileSync(installerPath, 'utf8').replace(
|
||||
"inputs.cache-pnpm-verification == 'true'",
|
||||
"inputs.cache-pnpm-verification == 'false'"
|
||||
)
|
||||
)
|
||||
)
|
||||
expect(changed.get(installerPath)).not.toEqual(source.get(installerPath))
|
||||
expect(resolveIdentity({ files: changed })).toEqual(resolveIdentity())
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32').each(inputs)(
|
||||
'changes the requested key when native input %s changes',
|
||||
(file) => {
|
||||
const changed = new Map(source)
|
||||
changed.set(
|
||||
file,
|
||||
Buffer.concat([source.get(file) ?? Buffer.alloc(0), Buffer.from('\nchanged')])
|
||||
)
|
||||
expect(resolveIdentity({ files: changed }).key).not.toBe(resolveIdentity().key)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'separates runtime, Node/pnpm versions, architecture, image and libc hosts',
|
||||
() => {
|
||||
const baseline = resolveIdentity()
|
||||
expect(baseline.path.split('\n')).toEqual(paths)
|
||||
const variants = [
|
||||
{ runtime: 'electron' },
|
||||
{ node: 'v24.22.0' },
|
||||
{ pnpm: '12.1.0' },
|
||||
{ arch: 'ARM64' },
|
||||
{ image: 'win11' },
|
||||
{ os: 'Linux', image: 'ubuntu22', libc: { id: 'ubuntu', version: '22.04' } },
|
||||
{ os: 'Linux', image: 'ubuntu22', libc: { id: 'almalinux', version: '8.10' } }
|
||||
].map(resolveIdentity)
|
||||
expect(new Set([baseline, ...variants].map(({ key }) => key)).size).toBe(variants.length + 1)
|
||||
expect(variants.at(-1).scope).toBe('almalinux-8.10')
|
||||
expect(variants.every(({ path }) => path === baseline.path)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps early input rejection before dependency installation and native restoration', () => {
|
||||
const early = installer.runs.steps.findIndex((step) => step.name === 'Validate native runtime')
|
||||
const install = installer.runs.steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const prepare = installer.runs.steps.findIndex((step) => step.id === 'native-runtime')
|
||||
expect(early).toBeLessThan(install)
|
||||
expect(install).toBeLessThan(prepare)
|
||||
expect(installer.runs.steps[early].run).toContain('none|node|electron)')
|
||||
expect(installer.runs.steps[prepare].if).toBe("inputs.native-runtime != 'none'")
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects invalid runtime and missing resolved Node before cache restoration',
|
||||
() => {
|
||||
const early = installer.runs.steps.find((step) => step.name === 'Validate native runtime')
|
||||
const validate = native.runs.steps.find((step) => step.name === 'Validate native runtime')
|
||||
for (const run of [early.run, validate.run]) {
|
||||
expect(
|
||||
runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-c', run],
|
||||
env: { ...process.env, NATIVE_RUNTIME: 'unsupported', NODE_VERSION: 'v24.21.0' },
|
||||
stdio: 'pipe'
|
||||
}).code
|
||||
).not.toBe(0)
|
||||
}
|
||||
expect(
|
||||
runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-c', validate.run],
|
||||
env: { ...process.env, NATIVE_RUNTIME: 'node', NODE_VERSION: '' },
|
||||
stdio: 'pipe'
|
||||
}).code
|
||||
).not.toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses requested identity on both restores and saves the Node ABI before Windows switches to Electron', () => {
|
||||
for (const step of native.runs.steps.filter((step) => step.uses?.startsWith('actions/cache'))) {
|
||||
expect(step.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}')
|
||||
expect(step.with.path.trim().split('\n')).toEqual(paths)
|
||||
expect(step.with['restore-keys']).toBeUndefined()
|
||||
}
|
||||
const job = parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.package_windows
|
||||
const save = job.steps.find((step) => step.name === 'Save compiled Node native modules')
|
||||
const install = job.steps.find((step) => step.id === 'deps')
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const electron = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
expect(install.with['persist-native-cache']).toBe('false')
|
||||
expect(save.with).toEqual({
|
||||
key: '${{ steps.deps.outputs.native-cache-key }}',
|
||||
path: '${{ steps.deps.outputs.native-cache-path }}'
|
||||
})
|
||||
expect(job.steps.indexOf(save)).toBeLessThan(job.steps.indexOf(build))
|
||||
expect(job.steps.indexOf(build)).toBeLessThan(job.steps.indexOf(electron))
|
||||
expect(electron.uses).toBe('./.github/actions/prepare-native-runtime')
|
||||
expect(electron.with).toEqual({
|
||||
'native-runtime': 'electron',
|
||||
'node-version': '${{ steps.deps.outputs.node-version }}'
|
||||
})
|
||||
expect(native.outputs['cache-key'].value).toBe('${{ steps.native-cache-scope.outputs.key }}')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps cache post-job paths when the nested step output context is absent',
|
||||
() => {
|
||||
const postContext = { steps: {} }
|
||||
const evaluateInput = (value) =>
|
||||
value.replace(
|
||||
/\$\{\{\s*steps\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}/g,
|
||||
(_expression, step, output) => postContext.steps[step]?.outputs?.[output] ?? ''
|
||||
)
|
||||
expect(evaluateInput('${{ steps.native-cache-scope.outputs.path }}')).toBe('')
|
||||
const emitted = resolveIdentity().path
|
||||
for (const step of native.runs.steps.filter((step) =>
|
||||
step.uses?.startsWith('actions/cache')
|
||||
)) {
|
||||
expect(evaluateInput(step.with.path).trim()).toBe(emitted)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -5,8 +5,8 @@ import { join } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const steps = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
|
||||
.runs.steps
|
||||
const steps = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')).runs
|
||||
.steps
|
||||
const toolchain = steps.find((step) => step.name === 'Use external node-gyp')
|
||||
|
||||
describe('CI native toolchain preparation', () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { defaultExclude } from 'vitest/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CROSS_VERSION_WIRE_DIR, UNIT_INCLUDE, discoverUnitFiles } from './ci-unit-files.mjs'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
function posixPaths(files) {
|
||||
return files.map((file) => file.replaceAll('\\', '/'))
|
||||
}
|
||||
|
||||
function workflowJobs(path) {
|
||||
return parse(readFileSync(path, 'utf8')).jobs
|
||||
}
|
||||
|
||||
// Why verify.needs: a job left out of it (e2e, ime, wsl) can be red or skipped without blocking a merge.
|
||||
// Each gating job carries the code_paths output that decides whether it runs; a called workflow's jobs inherit the caller's.
|
||||
function gatingJobs() {
|
||||
const jobs = workflowJobs('.github/workflows/pr.yml')
|
||||
return jobs.verify.needs.flatMap((name) => {
|
||||
const { if: condition = '', uses } = jobs[name]
|
||||
const gate = /needs\.code_paths\.outputs\.([\w-]+) == 'true'/.exec(condition)?.[1]
|
||||
const called = uses?.startsWith('./.github/workflows/')
|
||||
? Object.values(workflowJobs(uses.slice(2)))
|
||||
: [jobs[name]]
|
||||
return called.map((job) => ({ gate, pathArguments: vitestPathArguments(job) }))
|
||||
})
|
||||
}
|
||||
|
||||
// Path arguments of every step in the job that runs vitest; a trailing slash means a directory.
|
||||
function vitestPathArguments(job) {
|
||||
return (job.steps ?? [])
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string' && /\bvitest\b/.test(run))
|
||||
.flatMap((run) => run.replaceAll('\\\n', ' ').split(/\s+/))
|
||||
.map((token) => token.replace(/^['"]|['"]$/g, ''))
|
||||
.filter((token) => /^(?:src|config|tests|mobile)\//.test(token))
|
||||
}
|
||||
|
||||
function runByVitestStep(file, pathArguments) {
|
||||
return pathArguments.some(
|
||||
(path) => path === file || (path.endsWith('/') && file.startsWith(path))
|
||||
)
|
||||
}
|
||||
|
||||
// Why module scope: globbing the unit tree costs seconds, and every test reads the same result.
|
||||
const UNIT_FILES = posixPaths(globSync(UNIT_INCLUDE, { exclude: defaultExclude }))
|
||||
const SHARDED_UNIT_FILES = new Set(discoverUnitFiles())
|
||||
const EXCLUDED_UNIT_FILES = UNIT_FILES.filter((file) => !SHARDED_UNIT_FILES.has(file))
|
||||
const GATING_JOBS = gatingJobs()
|
||||
|
||||
describe('unit files kept out of the sharded test job', () => {
|
||||
it('each still runs in a job that gates the PR', () => {
|
||||
expect(EXCLUDED_UNIT_FILES.length).toBeGreaterThan(0)
|
||||
// Why: an excluded file no gating step names guards nothing, and nothing else reports it.
|
||||
expect(
|
||||
EXCLUDED_UNIT_FILES.filter(
|
||||
(file) => !GATING_JOBS.some((job) => runByVitestStep(file, job.pathArguments))
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('a change to each runs a gating job that names it', () => {
|
||||
// Why: otherwise a PR editing only the file skips the one job that runs it.
|
||||
expect(
|
||||
EXCLUDED_UNIT_FILES.filter(
|
||||
(file) =>
|
||||
!GATING_JOBS.some(
|
||||
({ gate, pathArguments }) =>
|
||||
runByVitestStep(file, pathArguments) && (!gate || classifyPrJobs([file])[gate])
|
||||
)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('runs the whole cross-version-wire directory, not a list of its files', () => {
|
||||
expect(GATING_JOBS.flatMap((job) => job.pathArguments)).toContain(CROSS_VERSION_WIRE_DIR)
|
||||
})
|
||||
|
||||
it('names every cross-version-wire test so the unit include picks it up', () => {
|
||||
// Why: a directory argument only reaches files the vitest include matches; any other name is silently skipped.
|
||||
const tests = posixPaths(
|
||||
globSync(`${CROSS_VERSION_WIRE_DIR}**/*.{test,spec}.{js,cjs,mjs,ts,tsx}`)
|
||||
)
|
||||
const included = new Set(UNIT_FILES)
|
||||
expect(tests.length).toBeGreaterThan(0)
|
||||
expect(tests.filter((file) => !included.has(file))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { globSync } from 'node:fs'
|
||||
import { defaultExclude } from 'vitest/config'
|
||||
|
||||
// Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update.
|
||||
export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/'
|
||||
|
||||
export const UNIT_INCLUDE = [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
@@ -32,7 +35,7 @@ export const UNIT_EXCLUDE = [
|
||||
'src/shared/posix-command-path-lookup.test.ts',
|
||||
'tests/e2e/relay-region-compatibility.unit.test.ts',
|
||||
'tests/e2e/relay-region-correction.unit.test.ts',
|
||||
'tests/e2e/cross-version-wire/**'
|
||||
`${CROSS_VERSION_WIRE_DIR}**`
|
||||
]
|
||||
|
||||
export function discoverUnitFiles(root = process.cwd()) {
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('what the mobile jobs restore from cache', () => {
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'./.github/actions/install-node-dependencies: Cache Electron package archive',
|
||||
'./.github/actions/install-node-dependencies: Restore compiled native modules',
|
||||
'./.github/actions/prepare-native-runtime: Restore compiled native modules',
|
||||
'./.github/actions/install-node-dependencies: Setup Node.js',
|
||||
'ios-build: Setup Ruby and fastlane'
|
||||
])
|
||||
|
||||
@@ -41,6 +41,7 @@ const ALWAYS_FILES = new Set([
|
||||
])
|
||||
const ALWAYS_PREFIXES = [
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'.github/actions/prepare-native-runtime/',
|
||||
// These areas also contain worker paths and fixtures opened without an import.
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
|
||||
@@ -83,6 +83,7 @@ it.each([
|
||||
'config/patches/node-pty@1.1.0.patch',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'.github/workflows/node-server-tests.yml',
|
||||
'src/main/persistence/profile-state/new-worker.ts'
|
||||
])('always selects build, native and dynamically opened inputs: %s', async (file) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ const PLATFORM_PREFIXES = [
|
||||
'native/',
|
||||
'config/patches/',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'.github/actions/prepare-native-runtime/',
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
'src/main/orcad/',
|
||||
|
||||
@@ -27,6 +27,7 @@ it.each([
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'config/patches/node-pty.patch',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'src/main/ssh/ssh-provider.ts',
|
||||
'src/main/providers/local-pty-provider.ts',
|
||||
'src/shared/child-process/run-process.ts',
|
||||
|
||||
@@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
|
||||
const GLOBAL_FORCE_PREFIXES = [
|
||||
'.github/workflows/pr.yml',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'.github/actions/prepare-native-runtime/',
|
||||
'config/scripts/pr-code-change-scope'
|
||||
]
|
||||
|
||||
@@ -98,6 +99,9 @@ const SHELL_PREFIXES = [
|
||||
'src/main/shell-wrapper-',
|
||||
'src/main/terminal-history-fish',
|
||||
'src/main/zsh-',
|
||||
'src/main/runtime/structured-session-cli-login-shell',
|
||||
'src/main/runtime/structured-session-login-shell-test-harness',
|
||||
'src/main/runtime/structured-session-child-identity-env',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme',
|
||||
'src/shared/fish-',
|
||||
'src/shared/pty-reply-echo-shapes',
|
||||
@@ -178,7 +182,19 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/main/runtime/runtime-worktree-agent-',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources',
|
||||
'src/shared/runtime-worktree-contracts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer',
|
||||
// Turn-end status a newer host publishes and an older desktop reads (cross-version-host-observed-turn-end).
|
||||
'src/shared/agent-turn-outcome',
|
||||
'src/shared/agent-status-types',
|
||||
'src/shared/agent-lead-status-fold',
|
||||
'src/shared/agent-session-turn-record',
|
||||
'src/shared/structured-agent-session-agent-status',
|
||||
'src/shared/structured-agent-session-projection',
|
||||
'src/shared/workspace-session-sleeping-agents',
|
||||
// An older app opening a newer orchestration database (orchestration-delivery-downgrade).
|
||||
'src/main/runtime/orchestration/db.ts',
|
||||
'src/main/runtime/orchestration/db/',
|
||||
'src/main/runtime/orchestration/orchestration-schema-version-skew'
|
||||
]
|
||||
|
||||
const MANAGED_HOOK_PREFIXES = [
|
||||
@@ -207,8 +223,26 @@ const NATIVE_CACHE_FILES = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs'
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/scripts/node-pty-job-ownership.cjs',
|
||||
'config/scripts/windows-pe-machine.cjs',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild.mjs',
|
||||
'config/scripts/windows-process-tree-creation-time.cjs',
|
||||
'config/scripts/install-electron-package-binary.mjs',
|
||||
'config/scripts/electron-platform-path.mjs',
|
||||
'config/scripts/zip-extractor-command.mjs',
|
||||
'src/shared/zip-extractor-command.ts',
|
||||
'config/scripts/shared-electron-dist-cache.mjs',
|
||||
'config/scripts/space-sharing-copy.mjs',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'native/windows-registry/binding.gyp',
|
||||
'native/windows-registry/package.json',
|
||||
'native/windows-registry/index.js'
|
||||
])
|
||||
|
||||
const NATIVE_CACHE_PREFIXES = [
|
||||
|
||||
@@ -286,6 +286,23 @@ describe('per-job path classification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('runs shell contracts for the structured-session login-shell test, its harness and its subject', () => {
|
||||
expectClassification(
|
||||
['src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts'],
|
||||
{ shell_contracts: true }
|
||||
)
|
||||
for (const file of [
|
||||
'src/main/runtime/structured-session-login-shell-test-harness.ts',
|
||||
'src/main/runtime/structured-session-child-identity-env.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
|
||||
for (const file of [
|
||||
'src/main/orcad/external-chromium-browser-session.ts',
|
||||
@@ -367,7 +384,11 @@ describe('per-job path classification', () => {
|
||||
'src/main/runtime/runtime-worktree-agent-rows.ts',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources.ts',
|
||||
'src/shared/runtime-worktree-contracts.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
|
||||
'src/shared/structured-agent-session-projection.ts',
|
||||
'src/shared/agent-turn-outcome.ts',
|
||||
'src/main/runtime/orchestration/db.ts',
|
||||
'src/main/runtime/orchestration/orchestration-schema-version-skew.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
'cross-version-wire': true,
|
||||
@@ -398,6 +419,7 @@ describe('per-job path classification', () => {
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch'
|
||||
|
||||
@@ -13,6 +13,9 @@ const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
const nativeAction = parse(
|
||||
readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
@@ -360,9 +363,9 @@ describe('PR workflow parallelism', () => {
|
||||
).toBe("steps.deps.outputs.native-cache-hit != 'true'")
|
||||
|
||||
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'")
|
||||
expect(nativeAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if).toBe(
|
||||
"runner.os == 'Linux' && inputs.native-runtime == 'node'"
|
||||
)
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
@@ -383,7 +386,13 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.name === 'Prepare native runtime'
|
||||
)
|
||||
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
|
||||
expect(prepareRuntime.uses).toBe('./.github/actions/prepare-native-runtime')
|
||||
expect(prepareRuntime.with).toEqual({
|
||||
'native-runtime': '${{ inputs.native-runtime }}',
|
||||
'node-version':
|
||||
'${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}',
|
||||
'persist-native-cache': '${{ inputs.persist-native-cache }}'
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses native preparation after the dependency action gate', () => {
|
||||
@@ -399,14 +408,16 @@ describe('PR workflow parallelism', () => {
|
||||
})
|
||||
|
||||
it('restores compiled native modules after the install that strips them', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const installerSteps = dependencyAction.runs.steps
|
||||
const installIndex = installerSteps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const nativeIndex = installerSteps.findIndex((step) => step.id === 'native-runtime')
|
||||
expect(installIndex).toBeLessThan(nativeIndex)
|
||||
const steps = nativeAction.runs.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
|
||||
const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
|
||||
|
||||
// `--ignore-scripts` leaves no build/, so a restore before the install would be
|
||||
// overwritten and one after the rebuild would never save a hit.
|
||||
expect(installIndex).toBeLessThan(cacheIndex)
|
||||
expect(cacheIndex).toBeLessThan(prepareIndex)
|
||||
expect(steps[cacheIndex].if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
|
||||
@@ -421,20 +432,8 @@ describe('PR workflow parallelism', () => {
|
||||
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
||||
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
||||
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
|
||||
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.os }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version'
|
||||
)
|
||||
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
|
||||
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
expect(cacheStep.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}')
|
||||
expect(cacheStep.with.path).not.toContain('${{')
|
||||
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
|
||||
expect(cacheStep.with.path).toContain('native/windows-registry/build')
|
||||
expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*')
|
||||
@@ -445,17 +444,24 @@ describe('PR workflow parallelism', () => {
|
||||
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
|
||||
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(cacheScope.run).toContain('/etc/os-release')
|
||||
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
|
||||
expect(nativeAction.outputs['cache-scope'].value).toBe(
|
||||
'${{ steps.native-cache-scope.outputs.scope }}'
|
||||
)
|
||||
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
|
||||
expect(nativeAction.outputs['cache-hit'].value).toContain(
|
||||
'steps.native-cache-restore.outputs.cache-hit'
|
||||
)
|
||||
expect(dependencyAction.outputs['native-cache-hit'].value).toContain(
|
||||
expect(nativeAction.outputs['cache-hit'].value).toContain(
|
||||
'steps.native-cache-restore-only.outputs.cache-hit'
|
||||
)
|
||||
const electronCache = steps.find((step) => step.name === 'Cache Electron package archive')
|
||||
const electronCacheResolution = steps.find(
|
||||
for (const output of ['scope', 'hit', 'key', 'path']) {
|
||||
expect(dependencyAction.outputs[`native-cache-${output}`].value).toBe(
|
||||
`\${{ steps.native-runtime.outputs.cache-${output} }}`
|
||||
)
|
||||
}
|
||||
const electronCache = installerSteps.find(
|
||||
(step) => step.name === 'Cache Electron package archive'
|
||||
)
|
||||
const electronCacheResolution = installerSteps.find(
|
||||
(step) => step.name === 'Resolve Electron package cache'
|
||||
)
|
||||
expect(electronCacheResolution.if).toBe(
|
||||
|
||||
@@ -14,7 +14,11 @@ describe('packaged skills CLI PR gates', () => {
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release:parallel')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(prepareStep.uses).toBe('./.github/actions/prepare-native-runtime')
|
||||
expect(prepareStep.with).toEqual({
|
||||
'native-runtime': 'electron',
|
||||
'node-version': '${{ steps.deps.outputs.node-version }}'
|
||||
})
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Antigravity readiness: what the transcripts show
|
||||
|
||||
`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether
|
||||
an Antigravity pane is ready for a prompt. It has been written five times, each version tuned
|
||||
Antigravity readiness lives in `src/main/runtime/agent-state-rules/antigravity.json`: a screen rule
|
||||
over the trusted grid, and a text anchor that runs the named scan
|
||||
`findAntigravityComposerIndex` (`agent-state-rules/antigravity-text-composer.ts`) over the
|
||||
line-folded tail when no trusted grid exists. That text scan decides whether a pane is ready for a
|
||||
prompt from its tail alone. It has been written five times, each version tuned
|
||||
against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four
|
||||
were found worse than the bug they replaced, and the fifth was reverted.
|
||||
|
||||
@@ -41,7 +44,7 @@ replays them.
|
||||
What they show:
|
||||
|
||||
- **The text tail misses three ready screens.** On `ready-accept-edits`, `ready-plan` and
|
||||
`turn-ended` the line-folded tail never satisfies `findAntigravityReadyPromptIndex`; the screen
|
||||
`turn-ended` the line-folded tail never satisfies `findAntigravityComposerIndex`; the screen
|
||||
rule does. (`turn-ended` is the 1.2.14 form of the old `busy-turn-ended` known defect.)
|
||||
- **A caret rule is wrong on the screen.** The grid keeps the bare `>` through a turn and behind
|
||||
the picker. The text tail happened to lose it mid-turn (section 8); the screen does not.
|
||||
|
||||
@@ -1060,3 +1060,138 @@ warnings remained non-fatal. Every full-repository scan passed cleanly.
|
||||
|
||||
Keep the existing production waves. The temporary workflow and 601-line
|
||||
benchmark driver were removed after recording this rejected result.
|
||||
|
||||
## Native cache ownership: retain the extraction
|
||||
|
||||
Native restoration, toolchain recovery, and preparation now belong to
|
||||
`.github/actions/prepare-native-runtime/action.yml`. The installer forwards
|
||||
its requested key and three build paths; Windows packaging saves the Node
|
||||
build before calling the same action for Electron. Existing native load,
|
||||
patched-build, Windows job-ownership, registry, and process-table probes remain
|
||||
unchanged on restored consumers. Exact keys still separate OS/image or Linux
|
||||
container libc, architecture, runtime, resolved Node version, and actual pnpm
|
||||
version, without partial-key restoration.
|
||||
|
||||
The source hash covers the dedicated action, `pnpm-lock.yaml`,
|
||||
`pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, both native dependency patches,
|
||||
and these complete build/probe inputs:
|
||||
|
||||
- `config/scripts/ensure-native-runtime.mjs`, `rebuild-native-deps.mjs`,
|
||||
`node-pty-job-ownership.cjs`, `windows-pe-machine.cjs`,
|
||||
`windows-process-tree-gyp-rebuild.mjs`, and
|
||||
`windows-process-tree-creation-time.cjs`;
|
||||
- `config/scripts/install-electron-package-binary.mjs`,
|
||||
`electron-platform-path.mjs`, `zip-extractor-command.mjs`,
|
||||
`shared-electron-dist-cache.mjs`, `space-sharing-copy.mjs`, and
|
||||
`src/shared/zip-extractor-command.ts`;
|
||||
- `native/windows-registry/src/addon.cc`, `binding.gyp`, `package.json`, and
|
||||
`index.js`.
|
||||
|
||||
The patches are `config/patches/node-pty@1.1.0.patch` and
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch`. Root app version and
|
||||
script metadata are excluded; installed package versions remain owned by the
|
||||
full lockfile, and the external node-gyp pin belongs to the native action.
|
||||
A negative control changing only the installer's
|
||||
pnpm verification condition preserves the native key and paths. Every declared
|
||||
native input mutation changes the key, and main warming watches those inputs.
|
||||
|
||||
This policy creates one cold namespace. The bounded 50-head main sample has
|
||||
49 adjacent transitions and three native-key changes under both the old and
|
||||
expanded policies: the added node-pty helper export still invalidates #24448.
|
||||
There is no measured historical net saving.
|
||||
|
||||
The [cold warming run](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/1)
|
||||
published all four exact Node keys, and its
|
||||
[warm rerun](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/2)
|
||||
restored them on fresh runners with the same frozen inputs, Node 24.21.0, and
|
||||
pnpm 12.0.0. All five jobs passed in both attempts. These times cover the entire
|
||||
native action: runtime validation, key resolution, restore, any toolchain
|
||||
recovery, and the unchanged native preparation probes.
|
||||
|
||||
| Native Node lane | Cold action | Warm action | Cold post-job save |
|
||||
| ---------------- | ----------- | ----------- | ------------------ |
|
||||
| Linux x64 | 18.288 s | 0.993 s | 0.387 s |
|
||||
| Linux ARM64 | 11.540 s | 1.062 s | 1.113 s |
|
||||
| Windows x64 | 104.482 s | 1.381 s | 2.424 s |
|
||||
| Windows ARM64 | 256.662 s | 3.832 s | 1.243 s |
|
||||
|
||||
Cold Windows jobs rebuilt all three native addons. Warm jobs loaded and probed
|
||||
the restored builds; Linux also ran the existing check-only probe before
|
||||
skipping the external node-gyp installation. Warm post-job steps recognized
|
||||
their primary keys and did not save again. An earlier trial exposed unavailable
|
||||
nested composite outputs during post-job saving; both cache variants now use
|
||||
the same literal path inventory as the requested output, and the fixed cold
|
||||
jobs published their caches without missing-path warnings.
|
||||
|
||||
Both [PR package jobs](https://github.com/stablyai/orca/actions/runs/36945659474)
|
||||
passed. Windows packaging consumed the same-run Node seed in 1.374 seconds
|
||||
before its Node tests and Electron transition. Its Electron cache initially
|
||||
missed while the modules were already healthy, so that stage does not establish
|
||||
an avoided compilation. Linux's Electron cache was also published, and all 19
|
||||
bundled native binaries passed the existing glibc floor check.
|
||||
|
||||
The [first six-platform headless run](https://github.com/stablyai/orca/actions/runs/36945658897)
|
||||
ran every persistence lane: five passed, while Mac Intel failed waiting for a
|
||||
cancel-test worker's ready file before its 500 ms timeout. That lane deliberately
|
||||
uses `native-runtime: none`; its separate slot build and smoke passed. The failure
|
||||
blocked the five downstream Linux glibc/musl qualifications, so this run does not
|
||||
establish complete headless qualification. All six persistence lanes, Node 18
|
||||
handoffs, and Linux floor/musl gates remain; final qualification is tracked in
|
||||
the [PR's latest checks](https://github.com/stablyai/orca/pull/24476/checks).
|
||||
|
||||
These are single cold/warm observations, not paired medians or a measured
|
||||
whole-workflow saving. They demonstrate usable exact-key reuse after publication;
|
||||
future savings depend on cache availability and unchanged native inputs. The
|
||||
trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed
|
||||
after merging this new namespace; the existing main push and hourly warming
|
||||
jobs provide that seed.
|
||||
|
||||
## Separate mobile install verification: retain the current policy
|
||||
|
||||
Three local paired pnpm 12 mobile installs reduced the median from 17.155 to
|
||||
15.871 seconds, a 1.284-second difference before cache transfer and postinstall
|
||||
scripts. That narrow margin does not establish a net hosted saving, so the
|
||||
separate mobile verification record was not adopted.
|
||||
|
||||
## Unit shard weights: retain the current allocation
|
||||
|
||||
The latest five shard wall times were 526/495/503/508/510 seconds. Reweighting
|
||||
projected roughly a 4% reduction in the slowest shard without reducing total
|
||||
CPU work; the evidence across runs was weak. That estimate does not justify
|
||||
changing allocation, so the current weights remain.
|
||||
|
||||
## Serializer oracle allocations: retain the change
|
||||
|
||||
The serializer round-trip oracle now reloads one xterm cell per buffer traversal
|
||||
and writes flag digits directly, avoiding fresh cell objects and flag arrays for
|
||||
every comparison. Independent replay terminals, cell descriptors, transcript
|
||||
fixtures, resize schedules, ConPTY modes and seeds remain unchanged.
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36944080887)
|
||||
used one Ubuntu 24.04 ARM64 runner, image 20260927.135.1, Node 24.21.0 and one
|
||||
isolated fork. The baseline formatter was frozen from f69052e. Byte-parity capture
|
||||
ran separately; these three alternating pairs had no payload instrumentation.
|
||||
Times cover the complete Vitest invocation, including startup and shutdown.
|
||||
|
||||
| Pair/order | Baseline | Candidate | Change |
|
||||
| ------------------ | -------- | --------- | ------ |
|
||||
| 1: baseline first | 70.631 s | 61.661 s | -12.7% |
|
||||
| 2: candidate first | 70.619 s | 62.284 s | -11.8% |
|
||||
| 3: baseline first | 70.750 s | 62.091 s | -12.2% |
|
||||
| Median | 70.631 s | 62.091 s | -12.1% |
|
||||
|
||||
Median test-body time fell from 69.519 to 60.953 seconds. All eight full-cohort
|
||||
invocations preserved the same 89 passes and two existing conditional skips
|
||||
across three files. Separate baseline/candidate captures produced identical
|
||||
95,017,559-byte payloads for all 1,435 scenarios and 7,649 checkpoints, with zero
|
||||
source crashes; both SHA256 hashes matched the local captures.
|
||||
|
||||
Seven focused controls compare against the original allocating oracle, including
|
||||
all 128 text-flag combinations, styled blanks, wide cells, cell reuse and immutable
|
||||
snapshots. Five deliberate faults were detected: stale cell contents, a missing
|
||||
bold flag, changed empty-cell policy, removed scratch reuse and a source-parser
|
||||
crash. The last control also proved that crash returns enter the capture.
|
||||
|
||||
This measures the three-file oracle cohort. Whole-shard timings include other
|
||||
test bodies, imports and transforms, so a whole-suite saving needs separate
|
||||
measurement.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Cline and Prime Agent readiness: what the transcripts show
|
||||
|
||||
Both agents paint their composer with cursor addressing on the alternate screen, so the line-folded
|
||||
text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by
|
||||
`isClineComposerReadyScreen` and `isPrimeAgentComposerReadyScreen`, through the same tiering as
|
||||
text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by the
|
||||
`composer_ready` rules in `src/main/runtime/agent-state-rules/cline.json` and `prime-agent.json`,
|
||||
through the same tiering as
|
||||
Antigravity ([`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md)): a pane
|
||||
with an output clock is believed only once quiet, and when a trustworthy screen exists it decides,
|
||||
so the quiet-process lane cannot settle a dialog it cannot see. Without a readable screen (a
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' }
|
||||
}
|
||||
|
||||
/** A frame as Claude Code 2.1.280 sends it: a transcript note at a render level. */
|
||||
function informational(
|
||||
level: 'info' | 'notice' | 'suggestion' | 'warning',
|
||||
content: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content,
|
||||
level,
|
||||
uuid: `5d3c1f0e-7a2b-4c6d-9e8f-00000000000${level.length}`,
|
||||
session_id: 'provider-1'
|
||||
}
|
||||
}
|
||||
|
||||
let root = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-claude-informational-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function itemsFor(frames: Record<string, unknown>[]) {
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
database: openTestJournalHostDatabase(root),
|
||||
now: () => 1_700_000_000_000,
|
||||
mintEpoch: () => 'epoch-1'
|
||||
})
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
|
||||
deferred.bind({ journal, fence: 1, publish: vi.fn() })
|
||||
const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' })
|
||||
for (const frame of frames) {
|
||||
translator.handle({ type: 'message', sessionId: 'orca-session', message: frame })
|
||||
await deferred.drained()
|
||||
}
|
||||
return journal.snapshot().items.map((item) => item.body)
|
||||
}
|
||||
|
||||
describe('a Claude informational frame', () => {
|
||||
it.each(['info', 'notice', 'suggestion'] as const)('writes no row at level %s', async (level) => {
|
||||
expect(await itemsFor([informational(level, `A ${level} note`)])).toEqual([])
|
||||
})
|
||||
|
||||
it('writes its own words as one warning row at level warning', async () => {
|
||||
const content =
|
||||
'UserPromptSubmit operation blocked by hook: secrets are not allowed\n\nOriginal prompt: deploy it'
|
||||
const items = await itemsFor([informational('warning', content)])
|
||||
|
||||
expect(items).toEqual([{ kind: 'status', tone: 'warning', text: content }])
|
||||
})
|
||||
|
||||
it('writes no row for a warning with nothing to say', async () => {
|
||||
expect(await itemsFor([informational('warning', ' ')])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// Only a `warning` frame (a UserPromptSubmit hook that blocked the prompt, say) earns a row, in its
|
||||
// own words; `info`, `notice` (a hook's systemMessage) and `suggestion` are session chrome, not chat rows.
|
||||
|
||||
import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { claudeText } from './claude-structured-item-translation'
|
||||
|
||||
export const CLAUDE_INFORMATIONAL_FRAME_KIND = 'message:system:informational'
|
||||
|
||||
/** The row a warning-level note writes; null for every other level and for a warning with no words. */
|
||||
export function claudeInformationalRowBody(
|
||||
message: Record<string, unknown>
|
||||
): AgentJournalStatusItem | null {
|
||||
const text = message.level === 'warning' ? claudeText(message.content)?.trim() : null
|
||||
return text
|
||||
? {
|
||||
kind: 'status',
|
||||
tone: 'warning',
|
||||
text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
claudeApiRetryRowBody,
|
||||
createClaudeApiRetryRuns
|
||||
} from './claude-api-retry-row'
|
||||
import {
|
||||
CLAUDE_INFORMATIONAL_FRAME_KIND,
|
||||
claudeInformationalRowBody
|
||||
} from './claude-informational-row'
|
||||
|
||||
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
|
||||
const type = claudeText(message.type) ?? 'unknown'
|
||||
@@ -147,6 +151,21 @@ export function createClaudeProviderFrameFallback(
|
||||
sink.publish()
|
||||
return true
|
||||
}
|
||||
if (kind === CLAUDE_INFORMATIONAL_FRAME_KIND) {
|
||||
// Never the frame as a row: a warning in its own words, any other level nothing.
|
||||
const body = claudeInformationalRowBody(claudeRecord(payload) ?? {})
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
beforeAppend?.()
|
||||
const identity = {
|
||||
provider: 'orca',
|
||||
clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}`
|
||||
} as const
|
||||
sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() })
|
||||
sink.publish()
|
||||
return true
|
||||
}
|
||||
const translated = unhandledProviderFrameJournalItem(
|
||||
'claude',
|
||||
kind,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
|
||||
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
|
||||
|
||||
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
|
||||
type BufferLine = NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>
|
||||
type Buffer = Terminal['buffer']['active']
|
||||
|
||||
const COLOR_MODE_P16 = 16777216
|
||||
const COLOR_MODE_P256 = 33554432
|
||||
const DEFAULT_BLANK = '▯·w1·b0:-1·000'
|
||||
const CLIPPED = 'CLIPPED'
|
||||
|
||||
// SerializeAddon re-emits palette 0-15 set via 38;5;N as SGR 30-37/90-97; same theme slot.
|
||||
function canonicalColorMode(mode: number, colorValue: number): number {
|
||||
return mode === COLOR_MODE_P256 && colorValue >= 0 && colorValue < 16 ? COLOR_MODE_P16 : mode
|
||||
}
|
||||
|
||||
function flags(values: boolean[]): string {
|
||||
return values.map((flag) => (flag ? '1' : '0')).join('')
|
||||
}
|
||||
|
||||
/** Visually effective cell state, same blank-cell policy as terminal-restore-parity-fixture. */
|
||||
function allocatingCellDescriptor(line: BufferLine | undefined, x: number, cols: number): string {
|
||||
if (!line || x >= line.length) {
|
||||
return DEFAULT_BLANK
|
||||
}
|
||||
const cell = line.getCell(x)
|
||||
if (!cell) {
|
||||
return DEFAULT_BLANK
|
||||
}
|
||||
if (x === cols - 1 && line.length > cols && cell.getWidth() > 1) {
|
||||
return CLIPPED
|
||||
}
|
||||
const chars = cell.getChars()
|
||||
const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor())
|
||||
const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor())
|
||||
if (chars === '' || chars === ' ') {
|
||||
const blank = chars === ' '
|
||||
const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : ''
|
||||
return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${flags([
|
||||
blank && cell.isUnderline() !== 0,
|
||||
blank && cell.isStrikethrough() !== 0,
|
||||
blank && cell.isOverline() !== 0
|
||||
])}${inverseFg}`
|
||||
}
|
||||
const cellFlags = flags([
|
||||
cell.isBold() !== 0,
|
||||
cell.isDim() !== 0,
|
||||
cell.isItalic() !== 0,
|
||||
cell.isUnderline() !== 0,
|
||||
cell.isInverse() !== 0,
|
||||
cell.isInvisible() !== 0,
|
||||
cell.isStrikethrough() !== 0
|
||||
])
|
||||
return `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${cellFlags}`
|
||||
}
|
||||
|
||||
function rowCells(line: BufferLine | undefined, cols: number): string[] {
|
||||
return Array.from({ length: cols }, (_, x) => allocatingCellDescriptor(line, x, cols))
|
||||
}
|
||||
|
||||
function allocatingBufferRows(
|
||||
buffer: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
cols: number
|
||||
): string[][] {
|
||||
const rows: string[][] = []
|
||||
for (let y = start; y < end; y++) {
|
||||
rows.push(rowCells(buffer.getLine(y), cols))
|
||||
}
|
||||
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
|
||||
rows.pop()
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
describe('serialize oracle cell reuse', () => {
|
||||
it.each([false, true])(
|
||||
'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
|
||||
(conpty) => {
|
||||
const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
try {
|
||||
const writes = [
|
||||
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
|
||||
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
|
||||
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
|
||||
'scroll1\r\nscroll2\r\nscroll3\r\n',
|
||||
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
|
||||
'\x1b[?1049l\x1b[2J\x1b[Hclear'
|
||||
]
|
||||
for (const data of writes) {
|
||||
writeTerminal(terminal, data)
|
||||
for (const buffer of [
|
||||
terminal.buffer.normal,
|
||||
terminal.buffer.alternate,
|
||||
terminal.buffer.active
|
||||
]) {
|
||||
expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
|
||||
allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
|
||||
)
|
||||
}
|
||||
terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
|
||||
expect(
|
||||
bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
|
||||
).toEqual(
|
||||
allocatingBufferRows(
|
||||
terminal.buffer.active,
|
||||
0,
|
||||
terminal.buffer.active.length,
|
||||
terminal.cols
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the order of every text flag combination while reloading a plain cell', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
const sgr = [1, 2, 3, 4, 7, 8, 9]
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
for (let mask = 0; mask < 128; mask++) {
|
||||
const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
|
||||
writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
|
||||
const line = terminal.buffer.active.getLine(0)
|
||||
expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4))
|
||||
expect(cellDescriptor(line, 1, 4, scratch)).toBe(allocatingCellDescriptor(line, 1, 4))
|
||||
}
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves styled spaces, empty cells and inverse foreground colors', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
for (const inverse of [false, true]) {
|
||||
for (const glyph of ['', ' ']) {
|
||||
for (let mask = 0; mask < 8; mask++) {
|
||||
const codes = [4, 9, 53].filter((_code, bit) => (mask & (1 << bit)) !== 0)
|
||||
writeTerminal(
|
||||
terminal,
|
||||
`\x1b[H\x1b[0m\x1b[38;2;3;4;5;48;5;2${inverse ? ';7' : ''}${codes.length ? `;${codes.join(';')}` : ''}m\x1b[2J${glyph}`
|
||||
)
|
||||
const line = terminal.buffer.active.getLine(0)
|
||||
expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a clipped wide leading cell at the comparison grid edge', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 8, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(terminal, 'abc界')
|
||||
const line = terminal.buffer.active.getLine(0)
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
|
||||
expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
|
||||
allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
|
||||
)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
|
||||
const buffer = terminal.buffer.active
|
||||
const scratch = buffer.getNullCell()
|
||||
const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
|
||||
const getLine = buffer.getLine.bind(buffer)
|
||||
const loaded: unknown[] = []
|
||||
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
|
||||
const line = getLine(y)
|
||||
if (line) {
|
||||
const getCell = line.getCell.bind(line)
|
||||
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
|
||||
loaded.push(cell)
|
||||
return getCell(x, cell)
|
||||
})
|
||||
}
|
||||
return line
|
||||
})
|
||||
const expected = allocatingBufferRows(buffer, 0, 2, 4)
|
||||
loaded.length = 0
|
||||
const actual = bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
expect(allocate).toHaveBeenCalledTimes(1)
|
||||
expect(loaded).toHaveLength(8)
|
||||
expect(loaded.every((cell) => cell === scratch)).toBe(true)
|
||||
writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
|
||||
bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(terminal, '\x1b[1;7;38;2;5;6;7mX')
|
||||
const line = terminal.buffer.active.getLine(0)
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4))
|
||||
for (const x of [-1, 4, 5]) {
|
||||
expect(cellDescriptor(line, x, 4, scratch)).toBe(allocatingCellDescriptor(line, x, 4))
|
||||
}
|
||||
expect(cellDescriptor(undefined, 0, 4, scratch)).toBe(
|
||||
allocatingCellDescriptor(undefined, 0, 4)
|
||||
)
|
||||
expect(cellDescriptor(line, 1, 4, scratch)).toBe(allocatingCellDescriptor(line, 1, 4))
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -18,16 +18,17 @@ function canonicalColorMode(mode: number, colorValue: number): number {
|
||||
return mode === COLOR_MODE_P256 && colorValue >= 0 && colorValue < 16 ? COLOR_MODE_P16 : mode
|
||||
}
|
||||
|
||||
function flags(values: boolean[]): string {
|
||||
return values.map((flag) => (flag ? '1' : '0')).join('')
|
||||
}
|
||||
|
||||
/** Visually effective cell state, same blank-cell policy as terminal-restore-parity-fixture. */
|
||||
export function cellDescriptor(line: BufferLine | undefined, x: number, cols: number): string {
|
||||
export function cellDescriptor(
|
||||
line: BufferLine | undefined,
|
||||
x: number,
|
||||
cols: number,
|
||||
reusableCell?: ReturnType<Buffer['getNullCell']>
|
||||
): string {
|
||||
if (!line || x >= line.length) {
|
||||
return DEFAULT_BLANK
|
||||
}
|
||||
const cell = line.getCell(x)
|
||||
const cell = line.getCell(x, reusableCell)
|
||||
if (!cell) {
|
||||
return DEFAULT_BLANK
|
||||
}
|
||||
@@ -40,26 +41,22 @@ export function cellDescriptor(line: BufferLine | undefined, x: number, cols: nu
|
||||
if (chars === '' || chars === ' ') {
|
||||
const blank = chars === ' '
|
||||
const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : ''
|
||||
return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${flags([
|
||||
blank && cell.isUnderline() !== 0,
|
||||
blank && cell.isStrikethrough() !== 0,
|
||||
blank && cell.isOverline() !== 0
|
||||
])}${inverseFg}`
|
||||
return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blank && cell.isUnderline() !== 0 ? '1' : '0'}${blank && cell.isStrikethrough() !== 0 ? '1' : '0'}${blank && cell.isOverline() !== 0 ? '1' : '0'}${inverseFg}`
|
||||
}
|
||||
const cellFlags = flags([
|
||||
cell.isBold() !== 0,
|
||||
cell.isDim() !== 0,
|
||||
cell.isItalic() !== 0,
|
||||
cell.isUnderline() !== 0,
|
||||
cell.isInverse() !== 0,
|
||||
cell.isInvisible() !== 0,
|
||||
cell.isStrikethrough() !== 0
|
||||
])
|
||||
const cellFlags =
|
||||
`${cell.isBold() !== 0 ? '1' : '0'}${cell.isDim() !== 0 ? '1' : '0'}` +
|
||||
`${cell.isItalic() !== 0 ? '1' : '0'}${cell.isUnderline() !== 0 ? '1' : '0'}` +
|
||||
`${cell.isInverse() !== 0 ? '1' : '0'}${cell.isInvisible() !== 0 ? '1' : '0'}` +
|
||||
`${cell.isStrikethrough() !== 0 ? '1' : '0'}`
|
||||
return `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${cellFlags}`
|
||||
}
|
||||
|
||||
function rowCells(line: BufferLine | undefined, cols: number): string[] {
|
||||
return Array.from({ length: cols }, (_, x) => cellDescriptor(line, x, cols))
|
||||
function rowCells(
|
||||
line: BufferLine | undefined,
|
||||
cols: number,
|
||||
reusableCell: ReturnType<Buffer['getNullCell']>
|
||||
): string[] {
|
||||
return Array.from({ length: cols }, (_, x) => cellDescriptor(line, x, cols, reusableCell))
|
||||
}
|
||||
|
||||
// A wide glyph whose trailing half lies past the grid cannot be replayed; any blank is faithful.
|
||||
@@ -73,8 +70,9 @@ function rowsMatch(expected: string[], actual: string[]): boolean {
|
||||
|
||||
export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
|
||||
const rows: string[][] = []
|
||||
const reusableCell = buffer.getNullCell()
|
||||
for (let y = start; y < end; y++) {
|
||||
rows.push(rowCells(buffer.getLine(y), cols))
|
||||
rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
|
||||
}
|
||||
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
|
||||
rows.pop()
|
||||
|
||||
@@ -23,7 +23,10 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: {
|
||||
}): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
async (_event, args: { selector: string; force?: boolean }): Promise<OrcadManagedDeployResult> => {
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; force?: boolean }
|
||||
): Promise<OrcadManagedDeployResult> => {
|
||||
const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), {
|
||||
selector: requiredString(args?.selector, 'Server'),
|
||||
force: args?.force === true
|
||||
|
||||
@@ -133,7 +133,8 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = {
|
||||
'message:system:permission_denied': 'error-surface',
|
||||
'message:prompt_suggestion': 'status-chrome',
|
||||
'message:system:mirror_error': 'error-surface',
|
||||
'message:system:informational': 'timeline-substantive',
|
||||
// Written by its own row (`claude-informational-row`): a warning in its words, else nothing.
|
||||
'message:system:informational': 'status-chrome',
|
||||
'message:conversation_reset': 'status-chrome',
|
||||
// A `started`/`completed`/`cancelled` state for one queued command uuid and
|
||||
// nothing else; the CLI keeps it out of its own transcript too. A state that
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
|
||||
/**
|
||||
@@ -54,3 +55,44 @@ export function listStructuredAgentSessionTabs(
|
||||
agent: session.params.provider
|
||||
}))
|
||||
}
|
||||
|
||||
type TabSessions = ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
child?: unknown
|
||||
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
|
||||
}
|
||||
>
|
||||
|
||||
/** The host's chat-tab surface; reads `host.deps` per call, so it sees the host's wrapped deps. */
|
||||
export function createStructuredAgentSessionTabSurface(
|
||||
host: Parameters<typeof setStructuredAgentSessionTabVisibility>[0] & {
|
||||
deps: {
|
||||
store: Pick<
|
||||
AgentSessionRecordStore,
|
||||
'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs'
|
||||
>
|
||||
}
|
||||
},
|
||||
sessions: TabSessions,
|
||||
forgetStatus: (sessionId: string) => void
|
||||
) {
|
||||
return {
|
||||
listSessionTabs: () => listStructuredAgentSessionTabs(sessions),
|
||||
getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(),
|
||||
getSessionTabId: (sessionId: string): string | null =>
|
||||
host.deps.store.getSessionTabId(sessionId),
|
||||
showSessionTabs: (sessionIds: readonly string[]) => host.deps.store.showSessionTabs(sessionIds),
|
||||
setSessionTabVisibility: async (
|
||||
sessionId: string,
|
||||
visible: boolean,
|
||||
tabId?: string
|
||||
): Promise<void> => {
|
||||
await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId)
|
||||
// The tab edge of the row's lifetime; the handle close is the other.
|
||||
if (!visible && !sessions.get(sessionId)?.child) {
|
||||
forgetStatus(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,28 +216,22 @@ export class StructuredAgentSessionHost {
|
||||
supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean =>
|
||||
providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent)
|
||||
|
||||
listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions)
|
||||
getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex()
|
||||
getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId)
|
||||
showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds)
|
||||
private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface(
|
||||
this,
|
||||
this.sessions,
|
||||
(sessionId) => this.clientDelivery.forgetStatus(sessionId)
|
||||
)
|
||||
listSessionTabs = this.tabs.listSessionTabs
|
||||
getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex
|
||||
getSessionTabId = this.tabs.getSessionTabId
|
||||
showSessionTabs = this.tabs.showSessionTabs
|
||||
setSessionTabVisibility = this.tabs.setSessionTabVisibility
|
||||
/** The records file could not be read this launch, so chats it holds are not listed yet. */
|
||||
legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true
|
||||
/** This runtime holds a chat: a record, or the records file's chats still owed their copy. */
|
||||
holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed()
|
||||
onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener)
|
||||
|
||||
setSessionTabVisibility = async (
|
||||
sessionId: string,
|
||||
visible: boolean,
|
||||
tabId?: string
|
||||
): Promise<void> => {
|
||||
await sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId)
|
||||
// The tab edge of the row's lifetime; the handle close is the other.
|
||||
if (!visible && !this.sessions.get(sessionId)?.child) {
|
||||
this.clientDelivery.forgetStatus(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
reconcileRestartLeases = (): Promise<void> => this.restore.reconcileRestartLeases()
|
||||
|
||||
restoreReadableSessions = (sessionIds?: readonly string[]): Promise<void> =>
|
||||
|
||||
@@ -181,11 +181,12 @@ describe('profile state backup worker', () => {
|
||||
const cancellation = new AbortController()
|
||||
const pending = runProfileStateBackupWorker(job, {
|
||||
workerPath: worker,
|
||||
timeoutMs: 500,
|
||||
// Worker startup must not race the cancellation assertion.
|
||||
timeoutMs: mode === 'cancel' ? 10_000 : 500,
|
||||
signal: cancellation.signal
|
||||
})
|
||||
const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out')
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true))
|
||||
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5_000 })
|
||||
expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4)
|
||||
if (mode === 'cancel') {
|
||||
cancellation.abort()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ScreenCondition, TextTest } from './agent-state-rules-schema'
|
||||
|
||||
export type TextMatcher = (text: string) => boolean
|
||||
|
||||
type TextTerm = Extract<TextTest, { regex: string } | { contains: string }>
|
||||
|
||||
function compileTerm(term: TextTerm): TextMatcher {
|
||||
if ('contains' in term) {
|
||||
return (text) => text.includes(term.contains)
|
||||
}
|
||||
const pattern = new RegExp(term.regex, term.ignoreCase ? 'i' : '')
|
||||
return (text) => pattern.test(text)
|
||||
}
|
||||
|
||||
export function compileTextTest(test: TextTest): TextMatcher {
|
||||
if ('regex' in test || 'contains' in test) {
|
||||
return compileTerm(test)
|
||||
}
|
||||
const all = (test.all ?? []).map(compileTerm)
|
||||
const any = (test.any ?? []).map(compileTerm)
|
||||
const none = (test.none ?? []).map(compileTerm)
|
||||
return (text) =>
|
||||
all.every((matches) => matches(text)) &&
|
||||
(any.length === 0 || any.some((matches) => matches(text))) &&
|
||||
!none.some((matches) => matches(text))
|
||||
}
|
||||
|
||||
export type ScreenMatcher = (screenLines: readonly string[]) => boolean
|
||||
|
||||
export function compileScreenCondition(screen: ScreenCondition): ScreenMatcher {
|
||||
if (!screen.rows) {
|
||||
return () => true
|
||||
}
|
||||
const rows = screen.rows.map((row) =>
|
||||
'optional' in row
|
||||
? { matches: compileTextTest(row.optional), optional: true }
|
||||
: { matches: compileTextTest(row), optional: false }
|
||||
)
|
||||
const endsWithinBottom = screen.endsWithinBottom ?? 1
|
||||
const noneAbove = screen.noneAbove ? compileTextTest(screen.noneAbove) : null
|
||||
const lastRow = rows.at(-1)
|
||||
const rowsUpward = rows.slice(0, -1).toReversed()
|
||||
return (screenLines) => {
|
||||
const lines = screenLines.map((line) => line.trim())
|
||||
let end = lines.length - 1
|
||||
const lowestEnd = Math.max(0, lines.length - endsWithinBottom)
|
||||
while (end >= lowestEnd && !lastRow?.matches(lines[end])) {
|
||||
end -= 1
|
||||
}
|
||||
if (end < lowestEnd) {
|
||||
return false
|
||||
}
|
||||
let cursor = end - 1
|
||||
for (const row of rowsUpward) {
|
||||
if (row.matches(lines[cursor] ?? '')) {
|
||||
cursor -= 1
|
||||
} else if (!row.optional) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return noneAbove === null || !lines.slice(0, Math.max(0, cursor + 1)).some(noneAbove)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Why a subset: rule patterns run on every poll, and Node has no linear-time regex engine, so a
|
||||
// pattern that can backtrack exponentially is refused when the file loads. Overlapping adjacent
|
||||
// quantifiers (`\s*\s*x`) are polynomial and not detected.
|
||||
export function findUnsafePatternReason(pattern: string): string | null {
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
} catch {
|
||||
return 'does not compile'
|
||||
}
|
||||
if (/\\[1-9]|\\k</.test(pattern)) {
|
||||
return 'uses a backreference'
|
||||
}
|
||||
if (/\(\?<[=!]/.test(pattern)) {
|
||||
return 'uses a lookbehind'
|
||||
}
|
||||
return repeatsAVariableGroup(pattern)
|
||||
? 'repeats a group that can match in more than one way'
|
||||
: null
|
||||
}
|
||||
|
||||
function isRepeatingQuantifier(char: string | undefined): boolean {
|
||||
return char === '*' || char === '+' || char === '{'
|
||||
}
|
||||
|
||||
// A repeated group whose body varies, by a quantifier or an alternation: `(a+)*`, `(a?)+`,
|
||||
// `(a|aa)+`. Such a body can split one input many ways, each of which a failed match retries.
|
||||
function repeatsAVariableGroup(pattern: string): boolean {
|
||||
const groupVaries: boolean[] = []
|
||||
let inClass = false
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index]
|
||||
if (char === '\\') {
|
||||
index += 1
|
||||
} else if (inClass) {
|
||||
inClass = char !== ']'
|
||||
} else if (char === '[') {
|
||||
inClass = true
|
||||
} else if (char === '(') {
|
||||
groupVaries.push(false)
|
||||
// Why skip: the `?` of `(?:`, `(?=` or `(?<name>` opens a group; it quantifies nothing.
|
||||
if (pattern[index + 1] === '?') {
|
||||
index += 1
|
||||
}
|
||||
} else if (char === ')') {
|
||||
const bodyVaries = groupVaries.pop() ?? false
|
||||
if (bodyVaries && isRepeatingQuantifier(pattern[index + 1])) {
|
||||
return true
|
||||
}
|
||||
if (bodyVaries && groupVaries.length > 0) {
|
||||
groupVaries[groupVaries.length - 1] = true
|
||||
}
|
||||
} else if (
|
||||
(isRepeatingQuantifier(char) || char === '?' || char === '|') &&
|
||||
groupVaries.length > 0
|
||||
) {
|
||||
groupVaries[groupVaries.length - 1] = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
detectExplicitIdleStatusFromTitle,
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptBody,
|
||||
isKnownReadyPromptPreview,
|
||||
isKnownReadyPromptSettled
|
||||
} from '../terminal-wait-detection'
|
||||
import { nameOnlyIdleNeedsCorroboration } from '../tui-idle-evidence'
|
||||
import {
|
||||
compileAgentRules,
|
||||
evaluateCompiledRules,
|
||||
readsTrustedScreen,
|
||||
type AgentStateRegions
|
||||
} from './agent-state-rules-engine'
|
||||
import { parseAgentStateRuleFiles } from './agent-state-rules-catalog'
|
||||
import { showsIdleTitleAnchor } from './agent-state-title-anchors'
|
||||
|
||||
const STRONG_QUIET = { state: 'idle', strength: 'strong', requiresQuiet: true }
|
||||
const TITLE = { region: 'title', status: 'idle' }
|
||||
|
||||
function rule(id: string, when: Record<string, unknown>, answer: Record<string, unknown>) {
|
||||
return { id, why: 'test', priority: 100, when, answer }
|
||||
}
|
||||
|
||||
const READY_ANCHOR = {
|
||||
id: 'ready',
|
||||
why: 'test',
|
||||
when: { region: 'text', find: { lastOf: 'ready>' } },
|
||||
answer: { state: 'idle' }
|
||||
}
|
||||
|
||||
function file(rules: unknown[], extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { id: 'codex', engineVersion: 1, anchors: [READY_ANCHOR], rules, ...extra }
|
||||
}
|
||||
|
||||
function evaluate(rules: unknown[], regions: AgentStateRegions): string | null {
|
||||
const [parsed] = parseAgentStateRuleFiles([file(rules, { profile: { screenSource: 'live' } })])
|
||||
return evaluateCompiledRules(compileAgentRules(parsed), regions)?.ruleId ?? null
|
||||
}
|
||||
|
||||
describe('region schema', () => {
|
||||
const screen = { region: 'screen', predicate: 'codex-composer-ready' }
|
||||
|
||||
it.each([
|
||||
[
|
||||
'screen rows beside a predicate',
|
||||
file([rule('a', { ...screen, rows: [{ contains: '>' }] }, STRONG_QUIET)], {
|
||||
profile: { screenSource: 'live' }
|
||||
})
|
||||
],
|
||||
['screen rules with no screenSource', file([rule('a', screen, STRONG_QUIET)])],
|
||||
[
|
||||
'withoutClock on a weak rule',
|
||||
file([
|
||||
rule(
|
||||
'a',
|
||||
{ region: 'title', status: 'idle' },
|
||||
{ state: 'idle', strength: 'weak', requiresQuiet: true, withoutClock: 'skip' }
|
||||
)
|
||||
])
|
||||
],
|
||||
[
|
||||
'a text rule naming no anchor of its file',
|
||||
file([rule('a', { region: 'text', anchor: 'missing' }, STRONG_QUIET)])
|
||||
],
|
||||
[
|
||||
'a text rule naming a non-idle anchor',
|
||||
file([rule('a', { region: 'text', anchor: 'live' }, STRONG_QUIET)], {
|
||||
anchors: [{ ...READY_ANCHOR, id: 'live', answer: { state: 'live' } }]
|
||||
})
|
||||
],
|
||||
[
|
||||
'a title anchor answering other than idle',
|
||||
file([], {
|
||||
anchors: [
|
||||
{
|
||||
id: 't',
|
||||
why: 'test',
|
||||
when: { region: 'title', match: { contains: '◇' } },
|
||||
answer: { state: 'live' }
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
[
|
||||
'a title rule on a status other than idle',
|
||||
file([rule('a', { region: 'title', status: 'working' }, STRONG_QUIET)])
|
||||
],
|
||||
['an unknown pane id', { ...file([]), id: 'unknown' }],
|
||||
['two anchors with one id', file([], { anchors: [READY_ANCHOR, READY_ANCHOR] })],
|
||||
[
|
||||
'two rules with one id',
|
||||
file([rule('a', TITLE, STRONG_QUIET), rule('a', TITLE, STRONG_QUIET)])
|
||||
]
|
||||
])('rejects %s', (_label, candidate) => {
|
||||
expect(() => parseAgentStateRuleFiles([candidate])).toThrow()
|
||||
})
|
||||
|
||||
it('accepts the unknown-pane file', () => {
|
||||
expect(() => parseAgentStateRuleFiles([{ ...file([]), id: 'unknown-pane' }])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('regions', () => {
|
||||
const title = rule('title', { region: 'title', status: 'idle' }, STRONG_QUIET)
|
||||
const text = rule('text', { region: 'text', anchor: 'ready' }, STRONG_QUIET)
|
||||
|
||||
it('skips a rule whose region the lane does not read', () => {
|
||||
expect(evaluate([title], { readScreenLines: () => [] })).toBeNull()
|
||||
expect(evaluate([title], { readTitleStatus: () => 'idle' })).toBe('title')
|
||||
expect(evaluate([title], { readTitleStatus: () => 'working' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a named screen predicate over the lowercased screen', () => {
|
||||
const composer = rule(
|
||||
'c',
|
||||
{ region: 'screen', predicate: 'codex-composer-ready' },
|
||||
STRONG_QUIET
|
||||
)
|
||||
expect(evaluate([composer], { readScreenLines: () => ['› Ask Codex to do anything'] })).toBe(
|
||||
'c'
|
||||
)
|
||||
expect(
|
||||
evaluate([composer], {
|
||||
readScreenLines: () => ['esc to interrupt)', '› Ask Codex to do anything']
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('needs its text anchor settled: no blocker painted after it', () => {
|
||||
const readText = (value: string) => ({ readText: () => value })
|
||||
expect(evaluate([text], readText('ready>'))).toBe('text')
|
||||
expect(evaluate([text], readText('ready>\ndo you trust this folder?'))).toBeNull()
|
||||
expect(evaluate([text], readText('do you trust this folder?\nready>'))).toBe('text')
|
||||
})
|
||||
|
||||
it('skips a rule marked withoutClock skip only on a pane with no output clock', () => {
|
||||
const skipped = rule(
|
||||
's',
|
||||
{ region: 'title', status: 'idle' },
|
||||
{ ...STRONG_QUIET, withoutClock: 'skip' }
|
||||
)
|
||||
const regions = { readTitleStatus: () => 'idle' as const }
|
||||
expect(evaluate([skipped, title], { ...regions, hasOutputClock: true })).toBe('s')
|
||||
expect(evaluate([skipped, title], { ...regions, hasOutputClock: false })).toBe('title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the bundled Codex text anchors', () => {
|
||||
const header =
|
||||
'╭───╮\n│ >_ openai codex (v0.157.0) │\n│ model: gpt-5 │\n│ directory: ~/repo │\n╰───╯'
|
||||
|
||||
it('settles on the loaded header, and takes the header as proof a dialog was answered', () => {
|
||||
expect(isKnownReadyPromptSettled(header)).toBe(true)
|
||||
expect(
|
||||
detectTerminalWaitBlockedReason(
|
||||
'do you trust this folder?\n1. yes\n>_ openai codex (v0.158.0)'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('holds a provisional header: present, but not yet taking input', () => {
|
||||
const provisional = header.replace('gpt-5', 'loading')
|
||||
expect(isKnownReadyPromptPreview(provisional)).toBe(true)
|
||||
expect(isKnownReadyPromptSettled(provisional)).toBe(false)
|
||||
expect(isKnownReadyPromptSettled(`${provisional}\n› hi\ngpt-5 · ~/repo`)).toBe(true)
|
||||
})
|
||||
|
||||
it('holds its own ready text to quiet on a clocked Codex pane, and believes it clockless', () => {
|
||||
expect(isKnownReadyPromptBody(header, 'codex', () => null, true)).toBe(false)
|
||||
expect(isKnownReadyPromptBody(header, 'codex', () => null, false)).toBe(true)
|
||||
expect(isKnownReadyPromptBody(header, 'claude', () => null, true)).toBe(true)
|
||||
})
|
||||
|
||||
it("takes no other agent's ready text on a clocked Codex pane, as before the rule files", () => {
|
||||
const cursorPrompt = '>_ openai codex (v0.158.0)\ncursor agent\n→'
|
||||
expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, true)).toBe(false)
|
||||
expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, false)).toBe(true)
|
||||
expect(isKnownReadyPromptBody(cursorPrompt, 'claude', () => null, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('settles an unknown pane on the live-screen Codex header at once, even clocked', () => {
|
||||
const screen = () => header.split('\n')
|
||||
expect(isKnownReadyPromptBody('', null, screen, true)).toBe(true)
|
||||
expect(isKnownReadyPromptBody('', 'claude', screen, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the live screen for Codex, not the trusted grid', () => {
|
||||
expect(readsTrustedScreen('codex')).toBe(false)
|
||||
expect(readsTrustedScreen('cline')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the bundled title anchors', () => {
|
||||
it.each(['✳ Claude Code', '* Claude Code', '◇ Ready (repo)', 'π - orca', 'OC | orca'])(
|
||||
'reads %s as an explicit idle title',
|
||||
(title) => {
|
||||
expect(detectExplicitIdleStatusFromTitle(title)).toBe('idle')
|
||||
}
|
||||
)
|
||||
|
||||
it('marks an agent rest title only when an anchor matches it', () => {
|
||||
expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true)
|
||||
expect(showsIdleTitleAnchor('Claude Code')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a name-only title to the agent idle-title rule', () => {
|
||||
expect(detectExplicitIdleStatusFromTitle('claude')).toBeNull()
|
||||
expect(nameOnlyIdleNeedsCorroboration('pi')).toBe(true)
|
||||
expect(nameOnlyIdleNeedsCorroboration('omp')).toBe(true)
|
||||
expect(nameOnlyIdleNeedsCorroboration('gemini')).toBe(false)
|
||||
expect(nameOnlyIdleNeedsCorroboration('opencode')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AgentStateRulesFileSchema, type AgentStateRulesFile } from './agent-state-rules-schema'
|
||||
import antigravity from './antigravity.json'
|
||||
import claude from './claude.json'
|
||||
import cline from './cline.json'
|
||||
import codex from './codex.json'
|
||||
import cursor from './cursor.json'
|
||||
import gemini from './gemini.json'
|
||||
import omp from './omp.json'
|
||||
import opencode from './opencode.json'
|
||||
import opencode2 from './opencode2.json'
|
||||
import pi from './pi.json'
|
||||
import primeAgent from './prime-agent.json'
|
||||
import unknownPane from './unknown-pane.json'
|
||||
|
||||
/** Validates bundled rule files; a malformed one throws, naming the file and the bad field. */
|
||||
export function parseAgentStateRuleFiles(files: readonly unknown[]): AgentStateRulesFile[] {
|
||||
const parsed = files.map((file, index) => {
|
||||
const result = AgentStateRulesFileSchema.safeParse(file)
|
||||
if (!result.success) {
|
||||
throw new Error(`agent state rules file ${index}: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
})
|
||||
const seen = new Set<AgentStateRulesFile['id']>()
|
||||
for (const file of parsed) {
|
||||
if (seen.has(file.id)) {
|
||||
throw new Error(`agent state rules: two files for ${file.id}`)
|
||||
}
|
||||
seen.add(file.id)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// Why imported, not read from disk: the bundler inlines them, so packaged and headless builds
|
||||
// carry the rules with no resource path to resolve.
|
||||
export const BUNDLED_AGENT_STATE_RULE_FILES: readonly AgentStateRulesFile[] =
|
||||
parseAgentStateRuleFiles([
|
||||
antigravity,
|
||||
claude,
|
||||
cline,
|
||||
codex,
|
||||
cursor,
|
||||
gemini,
|
||||
omp,
|
||||
opencode,
|
||||
opencode2,
|
||||
pi,
|
||||
primeAgent,
|
||||
unknownPane
|
||||
])
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateTuiIdle, type TuiIdleEvaluationInput } from '../tui-idle-evidence'
|
||||
import { isKnownReadyPromptBody, isQuietReadyScreenBody } from '../terminal-wait-detection'
|
||||
import {
|
||||
compileAgentRules,
|
||||
evaluateAgentStateRules,
|
||||
evaluateCompiledRules,
|
||||
type AgentStateVerdict
|
||||
} from './agent-state-rules-engine'
|
||||
import { parseAgentStateRuleFiles } from './agent-state-rules-catalog'
|
||||
|
||||
const QUIESCENCE_MS = 3000
|
||||
|
||||
function anchor(when: Record<string, unknown>, answer: Record<string, unknown>) {
|
||||
return { id: 'anchor', why: 'test', when, answer }
|
||||
}
|
||||
|
||||
type RuleFileOverrides = { rules?: unknown[]; anchors?: unknown[] } & Record<string, unknown>
|
||||
|
||||
function ruleFile(overrides: RuleFileOverrides = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'cline',
|
||||
engineVersion: 1,
|
||||
profile: { screenSource: 'trusted' },
|
||||
anchors: [],
|
||||
rules: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const SCREEN = { region: 'screen' }
|
||||
|
||||
function idleRule(id: string, priority: number, rows: unknown[]): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
why: 'test',
|
||||
priority,
|
||||
when: { ...SCREEN, rows },
|
||||
answer: { state: 'idle', strength: 'strong', requiresQuiet: true }
|
||||
}
|
||||
}
|
||||
|
||||
const HOLD = { id: 'hold', why: 'test', priority: 100, when: SCREEN, answer: { state: 'hold' } }
|
||||
|
||||
function evaluate(rules: unknown[], screen: readonly string[] | null): string | null {
|
||||
const [file] = parseAgentStateRuleFiles([ruleFile({ rules })])
|
||||
return (
|
||||
evaluateCompiledRules(compileAgentRules(file), { readScreenLines: () => screen })?.ruleId ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
describe('agent state rules schema', () => {
|
||||
it.each([
|
||||
['an unknown field', ruleFile({ fallback: 'quiet' })],
|
||||
['an unknown agent', ruleFile({ id: 'not-an-agent' })],
|
||||
['another engine version', ruleFile({ engineVersion: 2 })],
|
||||
['a misspelled rule field', ruleFile({ rules: [{ ...HOLD, requireQuiet: true }] })],
|
||||
['a rule with no why', ruleFile({ rules: [{ ...HOLD, why: undefined }] })],
|
||||
[
|
||||
'row modifiers with no rows',
|
||||
ruleFile({ rules: [{ ...HOLD, when: { ...SCREEN, endsWithinBottom: 2 } }] })
|
||||
],
|
||||
['a pattern that does not compile', ruleFile({ rules: [idleRule('a', 1, [{ regex: '(' }])] })],
|
||||
[
|
||||
'an optional last row',
|
||||
ruleFile({ rules: [idleRule('a', 1, [{ optional: { contains: '>' } }])] })
|
||||
],
|
||||
[
|
||||
'a codex-only blocked reason',
|
||||
ruleFile({
|
||||
anchors: [
|
||||
anchor(
|
||||
{ region: 'text', find: { lastOf: 'update' } },
|
||||
{ state: 'blocked', reason: 'codex-update-prompt' }
|
||||
)
|
||||
]
|
||||
})
|
||||
],
|
||||
[
|
||||
'an unregistered named anchor',
|
||||
ruleFile({
|
||||
anchors: [anchor({ region: 'text', find: { predicate: 'nope' } }, { state: 'idle' })]
|
||||
})
|
||||
],
|
||||
[
|
||||
'a blocked anchor the prefilter cannot key on',
|
||||
ruleFile({
|
||||
anchors: [
|
||||
anchor(
|
||||
{ region: 'text', find: { predicate: 'antigravity-text-composer' } },
|
||||
{ state: 'blocked', reason: 'agent-approval-prompt' }
|
||||
)
|
||||
]
|
||||
})
|
||||
],
|
||||
[
|
||||
'an uppercase anchor literal, which the lowercased tail never contains',
|
||||
ruleFile({
|
||||
anchors: [anchor({ region: 'text', find: { lastOf: 'Cursor' } }, { state: 'idle' })]
|
||||
})
|
||||
],
|
||||
[
|
||||
'an uppercase anchor contains term',
|
||||
ruleFile({
|
||||
anchors: [
|
||||
anchor(
|
||||
{ region: 'text', find: { lastOf: 'x' }, after: { contains: 'Run' } },
|
||||
{ state: 'idle' }
|
||||
)
|
||||
]
|
||||
})
|
||||
]
|
||||
])('rejects %s', (_label, file) => {
|
||||
expect(() => parseAgentStateRuleFiles([file])).toThrow()
|
||||
})
|
||||
|
||||
it('rejects two files for one agent', () => {
|
||||
expect(() => parseAgentStateRuleFiles([ruleFile(), ruleFile()])).toThrow(/two files/)
|
||||
})
|
||||
|
||||
const withPattern = (regex: string) => ruleFile({ rules: [idleRule('a', 1, [{ regex }])] })
|
||||
|
||||
it.each([
|
||||
['a backreference', '(a)\\1'],
|
||||
['a lookbehind', '(?<=a)b'],
|
||||
['nested quantifiers', '(a+)+$'],
|
||||
['a repeated optional', '(a?)*$'],
|
||||
['a repeated alternation', '(?:a|aa)+$'],
|
||||
['a variable group nested in a repeated one', '(?:x(?:a|b))+']
|
||||
])('rejects %s', (_label, regex) => {
|
||||
expect(() => parseAgentStateRuleFiles([withPattern(regex)])).toThrow(/pattern/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a repeated fixed group', '(?: or [a-z])*'],
|
||||
['an unrepeated alternation holding a repeat', '\\((?:tab|esc(?: or [a-z])*)\\)$'],
|
||||
['an optional alternation', '^(?:yes|no)?$']
|
||||
])('accepts %s', (_label, regex) => {
|
||||
expect(() => parseAgentStateRuleFiles([withPattern(regex)])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority evaluation', () => {
|
||||
const ready = idleRule('ready', 500, [{ regex: '^>$' }])
|
||||
|
||||
it('answers with the highest-priority match whatever the file order', () => {
|
||||
expect(evaluate([HOLD, ready], ['>'])).toBe('ready')
|
||||
expect(evaluate([HOLD, ready], ['busy'])).toBe('hold')
|
||||
})
|
||||
|
||||
it('breaks priority ties by file order', () => {
|
||||
const tiedHold = { ...HOLD, priority: 500 }
|
||||
expect(evaluate([tiedHold, ready], ['>'])).toBe('hold')
|
||||
expect(evaluate([ready, tiedHold], ['>'])).toBe('ready')
|
||||
})
|
||||
|
||||
it('gives no answer without a readable screen, so the caller decides', () => {
|
||||
expect(evaluate([ready, HOLD], null)).toBeNull()
|
||||
})
|
||||
|
||||
it('gives no answer when nothing matches and there is no hold', () => {
|
||||
expect(evaluate([ready], ['busy'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('screen rows', () => {
|
||||
const block = (match: Record<string, unknown>) => [
|
||||
{ ...HOLD, id: 'm', when: { ...SCREEN, ...match } }
|
||||
]
|
||||
|
||||
it('reads rows above the screen as empty', () => {
|
||||
const rows = [{ none: [{ contains: '⠋' }] }, { regex: '^>$' }]
|
||||
expect(evaluate(block({ rows }), ['>'])).toBe('m')
|
||||
expect(evaluate(block({ rows: [{ regex: '^─+$' }, { regex: '^>$' }] }), ['>'])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips an optional row that does not match', () => {
|
||||
const rows = [{ regex: '^top$' }, { optional: { contains: 'hint' } }, { regex: '^>$' }]
|
||||
expect(evaluate(block({ rows }), ['top', 'hint', '>'])).toBe('m')
|
||||
expect(evaluate(block({ rows }), ['top', '>'])).toBe('m')
|
||||
expect(evaluate(block({ rows }), ['other', '>'])).toBeNull()
|
||||
})
|
||||
|
||||
it('ends the block at the lowest match within endsWithinBottom', () => {
|
||||
const rows = [{ regex: '^─+$' }, { regex: '^mode$' }]
|
||||
expect(evaluate(block({ rows, endsWithinBottom: 2 }), ['───', 'mode', 'cwd'])).toBe('m')
|
||||
expect(evaluate(block({ rows, endsWithinBottom: 1 }), ['───', 'mode', 'cwd'])).toBeNull()
|
||||
})
|
||||
|
||||
it('vetoes a block when a row above it matches noneAbove', () => {
|
||||
const match = { rows: [{ regex: '^>$' }], noneAbove: { contains: '⠋' } }
|
||||
expect(evaluate(block(match), ['⠋ thinking', '>'])).toBeNull()
|
||||
expect(evaluate(block(match), ['done', '>'])).toBe('m')
|
||||
})
|
||||
|
||||
it('combines all, any and none on one row', () => {
|
||||
const rows = [{ all: [{ regex: '\\)$' }], any: [{ contains: 'yes' }, { contains: 'no' }] }]
|
||||
expect(evaluate(block({ rows }), ['yes (y)'])).toBe('m')
|
||||
expect(evaluate(block({ rows }), ['maybe (m)'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('strength and quiet through the tui-idle ranking', () => {
|
||||
const now = Date.now()
|
||||
|
||||
function verdictFor(ruled: AgentStateVerdict, lastOutputAt: number | null): string {
|
||||
const input: TuiIdleEvaluationInput = {
|
||||
record: { lastAgentStatus: null, lastOutputAt, lastOscTitle: null },
|
||||
readTailBlockedReason: () => null,
|
||||
readPositiveBodyEvidence: () => false,
|
||||
readQuietReadyBodyEvidence: () => false,
|
||||
readAgentRuleVerdict: () => ruled,
|
||||
agent: 'cline',
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
}
|
||||
const verdict = evaluateTuiIdle(input)
|
||||
return verdict.kind === 'pending' ? `pending:${verdict.quietForeground}` : verdict.kind
|
||||
}
|
||||
|
||||
const weak = (requiresQuiet: boolean): AgentStateVerdict => ({
|
||||
ruleId: 'w',
|
||||
state: 'idle',
|
||||
strength: 'weak',
|
||||
requiresQuiet
|
||||
})
|
||||
|
||||
it('settles weak idle only on the weak lane, and only once quiet when it asks to', () => {
|
||||
expect(verdictFor(weak(false), now)).toBe('ready-weak')
|
||||
expect(verdictFor(weak(true), now)).toBe('pending:closed')
|
||||
expect(verdictFor(weak(true), now - QUIESCENCE_MS)).toBe('ready-weak')
|
||||
expect(verdictFor(weak(true), null)).toBe('pending:closed')
|
||||
})
|
||||
|
||||
it('holds every weak lane on a hold', () => {
|
||||
expect(verdictFor({ ruleId: 'h', state: 'hold' }, now - QUIESCENCE_MS)).toBe('pending:closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a bundled strong, quiet idle rule', () => {
|
||||
// Antigravity's idle composer (agent-state-rules/antigravity.json).
|
||||
const readyScreen = ['─'.repeat(20), '>', '─'.repeat(20), '? for shortcuts']
|
||||
|
||||
it('is believed at once on a pane with no output clock', () => {
|
||||
expect(isKnownReadyPromptBody('', 'antigravity', () => readyScreen, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for quiet on a clocked pane', () => {
|
||||
expect(isKnownReadyPromptBody('', 'antigravity', () => readyScreen, true)).toBe(false)
|
||||
expect(isQuietReadyScreenBody('', 'antigravity', () => readyScreen)).toBe(true)
|
||||
})
|
||||
|
||||
it('holds when the screen shows something else', () => {
|
||||
const picker = [...readyScreen.slice(0, 3), 'esc to cancel']
|
||||
expect(evaluateAgentStateRules('antigravity', { readScreenLines: () => picker })?.state).toBe(
|
||||
'hold'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { AgentStatus } from '../../../shared/agent-detection'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { compileScreenCondition } from './agent-state-rule-matchers'
|
||||
import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog'
|
||||
import {
|
||||
UNKNOWN_PANE_RULES_ID,
|
||||
type AgentStateRuleAnswer,
|
||||
type AgentStateRuleCondition,
|
||||
type AgentStateRulesFile,
|
||||
type NamedScreenPredicate
|
||||
} from './agent-state-rules-schema'
|
||||
import { compileTextAnchor } from './agent-state-text-anchors'
|
||||
import { isSettledAfter } from './blocked-text-layer'
|
||||
import { isCodexComposerReadyScreen, isCodexHeaderReadyScreen } from './codex-screen-predicates'
|
||||
|
||||
/** What the first matching rule answered. Callers rank it among the other readiness lanes. */
|
||||
export type AgentStateVerdict = { ruleId: string } & AgentStateRuleAnswer
|
||||
|
||||
/**
|
||||
* The regions a lane lets the rules read. A region that is absent, or null because no trustworthy
|
||||
* copy exists, skips the rules that read it: the lane reads only the evidence it ranks.
|
||||
*/
|
||||
export type AgentStateRegions = {
|
||||
readScreenLines?: () => readonly string[] | null
|
||||
/** The lowercased text tail. */
|
||||
readText?: () => string
|
||||
/** The status the shared title classifier gave the pane title. */
|
||||
readTitleStatus?: () => AgentStatus | null
|
||||
/** False on a pane with no output clock (restored or adopted); absent reads as clocked. */
|
||||
hasOutputClock?: boolean
|
||||
}
|
||||
|
||||
type Region = AgentStateRuleCondition['region']
|
||||
|
||||
type RegionReader<T> = () => T | null
|
||||
|
||||
type RegionReads = {
|
||||
screen: RegionReader<readonly string[]>
|
||||
/** The screen joined and lowercased, for named screen predicates. */
|
||||
screenText: RegionReader<string>
|
||||
text: RegionReader<string>
|
||||
title: RegionReader<AgentStatus>
|
||||
}
|
||||
|
||||
type CompiledRule = {
|
||||
verdict: AgentStateVerdict
|
||||
region: Region
|
||||
skipWithoutClock: boolean
|
||||
/** False when its region is unreadable, which skips the rule. */
|
||||
matches: (reads: RegionReads) => boolean
|
||||
}
|
||||
|
||||
const NAMED_SCREEN_PREDICATES: Record<NamedScreenPredicate, (screen: string) => boolean> = {
|
||||
'codex-header-ready': isCodexHeaderReadyScreen,
|
||||
'codex-composer-ready': isCodexComposerReadyScreen
|
||||
}
|
||||
|
||||
function readThen<T>(read: RegionReader<T>, test: (value: T) => boolean): boolean {
|
||||
const value = read()
|
||||
return value !== null && test(value)
|
||||
}
|
||||
|
||||
function compileCondition(
|
||||
when: AgentStateRuleCondition,
|
||||
file: AgentStateRulesFile
|
||||
): CompiledRule['matches'] {
|
||||
switch (when.region) {
|
||||
case 'screen': {
|
||||
if (when.predicate) {
|
||||
const predicate = NAMED_SCREEN_PREDICATES[when.predicate]
|
||||
return (reads) => readThen(reads.screenText, predicate)
|
||||
}
|
||||
const matches = compileScreenCondition(when)
|
||||
return (reads) => readThen(reads.screen, matches)
|
||||
}
|
||||
case 'title':
|
||||
return (reads) => readThen(reads.title, (status) => status === when.status)
|
||||
case 'text': {
|
||||
const anchor = file.anchors.find((candidate) => candidate.id === when.anchor)
|
||||
// Why unreachable: the schema requires the anchor to be one of this file's text anchors.
|
||||
if (anchor?.when.region !== 'text') {
|
||||
throw new Error(`agent state rules ${file.id}: no text anchor ${when.anchor}`)
|
||||
}
|
||||
const find = compileTextAnchor(anchor.when, anchor.answer)
|
||||
return (reads) =>
|
||||
readThen(reads.text, (text) => isSettledAfter(text, find(text)?.index ?? null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] {
|
||||
// Why stable: equal priorities keep file order.
|
||||
return file.rules
|
||||
.toSorted((left, right) => right.priority - left.priority)
|
||||
.map((rule) => ({
|
||||
verdict: { ruleId: rule.id, ...rule.answer },
|
||||
region: rule.when.region,
|
||||
skipWithoutClock: rule.answer.state === 'idle' && rule.answer.withoutClock === 'skip',
|
||||
matches: compileCondition(rule.when, file)
|
||||
}))
|
||||
}
|
||||
|
||||
type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID
|
||||
|
||||
type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean }
|
||||
|
||||
const FILES_BY_KEY: ReadonlyMap<RulesKey, CompiledFile> = new Map(
|
||||
BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [
|
||||
file.id,
|
||||
{
|
||||
rules: compileAgentRules(file),
|
||||
readsTrustedScreen: file.profile?.screenSource === 'trusted'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// Why the unknown-pane file for a null agent: an adopted pane can still run a known agent.
|
||||
function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined {
|
||||
return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent's rules read the PTY's trusted grid. Why it changes which screen is read, and
|
||||
* how a clockless wait probes: those rules were recorded against that grid, while every other
|
||||
* agent keeps the live screen.
|
||||
*/
|
||||
export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean {
|
||||
return compiledFileFor(agent)?.readsTrustedScreen ?? false
|
||||
}
|
||||
|
||||
function someRule(
|
||||
agent: TuiAgent | null | undefined,
|
||||
test: (rule: CompiledRule) => boolean
|
||||
): boolean {
|
||||
return compiledFileFor(agent)?.rules.some(test) ?? false
|
||||
}
|
||||
|
||||
function isStrongQuietIdle(verdict: AgentStateVerdict): boolean {
|
||||
return verdict.state === 'idle' && verdict.strength === 'strong' && verdict.requiresQuiet
|
||||
}
|
||||
|
||||
/** Whether the agent has a ready sign it also paints mid-turn, which the quiet lane must read. */
|
||||
export function hasQuietReadyRules(agent: TuiAgent | null | undefined): boolean {
|
||||
return someRule(agent, (rule) => isStrongQuietIdle(rule.verdict))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent's own ready text is held to quiet. Why it shuts the shared text lane on a
|
||||
* clocked pane: that lane settles any rule file's ready text at once, which would skip the quiet.
|
||||
*/
|
||||
export function holdsReadyTextToQuiet(agent: TuiAgent | null | undefined): boolean {
|
||||
return someRule(agent, (rule) => rule.region === 'text' && isStrongQuietIdle(rule.verdict))
|
||||
}
|
||||
|
||||
/** Whether the agent's own idle-title rule waits for quiet; null when it has none. */
|
||||
export function idleTitleRequiresQuiet(agent: TuiAgent | null | undefined): boolean | null {
|
||||
const verdict = compiledFileFor(agent)?.rules.find((rule) => rule.region === 'title')?.verdict
|
||||
return verdict?.state === 'idle' ? verdict.requiresQuiet : null
|
||||
}
|
||||
|
||||
function memoize<T>(read: (() => T | null) | undefined): RegionReader<T> {
|
||||
if (!read) {
|
||||
return () => null
|
||||
}
|
||||
let value: T | null | undefined
|
||||
return () => (value === undefined ? (value = read()) : value)
|
||||
}
|
||||
|
||||
export function evaluateCompiledRules(
|
||||
rules: readonly CompiledRule[],
|
||||
regions: AgentStateRegions
|
||||
): AgentStateVerdict | null {
|
||||
const screen = memoize(regions.readScreenLines)
|
||||
const reads: RegionReads = {
|
||||
screen,
|
||||
screenText: memoize(() => screen()?.join('\n').toLowerCase() ?? null),
|
||||
text: memoize(regions.readText),
|
||||
title: memoize(regions.readTitleStatus)
|
||||
}
|
||||
// Why no answer rather than a refusal: with no readable region the caller's other lanes decide.
|
||||
for (const rule of rules) {
|
||||
if (rule.skipWithoutClock && regions.hasOutputClock === false) {
|
||||
continue
|
||||
}
|
||||
if (rule.matches(reads)) {
|
||||
return rule.verdict
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The agent's priority list over its regions: the first match answers; none leaves it to the caller. */
|
||||
export function evaluateAgentStateRules(
|
||||
agent: TuiAgent | null | undefined,
|
||||
regions: AgentStateRegions
|
||||
): AgentStateVerdict | null {
|
||||
const file = compiledFileFor(agent)
|
||||
return file ? evaluateCompiledRules(file.rules, regions) : null
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from 'zod'
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
import { findUnsafePatternReason } from './agent-state-rule-pattern-safety'
|
||||
|
||||
/**
|
||||
* One file per agent (`<agent>.json` beside this schema), plus `unknown-pane.json` for panes whose
|
||||
* agent Orca does not know. Every object is strict, so a misspelled field rejects the file instead
|
||||
* of silently dropping a condition. Every rule and anchor is `when` (a region and what it must
|
||||
* show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps
|
||||
* `engineVersion`; until then version 1 is still being defined.
|
||||
*/
|
||||
const AGENT_STATE_RULES_ENGINE_VERSION = 1
|
||||
|
||||
const MAX_PATTERN_LENGTH = 200
|
||||
const MAX_RULES = 32
|
||||
const MAX_ROWS = 12
|
||||
const MAX_TERMS = 8
|
||||
|
||||
const Literal = z.string().min(1).max(MAX_PATTERN_LENGTH)
|
||||
|
||||
// Why: these are matched against the lowercased text tail, so an uppercase letter never matches.
|
||||
const TailLiteral = Literal.refine(
|
||||
(text) => text === text.toLowerCase(),
|
||||
'must be lowercase: the text tail is lowercased'
|
||||
)
|
||||
|
||||
const SafeRegex = Literal.superRefine((pattern, ctx) => {
|
||||
const reason = findUnsafePatternReason(pattern)
|
||||
if (reason) {
|
||||
ctx.addIssue({ code: 'custom', message: `pattern ${reason}` })
|
||||
}
|
||||
})
|
||||
|
||||
/** A test on one row or one text segment: a single term, or every `all`, some `any`, no `none`. */
|
||||
function textTestSchema(containsLiteral: typeof Literal) {
|
||||
const term = z.union([
|
||||
z.object({ regex: SafeRegex, ignoreCase: z.boolean().optional() }).strict(),
|
||||
z.object({ contains: containsLiteral }).strict()
|
||||
])
|
||||
const terms = z.array(term).min(1).max(MAX_TERMS)
|
||||
return z.union([
|
||||
term,
|
||||
z
|
||||
.object({ all: terms.optional(), any: terms.optional(), none: terms.optional() })
|
||||
.strict()
|
||||
.refine((test) => Boolean(test.all ?? test.any ?? test.none), 'needs all, any or none')
|
||||
])
|
||||
}
|
||||
|
||||
const TextTestSchema = textTestSchema(Literal)
|
||||
const TailTextTestSchema = textTestSchema(TailLiteral)
|
||||
|
||||
const RowSchema = z.union([TextTestSchema, z.object({ optional: TextTestSchema }).strict()])
|
||||
|
||||
/** The evidence behind a rule, since JSON carries no comments; it ships beside the pattern it explains. */
|
||||
const Why = z.string().min(1).max(600)
|
||||
|
||||
const NAMED_SCREEN_PREDICATES = ['codex-header-ready', 'codex-composer-ready'] as const
|
||||
|
||||
/**
|
||||
* The screen `profile.screenSource` names. `rows` are consecutive trimmed rows, top-down; the
|
||||
* block ends at the bottom-most row, among the last `endsWithinBottom`, that passes the final
|
||||
* test, and a row above the screen reads as empty. Or `predicate`: a named engine scan over the
|
||||
* lowercased screen, for a shape rows cannot state. With neither, it holds whenever readable.
|
||||
*/
|
||||
const ScreenConditionSchema = z
|
||||
.object({
|
||||
region: z.literal('screen'),
|
||||
rows: z.array(RowSchema).min(1).max(MAX_ROWS).optional(),
|
||||
endsWithinBottom: z.number().int().min(1).max(MAX_ROWS).optional(),
|
||||
noneAbove: TextTestSchema.optional(),
|
||||
predicate: z.enum(NAMED_SCREEN_PREDICATES).optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(screen) => screen.rows || (!screen.endsWithinBottom && !screen.noneAbove),
|
||||
'endsWithinBottom and noneAbove need rows'
|
||||
)
|
||||
.refine(
|
||||
(screen) => !('optional' in (screen.rows?.at(-1) ?? {})),
|
||||
'the last row cannot be optional'
|
||||
)
|
||||
.refine((screen) => !(screen.rows && screen.predicate), 'rows or a predicate, not both')
|
||||
|
||||
/** The pane title's status, as the shared title classifier read it (`lastAgentStatus`). */
|
||||
const TitleConditionSchema = z
|
||||
.object({ region: z.literal('title'), status: z.enum(['idle']) })
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* The text tail: this file's `anchor` (an idle text anchor) is found and settled, meaning no
|
||||
* blocker was painted after it and no hold anchor shows anywhere.
|
||||
*/
|
||||
const TextConditionSchema = z.object({ region: z.literal('text'), anchor: Literal }).strict()
|
||||
|
||||
const RuleConditionSchema = z.discriminatedUnion('region', [
|
||||
ScreenConditionSchema,
|
||||
TitleConditionSchema,
|
||||
TextConditionSchema
|
||||
])
|
||||
|
||||
const RuleAnswerSchema = z.discriminatedUnion('state', [
|
||||
z
|
||||
.object({
|
||||
state: z.literal('idle'),
|
||||
/** Strong settles a wait at once; weak only on the poll, once nothing stronger spoke. */
|
||||
strength: z.enum(['strong', 'weak']),
|
||||
/** Believed only after the output clock has been quiet (agents paint this mid-turn too). */
|
||||
requiresQuiet: z.boolean(),
|
||||
/** On a pane with no output clock (restored or adopted), quiet cannot be measured: a strong
|
||||
* quiet rule is believed at once there, unless it says `skip` (then it does not apply). */
|
||||
withoutClock: z.literal('skip').optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(answer) => !answer.withoutClock || (answer.requiresQuiet && answer.strength === 'strong'),
|
||||
'withoutClock is for strong rules that require quiet'
|
||||
),
|
||||
// Why hold: the agent's own evidence was readable and said "not ready", which must also shut
|
||||
// the weak lanes (a name-only title or quiet process cannot see what the screen refused).
|
||||
z.object({ state: z.literal('hold') }).strict()
|
||||
])
|
||||
|
||||
/** One entry in the agent's priority list: highest `priority` first, ties in file order. */
|
||||
const AgentStateRuleSchema = z
|
||||
.object({
|
||||
id: Literal,
|
||||
why: Why,
|
||||
priority: z.number().int().min(0).max(1000),
|
||||
when: RuleConditionSchema,
|
||||
answer: RuleAnswerSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const NAMED_TEXT_ANCHORS = ['antigravity-text-composer'] as const
|
||||
const NAMED_TITLE_PREDICATES = ['opencode-native-title'] as const
|
||||
|
||||
const AGENT_BLOCKED_REASONS = [
|
||||
'agent-update-prompt',
|
||||
'agent-trust-workspace',
|
||||
'agent-cwd-prompt',
|
||||
'agent-hooks-review-prompt',
|
||||
'agent-interactive-prompt',
|
||||
'agent-approval-prompt'
|
||||
] as const satisfies readonly RuntimeTerminalWaitBlockedReason[]
|
||||
|
||||
/**
|
||||
* A position in the lowercased text tail. Text anchors read every pane whatever agent it runs (a
|
||||
* tail can show another agent's dialog, and an adopted pane has no known agent), and the latest one
|
||||
* in the text wins. A blocked anchor reads the blocked layer's live window. An idle or live one is
|
||||
* a live prompt, which cancels an earlier blocker; only an idle one settles a wait. A hold anchor,
|
||||
* found anywhere, stops every text anchor from settling one (the agent is up, not ready).
|
||||
*/
|
||||
const TextAnchorConditionSchema = z
|
||||
.object({
|
||||
region: z.literal('text'),
|
||||
/** Where the anchor starts: a literal's last occurrence, or a named engine scan. */
|
||||
find: z.union([
|
||||
z.object({ lastOf: TailLiteral }).strict(),
|
||||
z.object({ predicate: z.enum(NAMED_TEXT_ANCHORS) }).strict()
|
||||
]),
|
||||
/** Reads only the last N lines of its input. */
|
||||
withinLastLines: z.number().int().min(1).max(64).optional(),
|
||||
/** The text from the anchor to the end must pass this. */
|
||||
after: TailTextTestSchema.optional(),
|
||||
/** Over the lines read, trailing blanks dropped: at least `atLeast` pass, the last one too
|
||||
* when `includingLast`. */
|
||||
lines: z
|
||||
.object({
|
||||
atLeast: z.number().int().min(1).max(MAX_ROWS),
|
||||
includingLast: z.boolean(),
|
||||
test: TailTextTestSchema
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* A title the shared classifier already calls idle, marked as an agent's own rest title. Like a
|
||||
* text anchor it is read whatever agent the pane runs: an adopted pane has no known agent, and a
|
||||
* pane can run another agent than it launched. `match` is a test, or a named engine predicate
|
||||
* shared with other title readers.
|
||||
*/
|
||||
const TitleAnchorConditionSchema = z
|
||||
.object({
|
||||
region: z.literal('title'),
|
||||
match: z.union([
|
||||
TextTestSchema,
|
||||
z.object({ predicate: z.enum(NAMED_TITLE_PREDICATES) }).strict()
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
|
||||
const AnchorSchema = z
|
||||
.object({
|
||||
id: Literal,
|
||||
why: Why,
|
||||
when: z.discriminatedUnion('region', [TextAnchorConditionSchema, TitleAnchorConditionSchema]),
|
||||
answer: z.discriminatedUnion('state', [
|
||||
z.object({ state: z.literal('blocked'), reason: z.enum(AGENT_BLOCKED_REASONS) }).strict(),
|
||||
z.object({ state: z.literal('idle') }).strict(),
|
||||
z.object({ state: z.literal('live') }).strict(),
|
||||
z.object({ state: z.literal('hold') }).strict()
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(anchor) =>
|
||||
anchor.answer.state !== 'blocked' ||
|
||||
(anchor.when.region === 'text' && 'lastOf' in anchor.when.find),
|
||||
"a blocked anchor needs find.lastOf: the blocked layer's prefilter keys on it"
|
||||
)
|
||||
.refine(
|
||||
(anchor) => anchor.when.region !== 'title' || anchor.answer.state === 'idle',
|
||||
'a title anchor answers idle'
|
||||
)
|
||||
|
||||
/** Facts about the agent that are not detection rules. */
|
||||
const ProfileSchema = z
|
||||
.object({
|
||||
/** Text whose presence in a pane's tail makes a tui-idle wait read its visible screen once. */
|
||||
screenProbeBanner: TailLiteral.optional(),
|
||||
/** The screen the rules read: the PTY's own grid, trusted only while the PTY still has that
|
||||
* size, or the live emulator's. Rules keep the source they were recorded against. */
|
||||
screenSource: z.enum(['trusted', 'live']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** The panes no agent file covers: no launch record, and no recognised foreground process. */
|
||||
export const UNKNOWN_PANE_RULES_ID = 'unknown-pane'
|
||||
|
||||
function hasUniqueIds(entries: readonly { id: string }[]): boolean {
|
||||
return new Set(entries.map((entry) => entry.id)).size === entries.length
|
||||
}
|
||||
|
||||
export const AgentStateRulesFileSchema = z
|
||||
.object({
|
||||
id: z.union([
|
||||
z.literal(UNKNOWN_PANE_RULES_ID),
|
||||
z.custom<TuiAgent>(isTuiAgent, 'not a known agent')
|
||||
]),
|
||||
engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION),
|
||||
profile: ProfileSchema.optional(),
|
||||
anchors: z.array(AnchorSchema).max(MAX_RULES),
|
||||
rules: z.array(AgentStateRuleSchema).max(MAX_RULES)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(file) =>
|
||||
!file.rules.some((rule) => rule.when.region === 'screen') ||
|
||||
file.profile?.screenSource !== undefined,
|
||||
'a file with screen rules names its profile.screenSource'
|
||||
)
|
||||
.refine((file) => {
|
||||
const idleTextAnchors = new Set(
|
||||
file.anchors
|
||||
.filter((anchor) => anchor.when.region === 'text' && anchor.answer.state === 'idle')
|
||||
.map((anchor) => anchor.id)
|
||||
)
|
||||
return file.rules.every(
|
||||
(rule) => rule.when.region !== 'text' || idleTextAnchors.has(rule.when.anchor)
|
||||
)
|
||||
}, "a text rule names one of this file's idle text anchors")
|
||||
.refine(
|
||||
(file) => hasUniqueIds(file.anchors) && hasUniqueIds(file.rules),
|
||||
'anchor ids, and rule ids, are unique within the file'
|
||||
)
|
||||
|
||||
export type TextTest = z.infer<typeof TextTestSchema>
|
||||
export type ScreenCondition = z.infer<typeof ScreenConditionSchema>
|
||||
export type AgentStateRuleCondition = z.infer<typeof RuleConditionSchema>
|
||||
export type AgentStateRuleAnswer = z.infer<typeof RuleAnswerSchema>
|
||||
export type Anchor = z.infer<typeof AnchorSchema>
|
||||
export type TextAnchorCondition = z.infer<typeof TextAnchorConditionSchema>
|
||||
export type TitleAnchorCondition = z.infer<typeof TitleAnchorConditionSchema>
|
||||
export type NamedTextAnchor = (typeof NAMED_TEXT_ANCHORS)[number]
|
||||
export type NamedScreenPredicate = (typeof NAMED_SCREEN_PREDICATES)[number]
|
||||
export type NamedTitlePredicate = (typeof NAMED_TITLE_PREDICATES)[number]
|
||||
export type AgentStateRulesFile = z.infer<typeof AgentStateRulesFileSchema>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAgentStateRuleFiles } from './agent-state-rules-catalog'
|
||||
import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text-anchors'
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer'
|
||||
import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection'
|
||||
|
||||
function anchorsOf(anchors: unknown[]) {
|
||||
return compileTextAnchors(
|
||||
parseAgentStateRuleFiles([{ id: 'cursor', engineVersion: 1, anchors, rules: [] }])
|
||||
)
|
||||
}
|
||||
|
||||
describe('blocked anchors', () => {
|
||||
const [menu] = anchorsOf([
|
||||
{
|
||||
id: 'menu',
|
||||
why: 'test',
|
||||
when: {
|
||||
region: 'text',
|
||||
find: { lastOf: 'run it?' },
|
||||
withinLastLines: 4,
|
||||
lines: { atLeast: 2, includingLast: true, test: { regex: '\\([a-z]\\)$' } }
|
||||
},
|
||||
answer: { state: 'blocked', reason: 'agent-approval-prompt' }
|
||||
}
|
||||
]).blocked
|
||||
|
||||
it('reports where the anchor starts once enough choices own the bottom', () => {
|
||||
const text = 'chat\nrun it?\nyes (y)\nno (n)\n'
|
||||
expect(menu(text)).toEqual({
|
||||
answer: { state: 'blocked', reason: 'agent-approval-prompt' },
|
||||
index: text.indexOf('run it?')
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a menu with too few choices, or one no longer at the bottom', () => {
|
||||
expect(menu('run it?\nyes (y)\n')).toBeNull()
|
||||
expect(menu('run it?\nyes (y)\nno (n)\nlater output')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads only the last lines', () => {
|
||||
expect(menu('run it?\na\nb\nyes (y)\nno (n)')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt anchors', () => {
|
||||
const [prompt] = anchorsOf([
|
||||
{
|
||||
id: 'prompt',
|
||||
why: 'test',
|
||||
when: { region: 'text', find: { lastOf: 'banner' }, after: { contains: '→' } },
|
||||
answer: { state: 'idle' }
|
||||
}
|
||||
]).prompts
|
||||
|
||||
it('needs the after test to pass on the text after the last banner', () => {
|
||||
expect(prompt('banner\n→')).toEqual({ answer: { state: 'idle' }, index: 0 })
|
||||
expect(prompt('→ banner')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a bundled busy prompt as live but not ready', () => {
|
||||
expect(findPromptAnchorIndexes('cursor agent\n⠋ generating\n→')).toEqual({
|
||||
live: 0,
|
||||
ready: null
|
||||
})
|
||||
expect(findPromptAnchorIndexes('cursor agent\n→')).toEqual({ live: 0, ready: 0 })
|
||||
expect(findPromptAnchorIndexes('cursor agent\n⠋ starting')).toEqual({ live: null, ready: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the bundled Cursor approval menu', () => {
|
||||
it('blocks once two choices own the bottom of the tail', () => {
|
||||
const menu =
|
||||
'cursor agent\n→ fix it\nrun this command?\n→ run (once) (y)\n skip & tell the agent (esc or n)'
|
||||
expect(detectTerminalWaitBlockedReason(menu)).toBe('agent-approval-prompt')
|
||||
expect(detectTerminalWaitBlockedReason(menu.split('\n').slice(0, -1).join('\n'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the blocked layer prefilter', () => {
|
||||
it('includes every bundled blocked anchor', () => {
|
||||
expect(TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test('Run this command?')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types'
|
||||
import { startOfLastLines } from '../terminal-wait-tail-window'
|
||||
import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers'
|
||||
import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog'
|
||||
import type {
|
||||
Anchor,
|
||||
AgentStateRulesFile,
|
||||
NamedTextAnchor,
|
||||
TextAnchorCondition
|
||||
} from './agent-state-rules-schema'
|
||||
import { findAntigravityComposerIndex } from './antigravity-text-composer'
|
||||
|
||||
export type BlockedTextSignal = { reason: RuntimeTerminalWaitBlockedReason; index: number }
|
||||
|
||||
type TextAnchorHit = { answer: Anchor['answer']; index: number }
|
||||
|
||||
export type TextAnchorFinder = (normalized: string) => TextAnchorHit | null
|
||||
|
||||
const NAMED_TEXT_ANCHOR_FINDERS: Record<NamedTextAnchor, (normalized: string) => number | null> = {
|
||||
'antigravity-text-composer': findAntigravityComposerIndex
|
||||
}
|
||||
|
||||
function compileFind(find: TextAnchorCondition['find']): (text: string) => number | null {
|
||||
if ('predicate' in find) {
|
||||
return NAMED_TEXT_ANCHOR_FINDERS[find.predicate]
|
||||
}
|
||||
return (text) => {
|
||||
const index = text.lastIndexOf(find.lastOf)
|
||||
return index === -1 ? null : index
|
||||
}
|
||||
}
|
||||
|
||||
function compileLineCount(lines: NonNullable<TextAnchorCondition['lines']>): TextMatcher {
|
||||
const test = compileTextTest(lines.test)
|
||||
return (text) => {
|
||||
const rows = text.split('\n')
|
||||
while (rows.length > 0 && rows.at(-1)?.trim() === '') {
|
||||
rows.pop()
|
||||
}
|
||||
return (
|
||||
rows.filter(test).length >= lines.atLeast && (!lines.includingLast || test(rows.at(-1) ?? ''))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function compileTextAnchor(
|
||||
when: TextAnchorCondition,
|
||||
answer: Anchor['answer']
|
||||
): TextAnchorFinder {
|
||||
const { withinLastLines } = when
|
||||
const find = compileFind(when.find)
|
||||
const after = when.after ? compileTextTest(when.after) : null
|
||||
const lines = when.lines ? compileLineCount(when.lines) : null
|
||||
return (text) => {
|
||||
const start = withinLastLines ? startOfLastLines(text, withinLastLines) : 0
|
||||
const region = text.slice(start)
|
||||
const index = find(region)
|
||||
if (index === null || (after && !after(region.slice(index))) || (lines && !lines(region))) {
|
||||
return null
|
||||
}
|
||||
return { answer, index: start + index }
|
||||
}
|
||||
}
|
||||
|
||||
type TextAnchor = { when: TextAnchorCondition; answer: Anchor['answer'] }
|
||||
|
||||
function textAnchorsOf(files: readonly AgentStateRulesFile[]): TextAnchor[] {
|
||||
return files.flatMap((file) =>
|
||||
file.anchors.flatMap(({ when, answer }) => (when.region === 'text' ? [{ when, answer }] : []))
|
||||
)
|
||||
}
|
||||
|
||||
function compileEach(anchors: readonly TextAnchor[]): TextAnchorFinder[] {
|
||||
return anchors.map(({ when, answer }) => compileTextAnchor(when, answer))
|
||||
}
|
||||
|
||||
export function compileTextAnchors(files: readonly AgentStateRulesFile[]): {
|
||||
blocked: TextAnchorFinder[]
|
||||
prompts: TextAnchorFinder[]
|
||||
holds: TextAnchorFinder[]
|
||||
blockedLiterals: string[]
|
||||
screenProbeBanners: string[]
|
||||
} {
|
||||
const anchors = textAnchorsOf(files)
|
||||
const blocked = anchors.filter((anchor) => anchor.answer.state === 'blocked')
|
||||
return {
|
||||
blocked: compileEach(blocked),
|
||||
prompts: compileEach(
|
||||
anchors.filter(({ answer }) => answer.state !== 'blocked' && answer.state !== 'hold')
|
||||
),
|
||||
holds: compileEach(anchors.filter(({ answer }) => answer.state === 'hold')),
|
||||
blockedLiterals: blocked.flatMap(({ when }) =>
|
||||
'lastOf' in when.find ? [when.find.lastOf] : []
|
||||
),
|
||||
screenProbeBanners: files.flatMap((file) => file.profile?.screenProbeBanner ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_ANCHORS = compileTextAnchors(BUNDLED_AGENT_STATE_RULE_FILES)
|
||||
|
||||
/** The literal every blocked anchor needs, for the blocked layer's one-pass prefilter. */
|
||||
export const BLOCKED_ANCHOR_LITERALS: readonly string[] = TEXT_ANCHORS.blockedLiterals
|
||||
|
||||
/** Every rule file's blocked anchor found in the blocked layer's live window. */
|
||||
export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] {
|
||||
return TEXT_ANCHORS.blocked.flatMap((find) => {
|
||||
const hit = find(window)
|
||||
return hit?.answer.state === 'blocked' ? [{ reason: hit.answer.reason, index: hit.index }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest live prompt (`live`: an idle or live anchor, which proves an earlier startup
|
||||
* dialog was answered) and the latest idle one (`ready`) that any rule file's anchors find.
|
||||
*/
|
||||
export function findPromptAnchorIndexes(normalized: string): {
|
||||
live: number | null
|
||||
ready: number | null
|
||||
} {
|
||||
let live: number | null = null
|
||||
let ready: number | null = null
|
||||
for (const find of TEXT_ANCHORS.prompts) {
|
||||
const hit = find(normalized)
|
||||
if (hit === null) {
|
||||
continue
|
||||
}
|
||||
live = Math.max(live ?? -1, hit.index)
|
||||
if (hit.answer.state === 'idle') {
|
||||
ready = Math.max(ready ?? -1, hit.index)
|
||||
}
|
||||
}
|
||||
return { live, ready }
|
||||
}
|
||||
|
||||
/** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */
|
||||
export function showsHoldAnchor(normalized: string): boolean {
|
||||
return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null)
|
||||
}
|
||||
|
||||
export function showsScreenProbeBanner(text: string): boolean {
|
||||
const normalized = text.toLowerCase()
|
||||
return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
|
||||
import { compileTextTest } from './agent-state-rule-matchers'
|
||||
import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog'
|
||||
import type {
|
||||
AgentStateRulesFile,
|
||||
NamedTitlePredicate,
|
||||
TitleAnchorCondition
|
||||
} from './agent-state-rules-schema'
|
||||
|
||||
// Why shared code: the same marker also proves OpenCode presence, so both must read it alike.
|
||||
const NAMED_TITLE_PREDICATES: Record<NamedTitlePredicate, (title: string) => boolean> = {
|
||||
'opencode-native-title': isOpenCodeNativeTitle
|
||||
}
|
||||
|
||||
type TitleAnchorMatcher = (title: string) => boolean
|
||||
|
||||
function compileTitleAnchor(when: TitleAnchorCondition): TitleAnchorMatcher {
|
||||
return 'predicate' in when.match
|
||||
? NAMED_TITLE_PREDICATES[when.match.predicate]
|
||||
: compileTextTest(when.match)
|
||||
}
|
||||
|
||||
function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchorMatcher[] {
|
||||
return files.flatMap((file) =>
|
||||
file.anchors.flatMap(({ when }) => (when.region === 'title' ? [compileTitleAnchor(when)] : []))
|
||||
)
|
||||
}
|
||||
|
||||
const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES)
|
||||
|
||||
/** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */
|
||||
export function showsIdleTitleAnchor(title: string): boolean {
|
||||
return TITLE_ANCHORS.some((matches) => matches(title))
|
||||
}
|
||||
+7
-36
@@ -1,34 +1,4 @@
|
||||
import { isTerminalWaitWhitespace } from './terminal-wait-tail-window'
|
||||
|
||||
/**
|
||||
* Antigravity paints its chrome with cursor addressing, so model/account rows are not stable
|
||||
* line anchors. The idle composer is the only captured marker that survives every ready screen.
|
||||
*/
|
||||
export function findAntigravityReadyPromptIndex(normalized: string): number | null {
|
||||
return findAntigravityComposerIndex(normalized)
|
||||
}
|
||||
|
||||
const SCREEN_RULE_RE = /^─{8,}$/
|
||||
|
||||
/**
|
||||
* The live screen's bottom four rows at an idle composer: rule, caret, rule, `? for shortcuts`.
|
||||
* Why the hint row decides: the caret stays painted through a turn and behind the `/model`
|
||||
* picker, but the hint reads `esc to cancel` mid-turn and in the palette, and the picker covers it.
|
||||
*/
|
||||
export function isAntigravityComposerReadyScreen(screenLines: readonly string[]): boolean {
|
||||
if (screenLines.length < 4) {
|
||||
return false
|
||||
}
|
||||
const [top = '', composer = '', bottom = '', hint = ''] = screenLines
|
||||
.slice(-4)
|
||||
.map((line) => line.trim())
|
||||
return (
|
||||
SCREEN_RULE_RE.test(top) &&
|
||||
isComposerLine(composer) &&
|
||||
SCREEN_RULE_RE.test(bottom) &&
|
||||
hint.toLowerCase().startsWith('? for shortcuts')
|
||||
)
|
||||
}
|
||||
import { isTerminalWaitWhitespace } from '../terminal-wait-tail-window'
|
||||
|
||||
/**
|
||||
* The composer is a bare `>` on the captured 3.7 Flash screens, but agy 1.2.7 paints the active
|
||||
@@ -67,7 +37,12 @@ function isModelRow(line: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function findAntigravityComposerIndex(normalized: string): number | null {
|
||||
/**
|
||||
* The named text anchor `antigravity-text-composer`: the idle composer in the folded text tail.
|
||||
* Why code, not rows: Antigravity paints its chrome with cursor addressing, so model/account rows
|
||||
* are not stable line anchors, and telling them apart takes this whole-tail scan.
|
||||
*/
|
||||
export function findAntigravityComposerIndex(normalized: string): number | null {
|
||||
const contentStart = normalized.lastIndexOf('antigravity cli')
|
||||
if (contentStart === -1) {
|
||||
return null
|
||||
@@ -127,7 +102,3 @@ function findAntigravityComposerIndex(normalized: string): number | null {
|
||||
}
|
||||
return modelAfterComposer && !workspaceAfterComposer ? null : composerStart
|
||||
}
|
||||
|
||||
export function hasAntigravityTerminalHeader(text: string): boolean {
|
||||
return text.toLowerCase().includes('antigravity cli')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "antigravity",
|
||||
"engineVersion": 1,
|
||||
"profile": { "screenProbeBanner": "antigravity cli", "screenSource": "trusted" },
|
||||
"anchors": [
|
||||
{
|
||||
"id": "text_composer",
|
||||
"why": "Without a trusted grid, the folded text tail is all there is; a trailing caret there also belongs to trust, sign-in and model menus, which the named scan rules out.",
|
||||
"when": { "region": "text", "find": { "predicate": "antigravity-text-composer" } },
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "composer_ready",
|
||||
"why": "The bottom four rows at an idle composer. The hint row decides: the caret stays painted through a turn and behind the /model picker, but the hint reads `esc to cancel` mid-turn and in the palette, and the picker covers it. The caret row may carry the edit mode (`> Accept-edits mode: ...`), but never other text: menus prefix their highlighted row with `> ` too. Quiet because a submit repaints the composer for a moment mid-turn.",
|
||||
"priority": 500,
|
||||
"when": {
|
||||
"region": "screen",
|
||||
"rows": [
|
||||
{ "regex": "^─{8,}$" },
|
||||
{ "regex": "^>$|^>\\s+[a-z][a-z-]*\\s+mode:\\s", "ignoreCase": true },
|
||||
{ "regex": "^─{8,}$" },
|
||||
{ "regex": "^\\? for shortcuts", "ignoreCase": true }
|
||||
]
|
||||
},
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": true }
|
||||
},
|
||||
{
|
||||
"id": "composer_not_shown",
|
||||
"why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.",
|
||||
"priority": 100,
|
||||
"when": { "region": "screen" },
|
||||
"answer": { "state": "hold" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { escapeRegex } from '../../../shared/string-utils'
|
||||
import { findStartupDialogBlockedSignals } from '../startup-dialog-blocked-signals'
|
||||
import { startOfLastNonBlankLines } from '../terminal-wait-tail-window'
|
||||
import {
|
||||
BLOCKED_ANCHOR_LITERALS,
|
||||
findBlockedAnchorSignals,
|
||||
showsHoldAnchor,
|
||||
type BlockedTextSignal
|
||||
} from './agent-state-text-anchors'
|
||||
|
||||
/**
|
||||
* The shared blocked layer, read before any agent's own rules. Why shared and positional: a tail
|
||||
* can show any agent's dialog whatever the pane runs, and an answered dialog stays in it, so the
|
||||
* blocker painted latest wins.
|
||||
*/
|
||||
|
||||
const BUILT_IN_SENTINEL_RE =
|
||||
/update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/
|
||||
|
||||
/** Matches any line that may carry a blocker; a cheap negative test before the full scan. */
|
||||
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = new RegExp(
|
||||
[BUILT_IN_SENTINEL_RE.source, ...BLOCKED_ANCHOR_LITERALS.map(escapeRegex)].join('|'),
|
||||
'i'
|
||||
)
|
||||
|
||||
// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the
|
||||
// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review,
|
||||
// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list.
|
||||
const LIVE_PROMPT_TAIL_LINES = 12
|
||||
|
||||
export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSignal | null {
|
||||
const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES)
|
||||
const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart)
|
||||
// Why: one combined negative scan avoids a dozen searches when no prompt can match.
|
||||
if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
const signal = findBlockedSignalInLiveWindow(normalized)
|
||||
// Why: callers compare this index against ready-header indexes found over the full tail.
|
||||
return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart }
|
||||
}
|
||||
|
||||
/** Whether a ready sign at `readyIndex` still owns the text: no blocker was painted after it. */
|
||||
export function isUnblockedAfter(normalized: string, readyIndex: number | null): boolean {
|
||||
if (readyIndex === null) {
|
||||
return false
|
||||
}
|
||||
const blockedSignal = findTerminalWaitBlockedSignal(normalized)
|
||||
return blockedSignal === null || blockedSignal.index <= readyIndex
|
||||
}
|
||||
|
||||
/** Unblocked, and no hold anchor says the agent behind it is still starting: input would land. */
|
||||
export function isSettledAfter(normalized: string, readyIndex: number | null): boolean {
|
||||
return isUnblockedAfter(normalized, readyIndex) && !showsHoldAnchor(normalized)
|
||||
}
|
||||
|
||||
function findBlockedSignalInLiveWindow(normalized: string): BlockedTextSignal | null {
|
||||
const candidates = findStartupDialogBlockedSignals(normalized)
|
||||
const trustIndex = Math.max(
|
||||
normalized.lastIndexOf('do you trust'),
|
||||
normalized.lastIndexOf('trust this'),
|
||||
normalized.lastIndexOf('trusted workspace')
|
||||
)
|
||||
const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex)
|
||||
if (
|
||||
trustIndex !== -1 &&
|
||||
(trustSegment.includes('workspace') ||
|
||||
trustSegment.includes('folder') ||
|
||||
trustSegment.includes('directory') ||
|
||||
trustSegment.includes('repo'))
|
||||
) {
|
||||
// Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog.
|
||||
candidates.push({ reason: 'agent-trust-workspace', index: trustIndex })
|
||||
}
|
||||
const interactivePromptIndex = Math.max(
|
||||
normalized.lastIndexOf('press enter to confirm'),
|
||||
normalized.lastIndexOf('press enter to continue'),
|
||||
normalized.lastIndexOf('press enter to view'),
|
||||
normalized.lastIndexOf('press enter to insert'),
|
||||
normalized.lastIndexOf('press t to trust')
|
||||
)
|
||||
const interactivePromptContext =
|
||||
interactivePromptIndex === -1
|
||||
? ''
|
||||
: normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200)
|
||||
// Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is
|
||||
// that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies
|
||||
// on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane.
|
||||
const hasInteractiveDialogContext =
|
||||
interactivePromptContext.includes('codex') ||
|
||||
interactivePromptContext.includes('permission') ||
|
||||
interactivePromptContext.includes('sandbox') ||
|
||||
interactivePromptContext.includes('trust') ||
|
||||
interactivePromptContext.includes('hook')
|
||||
if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) {
|
||||
const contextStart = Math.max(0, interactivePromptIndex - 600)
|
||||
const hasSpecificPromptInContext = candidates.some(
|
||||
(candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex
|
||||
)
|
||||
if (!hasSpecificPromptInContext) {
|
||||
candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex })
|
||||
}
|
||||
}
|
||||
// Why after the generic prompt: it yields only to the startup and trust dialogs above.
|
||||
candidates.push(...findBlockedAnchorSignals(normalized))
|
||||
const permissionPromptIndex = Math.max(
|
||||
normalized.lastIndexOf('permission required'),
|
||||
normalized.lastIndexOf('requires permission')
|
||||
)
|
||||
if (permissionPromptIndex !== -1) {
|
||||
const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500)
|
||||
const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) =>
|
||||
permissionSegment.includes(choice)
|
||||
).length
|
||||
if (decisionCount >= 2) {
|
||||
// Why neutral: an approval dialog with named choices identifies no agent; older hosts publish
|
||||
// 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member --
|
||||
// remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no
|
||||
// consumer switches exhaustively on it.
|
||||
// Why alias rather than drop the old spelling: preserve the existing remote receipt value for
|
||||
// mixed-version clients -- an older host still publishes codex-* on this path.
|
||||
candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex })
|
||||
}
|
||||
}
|
||||
return candidates.length > 0
|
||||
? candidates.reduce((latest, candidate) =>
|
||||
candidate.index > latest.index ? candidate : latest
|
||||
)
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "claude",
|
||||
"engineVersion": 1,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "idle_title_glyph",
|
||||
"why": "Claude Code prefixes its title with `✳` at rest; a working title carries a spinner instead.",
|
||||
"when": { "region": "title", "match": { "regex": "^✳" } },
|
||||
"answer": { "state": "idle" }
|
||||
},
|
||||
{
|
||||
"id": "idle_title_ascii",
|
||||
"why": "Claude Code's ASCII rest prefix, `* ` (`. ` while working).",
|
||||
"when": { "region": "title", "match": { "regex": "^\\* " } },
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "Claude announces rest with its own `✳` title, while a shell auto-title (`claude`, `claude ~/repo`) names it from launch, over the workspace-trust dialog or a busy turn alike, so a bare name counts only once quiet (#6011).",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "cline",
|
||||
"engineVersion": 1,
|
||||
"profile": { "screenSource": "trusted" },
|
||||
"anchors": [],
|
||||
"rules": [
|
||||
{
|
||||
"id": "composer_ready",
|
||||
"why": "Cline 3.0.x's empty composer box (startup, after-turn and Plan placeholders) over its Plan/Act mode row, which sits at most two rows above the bottom. Quiet and no spinner above because Cline repaints the same box while a reply streams and its spinner row has scrolled away.",
|
||||
"priority": 500,
|
||||
"when": {
|
||||
"region": "screen",
|
||||
"rows": [
|
||||
{ "regex": "^─{8,}$" },
|
||||
{
|
||||
"regex": "^❯ (?:what can i do for you\\?|ask anything\\.\\.\\.|plan something\\.\\.\\.)$",
|
||||
"ignoreCase": true
|
||||
},
|
||||
{ "regex": "^─{8,}$" },
|
||||
{ "regex": "[○●] plan [○●] act \\(tab\\)$", "ignoreCase": true }
|
||||
],
|
||||
"endsWithinBottom": 3,
|
||||
"noneAbove": { "regex": "[⠀-⣿]" }
|
||||
},
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": true }
|
||||
},
|
||||
{
|
||||
"id": "composer_not_shown",
|
||||
"why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.",
|
||||
"priority": 100,
|
||||
"when": { "region": "screen" },
|
||||
"answer": { "state": "hold" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+19
-33
@@ -1,3 +1,5 @@
|
||||
import { isUnblockedAfter } from './blocked-text-layer'
|
||||
|
||||
// Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title.
|
||||
const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m
|
||||
// Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat.
|
||||
@@ -23,45 +25,29 @@ function findCodexHeader(screen: string): { index: number; text: string } | null
|
||||
return { index, text }
|
||||
}
|
||||
|
||||
/** The 0.150-0.157 header, which only a grid reassembles (see isCodexScreenHeaderReady). */
|
||||
export function findCodexScreenReadyPromptIndex(screen: string): number | null {
|
||||
/**
|
||||
* The named screen predicate `codex-header-ready`: the 0.150-0.157 header, loaded, with no blocker
|
||||
* painted below it. Why the screen: Codex repaints that header by cell diff (`ESC[5;3Hdir
|
||||
* ESC[5;7Hctory:`), which only a grid reassembles; the line-folded wait text reads `dirctory:`.
|
||||
*/
|
||||
export function isCodexHeaderReadyScreen(screen: string): boolean {
|
||||
const header = findCodexHeader(screen)
|
||||
return header !== null &&
|
||||
return (
|
||||
header !== null &&
|
||||
header.text.includes('model:') &&
|
||||
header.text.includes('directory:') &&
|
||||
!CODEX_HEADER_LOADING_RE.test(header.text)
|
||||
? header.index
|
||||
: null
|
||||
}
|
||||
|
||||
// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live
|
||||
// screen shows no header then, while the text copy keeps the provisional one until the live chat
|
||||
// paints its footer after it (a later model repaint rewrites only the value, never the label).
|
||||
// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`);
|
||||
// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first.
|
||||
export function isCodexProvisionalStartupText(normalized: string): boolean {
|
||||
const headerIndex = normalized.lastIndexOf('openai codex')
|
||||
if (headerIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const loading = /model:\s+loading/.exec(normalized.slice(headerIndex))
|
||||
return loading !== null && !normalized.includes('·', headerIndex + loading.index)
|
||||
}
|
||||
|
||||
// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the
|
||||
// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered.
|
||||
export function findCodexHeaderIndex(normalized: string): number | null {
|
||||
const index = normalized.lastIndexOf('openai codex (v')
|
||||
return index === -1 ? null : index
|
||||
!CODEX_HEADER_LOADING_RE.test(header.text) &&
|
||||
isUnblockedAfter(screen, header.index)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 1b, codex panes only: the empty composer with no busy status row just above it and no
|
||||
* header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header
|
||||
* away, so this is its only version-stable rest body. No dialog check: every Codex dialog
|
||||
* replaces the composer, while an answer ending "Would you like to…?" must not block the lane.
|
||||
* The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status
|
||||
* timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a
|
||||
* The named screen predicate `codex-composer-ready`: the empty composer with no busy status row
|
||||
* just above it and no header load. Codex 0.158 dropped `model:`/`directory:`, and a long session
|
||||
* scrolls the header away, so this is its only version-stable rest body. No dialog check: every
|
||||
* Codex dialog replaces the composer, while an answer ending "Would you like to…?" must not block
|
||||
* the lane. The mid-turn guard is quiescence, fed by the ~100 ms title spinner and status timer;
|
||||
* `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a
|
||||
* `tui.terminal_title` without activity/spinner removes it.
|
||||
*/
|
||||
export function isCodexComposerReadyScreen(screen: string): boolean {
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"id": "codex",
|
||||
"engineVersion": 1,
|
||||
"profile": { "screenSource": "live" },
|
||||
"anchors": [
|
||||
{
|
||||
"id": "ready_header",
|
||||
"why": "The startup header with its model and directory labels is the stable ready text across versions; Codex prints permissions only in YOLO mode. Its last occurrence counts, so a header repainted after a dialog proves the dialog was answered.",
|
||||
"when": {
|
||||
"region": "text",
|
||||
"find": { "lastOf": "openai codex" },
|
||||
"after": { "all": [{ "contains": "model:" }, { "contains": "directory:" }] }
|
||||
},
|
||||
"answer": { "state": "idle" }
|
||||
},
|
||||
{
|
||||
"id": "header",
|
||||
"why": "Codex repaints its whole screen, header included, once a startup dialog closes, and no dialog draws the header. 0.158's header has no labels, so the header alone proves an earlier dialog was answered, but not that Codex is ready.",
|
||||
"when": { "region": "text", "find": { "lastOf": "openai codex (v" } },
|
||||
"answer": { "state": "live" }
|
||||
},
|
||||
{
|
||||
"id": "provisional_startup",
|
||||
"why": "0.157 paints a provisional header (`model: loading`) while its daemon starts and discards input typed behind it, so no ready text may settle a wait until the live chat paints a footer row, each of which draws a `·`. Read from the text because 0.157 leaves its alternate screen while the daemon starts; a later model repaint rewrites only the value, never the label.",
|
||||
"when": {
|
||||
"region": "text",
|
||||
"find": { "lastOf": "openai codex" },
|
||||
"after": {
|
||||
"all": [{ "regex": "model:\\s+loading" }],
|
||||
"none": [{ "regex": "model:\\s+loading[\\s\\S]*·" }]
|
||||
}
|
||||
},
|
||||
"answer": { "state": "hold" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "header_ready",
|
||||
"why": "The 0.150-0.157 header, loaded and with no blocker below it, read from the live screen because Codex repaints it by cell diff, which only a grid reassembles. Quiet because the header stays painted through a turn.",
|
||||
"priority": 500,
|
||||
"when": { "region": "screen", "predicate": "codex-header-ready" },
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": true }
|
||||
},
|
||||
{
|
||||
"id": "composer_ready",
|
||||
"why": "The empty composer with no busy status row above it: 0.158 dropped the header labels and a long session scrolls the header away, so this is the version-stable rest screen. Quiet because Codex keeps the composer painted mid-turn, and skipped without an output clock, where nothing could tell a running turn apart.",
|
||||
"priority": 500,
|
||||
"when": { "region": "screen", "predicate": "codex-composer-ready" },
|
||||
"answer": {
|
||||
"state": "idle",
|
||||
"strength": "strong",
|
||||
"requiresQuiet": true,
|
||||
"withoutClock": "skip"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "text_header_ready",
|
||||
"why": "The ready header in the text tail, settled. Quiet because it stays in the tail through a turn; an idle Codex titles its pane with its cwd, so no title says it is at rest.",
|
||||
"priority": 500,
|
||||
"when": { "region": "text", "anchor": "ready_header" },
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": true }
|
||||
},
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "A shell auto-title names Codex from launch, over a startup dialog or a busy turn alike, while its hooks drive an explicit `Codex ready` title at rest, so a bare name counts only once quiet (#6011).",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "cursor",
|
||||
"engineVersion": 1,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "approval_menu",
|
||||
"why": "cursor-agent has no approval hook, so its key-bound menu is the only authority. Bounded to the last 8 lines because an answered menu stays in scrollback, and each choice must end in a selectable key because narration can repeat the menu's wording.",
|
||||
"when": {
|
||||
"region": "text",
|
||||
"find": { "lastOf": "run this command?" },
|
||||
"withinLastLines": 8,
|
||||
"lines": {
|
||||
"atLeast": 2,
|
||||
"includingLast": true,
|
||||
"test": {
|
||||
"all": [
|
||||
{
|
||||
"regex": "\\((?:shift\\+tab|ctrl\\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\\u21b5\\u21e7\\u21b9\\u238b\\u23ce]{1,3})\\)\\s*$"
|
||||
}
|
||||
],
|
||||
"any": [
|
||||
{ "contains": "run (once)" },
|
||||
{ "contains": "to allowlist?" },
|
||||
{ "contains": "run everything" },
|
||||
{ "contains": "skip & tell the agent" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"answer": { "state": "blocked", "reason": "agent-approval-prompt" }
|
||||
},
|
||||
{
|
||||
"id": "prompt_busy",
|
||||
"why": "The banner's last occurrence skips the trust dialog's own `Cursor Agent` text; `→` is the persistent input prompt. cursor-agent emits no idle title, so a braille spinner after the banner is the only busy sign; a busy prompt still proves an earlier dialog was answered.",
|
||||
"when": {
|
||||
"region": "text",
|
||||
"find": { "lastOf": "cursor agent" },
|
||||
"after": { "all": [{ "contains": "→" }, { "regex": "[⠁-⣿]" }] }
|
||||
},
|
||||
"answer": { "state": "live" }
|
||||
},
|
||||
{
|
||||
"id": "prompt_idle",
|
||||
"why": "The same prompt with no spinner after the banner.",
|
||||
"when": {
|
||||
"region": "text",
|
||||
"find": { "lastOf": "cursor agent" },
|
||||
"after": { "all": [{ "contains": "→" }], "none": [{ "regex": "[⠁-⣿]" }] }
|
||||
},
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "gemini",
|
||||
"engineVersion": 1,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "Gemini CLI marks rest with `◇` in its title (`✦` while working, `✋` on a permission).",
|
||||
"when": { "region": "title", "match": { "contains": "◇" } },
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "name_title",
|
||||
"why": "Its `◇` rest title would justify asking a name-only title for quiet, but Gemini is not yet held to it: a name-only title settles once held, with no quiet asked.",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "omp",
|
||||
"engineVersion": 1,
|
||||
"anchors": [],
|
||||
"rules": [
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "Its `π - <session>` rest title is Pi's title anchor (pi.json), which every pane reads. Orca writes an explicit `OMP ready` title at rest, while a shell auto-title names OMP from launch, mid-turn too, so a bare name counts only once quiet (#6011).",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"engineVersion": 1,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "native_title",
|
||||
"why": "OpenCode's own session title (`OC | <session>`, after an optional wrapper label or status glyph). It unblocks waits on hookless remote panes; guarded writes corroborate it. Named because presence detection reads the same marker.",
|
||||
"when": { "region": "title", "match": { "predicate": "opencode-native-title" } },
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "name_title",
|
||||
"why": "OpenCode owns its session title, so Orca writes no `ready` title over it; its name is its only other rest sign, so it settles once held, with no quiet asked.",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "opencode2",
|
||||
"engineVersion": 1,
|
||||
"anchors": [],
|
||||
"rules": [
|
||||
{
|
||||
"id": "name_title",
|
||||
"why": "Its `OC |` rest title is OpenCode's title anchor (opencode.json), which every pane reads. Its name is its only other rest sign, so it settles once held, with no quiet asked.",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "pi",
|
||||
"engineVersion": 1,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "Pi, and OMP after it, title the pane `π - <session>` at rest (`π ⠋ <session>` while working).",
|
||||
"when": { "region": "title", "match": { "regex": "^π - " } },
|
||||
"answer": { "state": "idle" }
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "idle_title",
|
||||
"why": "Orca writes an explicit `Pi ready` title at rest, while a shell auto-title names Pi from launch, mid-turn too, so a bare name counts only once quiet (#6011).",
|
||||
"priority": 100,
|
||||
"when": { "region": "title", "status": "idle" },
|
||||
"answer": { "state": "idle", "strength": "weak", "requiresQuiet": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "prime-agent",
|
||||
"engineVersion": 1,
|
||||
"profile": { "screenSource": "trusted" },
|
||||
"anchors": [],
|
||||
"rules": [
|
||||
{
|
||||
"id": "composer_ready",
|
||||
"why": "Prime Agent 0.9.5+ at an idle composer: a bare `>` over the `← manage` footer, optionally under the view-mode hint (`Details mode` in 0.9.8, `Collapsed mode` in 0.9.5). Both stay painted for a whole turn, so only the status row above them (`⠦ Writing · 6s`) says a turn is running; quiet because the composer also paints just before the first-launch question.",
|
||||
"priority": 500,
|
||||
"when": {
|
||||
"region": "screen",
|
||||
"rows": [
|
||||
{ "none": [{ "regex": "[⠀-⣿]" }] },
|
||||
{
|
||||
"optional": { "regex": "^\\S+ mode \\(ctrl\\+o to expand\\)$", "ignoreCase": true }
|
||||
},
|
||||
{ "regex": "^>$" },
|
||||
{ "regex": "^← manage" }
|
||||
]
|
||||
},
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": true }
|
||||
},
|
||||
{
|
||||
"id": "composer_not_shown",
|
||||
"why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.",
|
||||
"priority": 100,
|
||||
"when": { "region": "screen" },
|
||||
"answer": { "state": "hold" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "unknown-pane",
|
||||
"engineVersion": 1,
|
||||
"profile": { "screenSource": "live" },
|
||||
"anchors": [],
|
||||
"rules": [
|
||||
{
|
||||
"id": "codex_header_ready",
|
||||
"why": "An adopted pane can run Codex without Orca knowing it. Codex's loaded 0.150-0.157 header on the live screen, with no blocker below it, settles such a pane at once (unlike a Codex pane, which holds it to quiet).",
|
||||
"priority": 500,
|
||||
"when": { "region": "screen", "predicate": "codex-header-ready" },
|
||||
"answer": { "state": "idle", "strength": "strong", "requiresQuiet": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
readRuntimeFixture,
|
||||
replayTranscript
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness'
|
||||
import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite'
|
||||
import {
|
||||
describeScreenRuledAgentTranscripts,
|
||||
readsIdleComposer
|
||||
} from './screen-ruled-agent-transcript-suite'
|
||||
import {
|
||||
isKnownReadyPromptBody,
|
||||
isKnownReadyPromptPreview,
|
||||
@@ -43,7 +45,6 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => {
|
||||
describeScreenRuledAgentTranscripts({
|
||||
agent: 'antigravity',
|
||||
foregroundProcess: 'agy',
|
||||
rule: isAntigravityComposerReadyScreen,
|
||||
ready: READY,
|
||||
notReady: NOT_READY,
|
||||
// Why these: the line-folded text rule reads only these ready screens.
|
||||
@@ -93,7 +94,7 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => {
|
||||
for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) {
|
||||
submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools'))
|
||||
answered ||= ruledScreenLines.some((line) => line.trim() === 'ok')
|
||||
if (submitted && !answered && isAntigravityComposerReadyScreen(ruledScreenLines)) {
|
||||
if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) {
|
||||
readyMidTurn += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
readRuntimeFixture,
|
||||
replayTranscript
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isClineComposerReadyScreen } from './cline-terminal-readiness'
|
||||
import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite'
|
||||
import {
|
||||
describeScreenRuledAgentTranscripts,
|
||||
readsIdleComposer
|
||||
} from './screen-ruled-agent-transcript-suite'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
@@ -38,7 +40,6 @@ describe('Cline readiness from captured bytes', () => {
|
||||
describeScreenRuledAgentTranscripts({
|
||||
agent: 'cline',
|
||||
foregroundProcess: 'cline',
|
||||
rule: isClineComposerReadyScreen,
|
||||
ready: READY,
|
||||
notReady: NOT_READY,
|
||||
// Why all: an idle Cline is quiet, so the quiet-process lane settles it.
|
||||
@@ -55,7 +56,7 @@ describe('Cline readiness from captured bytes', () => {
|
||||
// Why only quiescence can refuse it: the streaming reply has scrolled its spinner away.
|
||||
it('paints the same empty composer while a reply streams', async () => {
|
||||
const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40)
|
||||
expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(true)
|
||||
expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses every frame whose spinner row is still on screen', async () => {
|
||||
@@ -67,7 +68,7 @@ describe('Cline readiness from captured bytes', () => {
|
||||
)) {
|
||||
if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) {
|
||||
spinnerFrames += 1
|
||||
expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(false)
|
||||
expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false)
|
||||
}
|
||||
}
|
||||
// Presence precondition: the thinking spinner was painted above the composer.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
const CLINE_RULE_RE = /^─{8,}$/
|
||||
// Captured placeholders: startup (Act), after a turn (Act), and Plan mode.
|
||||
const CLINE_EMPTY_COMPOSERS: ReadonlySet<string> = new Set([
|
||||
'❯ what can i do for you?',
|
||||
'❯ ask anything...',
|
||||
'❯ plan something...'
|
||||
])
|
||||
const CLINE_MODE_ROW_RE = /[○●] plan [○●] act \(tab\)$/
|
||||
const BRAILLE_SPINNER_RE = /[⠀-⣿]/
|
||||
|
||||
/**
|
||||
* Cline 3.0.x's empty composer box with its Plan/Act status rows under it.
|
||||
* Why it proves only that the composer is up: Cline repaints the same box while a reply streams
|
||||
* and its spinner row has scrolled away, so callers must also require a quiet stream.
|
||||
*/
|
||||
export function isClineComposerReadyScreen(screenLines: readonly string[]): boolean {
|
||||
const lines = screenLines.map((line) => line.trim().toLowerCase())
|
||||
const modeRow = lines.findLastIndex((line) => CLINE_MODE_ROW_RE.test(line))
|
||||
// Why bounded: the mode row sits above the cwd and auto-approve rows, never higher.
|
||||
if (modeRow < 3 || modeRow < lines.length - 3) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
CLINE_RULE_RE.test(lines[modeRow - 1]) &&
|
||||
CLINE_EMPTY_COMPOSERS.has(lines[modeRow - 2]) &&
|
||||
CLINE_RULE_RE.test(lines[modeRow - 3]) &&
|
||||
!lines.slice(0, modeRow - 3).some((line) => BRAILLE_SPINNER_RE.test(line))
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
replayTranscript,
|
||||
type TranscriptReplayFrame
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isCodexComposerReadyScreen } from './codex-terminal-readiness'
|
||||
import { isCodexComposerReadyScreen } from './agent-state-rules/codex-screen-predicates'
|
||||
import {
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptBody,
|
||||
@@ -197,7 +197,7 @@ describe('Codex composer ready screen, frame by frame', () => {
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(waitText, 'codex', () => screenLines),
|
||||
agent: 'codex',
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
})
|
||||
@@ -294,7 +294,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => {
|
||||
readQuietReadyBodyEvidence: () =>
|
||||
isQuietReadyScreenBody(waitText, 'codex', () => screenLines),
|
||||
agent: 'codex',
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
})
|
||||
@@ -351,7 +351,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => {
|
||||
isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null),
|
||||
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header),
|
||||
agent: 'codex',
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
})
|
||||
@@ -380,7 +380,7 @@ describe('reading the live screen never removes quiet-lane readiness', () => {
|
||||
readPositiveBodyEvidence: () =>
|
||||
isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true),
|
||||
agent,
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS
|
||||
} satisfies Omit<TuiIdleEvaluationInput, 'record' | 'readQuietReadyBodyEvidence'>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { startOfLastLines } from './terminal-wait-tail-window'
|
||||
|
||||
// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority.
|
||||
const CURSOR_APPROVAL_CHOICE_MARKERS = [
|
||||
'run (once)',
|
||||
'to allowlist?',
|
||||
'run everything',
|
||||
'skip & tell the agent'
|
||||
]
|
||||
// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live.
|
||||
const CURSOR_APPROVAL_TAIL_LINES = 8
|
||||
|
||||
export function findCursorApprovalPromptIndex(normalized: string): number | null {
|
||||
const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES)
|
||||
const tail = normalized.slice(windowStart)
|
||||
if (!tail.includes('run this command?')) {
|
||||
return null
|
||||
}
|
||||
const lines = tail.split('\n')
|
||||
while (lines.length > 0 && lines.at(-1)?.trim() === '') {
|
||||
lines.pop()
|
||||
}
|
||||
let matchedLines = 0
|
||||
let lastChoiceLine = -1
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (!isCursorApprovalChoiceLine(lines[index])) {
|
||||
continue
|
||||
}
|
||||
matchedLines += 1
|
||||
lastChoiceLine = index
|
||||
}
|
||||
return matchedLines >= 2 && lastChoiceLine === lines.length - 1
|
||||
? windowStart + tail.lastIndexOf('run this command?')
|
||||
: null
|
||||
}
|
||||
|
||||
// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key.
|
||||
const CURSOR_APPROVAL_CHOICE_KEY_RE =
|
||||
/\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/
|
||||
|
||||
function isCursorApprovalChoiceLine(line: string): boolean {
|
||||
return (
|
||||
CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) &&
|
||||
CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker))
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isKnownReadyPromptBody,
|
||||
isKnownReadyPromptSettled
|
||||
} from './terminal-wait-detection'
|
||||
import { getScreenReadyRule } from './screen-ruled-agent-readiness'
|
||||
import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine'
|
||||
import { restoreProjectedComposerDraft } from './orca-runtime-terminal-projection'
|
||||
import type {
|
||||
RuntimeTerminalWait,
|
||||
@@ -55,7 +55,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
if (providerTimeoutMs < 1) {
|
||||
return
|
||||
}
|
||||
const screenRule = getScreenReadyRule(agent)
|
||||
const screenRule = readsTrustedScreen(agent)
|
||||
void withTimeout(
|
||||
this.readTerminal(waiter.handle, screenRule ? { screen: true } : {}, {
|
||||
timeoutMs: providerTimeoutMs,
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
readRuntimeFixture,
|
||||
replayTranscript
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness'
|
||||
import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite'
|
||||
import {
|
||||
describeScreenRuledAgentTranscripts,
|
||||
readsIdleComposer
|
||||
} from './screen-ruled-agent-transcript-suite'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
@@ -42,7 +44,6 @@ describe('Prime Agent readiness from captured bytes', () => {
|
||||
describeScreenRuledAgentTranscripts({
|
||||
agent: 'prime-agent',
|
||||
foregroundProcess: 'prime-agent',
|
||||
rule: isPrimeAgentComposerReadyScreen,
|
||||
ready: READY,
|
||||
notReady: NOT_READY,
|
||||
// Why all: an idle Prime is quiet, so the quiet-process lane settles it.
|
||||
@@ -70,7 +71,7 @@ describe('Prime Agent readiness from captured bytes', () => {
|
||||
const screen = screenOf(ruledScreenLines)
|
||||
markerSeen ||= submittedMarker !== null && screen.includes(submittedMarker)
|
||||
questionSeen ||= screen.includes('Share agent traces')
|
||||
if (markerSeen && !questionSeen && isPrimeAgentComposerReadyScreen(ruledScreenLines)) {
|
||||
if (markerSeen && !questionSeen && readsIdleComposer('prime-agent', ruledScreenLines)) {
|
||||
readyAfterMarker += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
const PRIME_FOOTER_PREFIX = '← manage'
|
||||
// 0.9.8 paints `Details mode`, 0.9.5 `Collapsed mode`.
|
||||
const PRIME_VIEW_MODE_HINT_RE = /^\S+ mode \(ctrl\+o to expand\)$/i
|
||||
const BRAILLE_SPINNER_RE = /[⠀-⣿]/
|
||||
|
||||
/**
|
||||
* Prime Agent 0.9.5+ at an idle composer: a bare `>` over the `← manage` footer.
|
||||
* Why the spinner veto: the footer and the caret both stay painted for a whole turn; only the
|
||||
* status row Prime keeps directly above them (`⠦ Writing · 6s`) says a turn is running.
|
||||
*/
|
||||
export function isPrimeAgentComposerReadyScreen(screenLines: readonly string[]): boolean {
|
||||
const footer = screenLines.at(-1)?.trim() ?? ''
|
||||
const composer = screenLines.at(-2)?.trim()
|
||||
if (!footer.startsWith(PRIME_FOOTER_PREFIX) || composer !== '>') {
|
||||
return false
|
||||
}
|
||||
let statusIndex = screenLines.length - 3
|
||||
if (PRIME_VIEW_MODE_HINT_RE.test(screenLines[statusIndex]?.trim() ?? '')) {
|
||||
statusIndex -= 1
|
||||
}
|
||||
return !BRAILLE_SPINNER_RE.test(screenLines[statusIndex] ?? '')
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../shared/runtime-environments'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
|
||||
export async function verifyRuntimeEnvironmentIdentity(
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
buildTerminalWaitResult,
|
||||
getTerminalState
|
||||
} from './terminal-wait-results'
|
||||
import { getScreenReadyRule } from './screen-ruled-agent-readiness'
|
||||
import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness'
|
||||
import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine'
|
||||
import { showsScreenProbeBanner } from './agent-state-rules/agent-state-text-anchors'
|
||||
import { buildTerminalWaitText } from './terminal-wait-tail-state'
|
||||
import {
|
||||
evaluateTuiIdle,
|
||||
@@ -27,9 +27,9 @@ import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-re
|
||||
|
||||
/**
|
||||
* A pane with no retained bytes and no status has only its provider's screen to read, and one
|
||||
* whose tail shows the Antigravity banner is probed as before screen rules. So is a clockless
|
||||
* screen-ruled pane whatever its status: a re-attached pane's own model can be untrusted. A
|
||||
* clocked one settles through the poll, once quiet.
|
||||
* whose tail shows a rule file's `profile.screenProbeBanner` is probed as before screen rules. So
|
||||
* is a clockless pane whose rules read the trusted screen, whatever its status: a re-attached
|
||||
* pane's own model can be untrusted. A clocked one settles through the poll, once quiet.
|
||||
*/
|
||||
function shouldProbeVisibleScreen(
|
||||
paneAgent: TuiAgent | null,
|
||||
@@ -38,8 +38,8 @@ function shouldProbeVisibleScreen(
|
||||
): boolean {
|
||||
return (
|
||||
(record.lastAgentStatus === null && waitText.length === 0) ||
|
||||
hasAntigravityTerminalHeader(waitText) ||
|
||||
(getScreenReadyRule(paneAgent) !== null && record.lastOutputAt === null)
|
||||
showsScreenProbeBanner(waitText) ||
|
||||
(readsTrustedScreen(paneAgent) && record.lastOutputAt === null)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness'
|
||||
import { isClineComposerReadyScreen } from './cline-terminal-readiness'
|
||||
import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness'
|
||||
|
||||
type ScreenReadyRule = (screenLines: readonly string[]) => boolean
|
||||
|
||||
/**
|
||||
* Agents whose live screen decides readiness. Each rule reads an idle composer off the grid,
|
||||
* which a folded text tail loses to cursor addressing. Why a clocked pane waits for quiet too:
|
||||
* the captures paint that composer for a moment mid-turn (a submit repaint, a spinner row
|
||||
* erased before its redraw) and, for Prime, just before its first-launch question.
|
||||
*/
|
||||
const SCREEN_READY_RULES: Partial<Record<TuiAgent, ScreenReadyRule>> = {
|
||||
antigravity: isAntigravityComposerReadyScreen,
|
||||
cline: isClineComposerReadyScreen,
|
||||
'prime-agent': isPrimeAgentComposerReadyScreen
|
||||
}
|
||||
|
||||
export function getScreenReadyRule(agent: TuiAgent | null | undefined): ScreenReadyRule | null {
|
||||
return agent ? (SCREEN_READY_RULES[agent] ?? null) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's screen rule applied to its live screen, or null when it has no rule or no
|
||||
* trustworthy screen is readable. Why a verdict outranks every text rule: the screen is what
|
||||
* the folded text was copied from, and the text cannot see a picker or dialog covering it.
|
||||
*/
|
||||
export function readScreenRuledVerdict(
|
||||
agent: TuiAgent | null | undefined,
|
||||
readScreenLines: () => readonly string[] | null
|
||||
): boolean | null {
|
||||
const rule = getScreenReadyRule(agent)
|
||||
const screenLines = rule ? readScreenLines() : null
|
||||
return rule && screenLines ? rule(screenLines) : null
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type TranscriptReplayResize
|
||||
} from './agent-transcript-replay-test-harness'
|
||||
import { isKnownReadyPromptBody, isQuietReadyScreenBody } from './terminal-wait-detection'
|
||||
import { evaluateAgentStateRules } from './agent-state-rules/agent-state-rules-engine'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
export type ScreenRuledFixture = { name: string; cols: number; rows: number; what: string }
|
||||
@@ -19,7 +20,6 @@ export type ScreenRuledFixture = { name: string; cols: number; rows: number; wha
|
||||
export type ScreenRuledAgentSuite = {
|
||||
agent: TuiAgent
|
||||
foregroundProcess: string
|
||||
rule: (screenLines: readonly string[]) => boolean
|
||||
ready: readonly ScreenRuledFixture[]
|
||||
notReady: readonly ScreenRuledFixture[]
|
||||
/** Ready recordings the text rules or the quiet-process lane settle with no screen. */
|
||||
@@ -45,8 +45,14 @@ function chunkCount(name: string): number {
|
||||
return Math.ceil(readRuntimeFixture(name).length / 64)
|
||||
}
|
||||
|
||||
/** Whether the agent's own rules read `screenLines` as an idle composer. */
|
||||
export function readsIdleComposer(agent: TuiAgent, screenLines: readonly string[]): boolean {
|
||||
return evaluateAgentStateRules(agent, { readScreenLines: () => screenLines })?.state === 'idle'
|
||||
}
|
||||
|
||||
export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite): void {
|
||||
const { agent, rule } = suite
|
||||
const { agent } = suite
|
||||
const rule = (screenLines: readonly string[]): boolean => readsIdleComposer(agent, screenLines)
|
||||
const [firstReady] = suite.ready
|
||||
if (!firstReady) {
|
||||
throw new Error(`${agent}: a suite needs a ready recording`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
|
||||
|
||||
// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the
|
||||
// Why rows, from each dialog's first `·` on: codex.json's provisional_startup takes a `·` after the
|
||||
// startup header for the live chat's footer, so each dialog must be matched by the time that `·`
|
||||
// lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the
|
||||
// text copy can lose letters and spaces (`updat available`); these rows are fixed literals.
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ensureStructuredAgentSessionHost,
|
||||
stopStructuredAgentSessionRuntime
|
||||
} from './structured-agent-session-runtime'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
|
||||
vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({
|
||||
scanAiVaultSessionsInWorker: vi.fn(),
|
||||
@@ -41,7 +42,8 @@ function installHost(): Promise<StructuredAgentSessionHost> {
|
||||
claimKeyId: 'key-1',
|
||||
resolveWorkspacePath: async () => stateDirectory,
|
||||
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
resolveEnvironment: async () => ({})
|
||||
resolveEnvironment: async () => ({}),
|
||||
logger: createStructuredAgentSessionLogger()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
tailMayContainBlockedSignal
|
||||
} from './terminal-tail-sentinel-index'
|
||||
import { computeTerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection'
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer'
|
||||
import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer'
|
||||
|
||||
// The definition the incremental index must reproduce: does ANY retained line (or the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection'
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer'
|
||||
|
||||
/**
|
||||
* Which retained tail lines match the wait-blocked sentinel, memoized per
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
import { isQoderComposerReady } from './qoder-terminal-readiness'
|
||||
import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isOpenCodeNativeTitle,
|
||||
type AgentStatus
|
||||
} from '../../shared/agent-detection'
|
||||
import { detectAgentStatusFromTitle, type AgentStatus } from '../../shared/agent-detection'
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness'
|
||||
import { readScreenRuledVerdict } from './screen-ruled-agent-readiness'
|
||||
import {
|
||||
findCodexHeaderIndex,
|
||||
findCodexScreenReadyPromptIndex,
|
||||
isCodexComposerReadyScreen,
|
||||
isCodexProvisionalStartupText
|
||||
} from './codex-terminal-readiness'
|
||||
import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals'
|
||||
import { startOfLastNonBlankLines } from './terminal-wait-tail-window'
|
||||
import { findCursorApprovalPromptIndex } from './cursor-approval-prompt-detection'
|
||||
evaluateAgentStateRules,
|
||||
hasQuietReadyRules,
|
||||
holdsReadyTextToQuiet,
|
||||
type AgentStateVerdict
|
||||
} from './agent-state-rules/agent-state-rules-engine'
|
||||
import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors'
|
||||
import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors'
|
||||
import {
|
||||
findTerminalWaitBlockedSignal,
|
||||
isSettledAfter,
|
||||
isUnblockedAfter
|
||||
} from './agent-state-rules/blocked-text-layer'
|
||||
|
||||
// Why agent-agnostic: Orca's own `<Agent> ready` titles, and any agent title stating rest in words.
|
||||
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
|
||||
const CLAUDE_IDLE_PREFIX = '\u2733'
|
||||
const GEMINI_IDLE_PREFIX = '\u25c7'
|
||||
const PI_IDLE_PREFIX = '\u03c0 - '
|
||||
|
||||
function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status !== 'idle') {
|
||||
return null
|
||||
}
|
||||
// Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence.
|
||||
if (
|
||||
EXPLICIT_IDLE_TITLE_RE.test(title) ||
|
||||
// Why: unblock hookless remote waits; guarded writes corroborate this marker.
|
||||
isOpenCodeNativeTitle(title) ||
|
||||
title.startsWith(CLAUDE_IDLE_PREFIX) ||
|
||||
title.startsWith('* ') ||
|
||||
title.includes(GEMINI_IDLE_PREFIX) ||
|
||||
title.startsWith(PI_IDLE_PREFIX)
|
||||
) {
|
||||
return 'idle'
|
||||
}
|
||||
return null
|
||||
return status === 'idle' && (EXPLICIT_IDLE_TITLE_RE.test(title) || showsIdleTitleAnchor(title))
|
||||
? 'idle'
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,32 +38,27 @@ export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus |
|
||||
|
||||
export function isKnownReadyPromptPreview(preview: string): boolean {
|
||||
const normalized = preview.toLowerCase()
|
||||
return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized))
|
||||
return isUnblockedAfter(normalized, findPromptAnchorIndexes(normalized).ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview
|
||||
* (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed
|
||||
* behind it while its daemon starts.
|
||||
* (agent presence), nothing counts while a hold anchor shows (Codex's provisional startup header):
|
||||
* 0.157 discards input typed behind it while its daemon starts.
|
||||
*/
|
||||
export function isKnownReadyPromptSettled(preview: string): boolean {
|
||||
const normalized = preview.toLowerCase()
|
||||
return isReadyPromptSettled(normalized, findKnownReadyPromptIndex(normalized))
|
||||
}
|
||||
|
||||
function isReadyPromptSettled(normalized: string, readyIndex: number | null): boolean {
|
||||
return (
|
||||
isReadyPromptUnblocked(normalized, readyIndex) && !isCodexProvisionalStartupText(normalized)
|
||||
)
|
||||
return isSettledAfter(normalized, findPromptAnchorIndexes(normalized).ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's
|
||||
* visible grid, or null when the runtime has no trustworthy one.
|
||||
* Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the screen the agent's
|
||||
* rules read, or null when the runtime has no trustworthy one.
|
||||
*
|
||||
* Why not for a clocked Codex or screen-ruled pane: its header or composer is also painted
|
||||
* mid-turn, so isQuietReadyScreenBody holds it to quiescence instead.
|
||||
* Why a clockless pane keeps it: quiescence needs an output clock, which a restored pane lacks.
|
||||
* Why the agent's own answer is final: a screen refusal must shut the shared text lane too.
|
||||
* A strong rule held to quiet counts here only on a clockless pane, which cannot measure quiet;
|
||||
* on a clocked one isQuietReadyScreenBody holds it to quiescence instead, and an agent whose
|
||||
* own ready text is held to quiet takes no shared text either.
|
||||
*/
|
||||
export function isKnownReadyPromptBody(
|
||||
waitText: string,
|
||||
@@ -90,77 +69,50 @@ export function isKnownReadyPromptBody(
|
||||
if (agent === 'qoder') {
|
||||
return isQoderComposerReady(readScreenLines())
|
||||
}
|
||||
const screenVerdict = readScreenRuledVerdict(agent, readScreenLines)
|
||||
if (screenVerdict !== null) {
|
||||
return screenVerdict && !hasOutputClock
|
||||
}
|
||||
if (agent === 'codex' && hasOutputClock) {
|
||||
// Why before the rules: such an agent settles only on the quiet lane while it has a clock.
|
||||
if (hasOutputClock && holdsReadyTextToQuiet(agent)) {
|
||||
return false
|
||||
}
|
||||
if (isKnownReadyPromptSettled(waitText)) {
|
||||
return true
|
||||
const ruled = evaluateAgentStateRules(agent, {
|
||||
readScreenLines,
|
||||
readText: () => waitText.toLowerCase(),
|
||||
hasOutputClock
|
||||
})
|
||||
if (ruled !== null) {
|
||||
return isStrongIdle(ruled) && (!ruled.requiresQuiet || !hasOutputClock)
|
||||
}
|
||||
// Why the agent gate: another agent's screen can merely mention "OpenAI Codex".
|
||||
if (agent !== null && agent !== 'codex') {
|
||||
return false
|
||||
}
|
||||
const screen = readScreen(readScreenLines)
|
||||
return screen !== null && isCodexScreenHeaderReady(screen)
|
||||
return isKnownReadyPromptSettled(waitText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1
|
||||
* it only proves the TUI is up, so the ranking holds it to quiescence.
|
||||
* Why identified panes only: a `cat`ed transcript or pager in an unknown pane can show the composer.
|
||||
* Tier 1b body evidence: a ready screen or text the agent also paints mid-turn, so the ranking
|
||||
* holds it to quiescence. Why identified panes only for Muse: a `cat`ed transcript or pager in an
|
||||
* unknown pane can show the composer.
|
||||
*/
|
||||
export function isQuietReadyScreenBody(
|
||||
waitText: string,
|
||||
agent: TuiAgent | null,
|
||||
readScreenLines: () => readonly string[] | null
|
||||
): boolean {
|
||||
if (agent === 'codex') {
|
||||
const screen = readScreen(readScreenLines)
|
||||
if (
|
||||
screen !== null &&
|
||||
(isCodexComposerReadyScreen(screen) || isCodexScreenHeaderReady(screen))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Why the provisional veto here too: a daemon start can stay quiet past the quiescence window.
|
||||
const normalized = waitText.toLowerCase()
|
||||
return isReadyPromptSettled(normalized, findCodexReadyPromptIndex(normalized))
|
||||
}
|
||||
if (readScreenRuledVerdict(agent, readScreenLines) === true) {
|
||||
return true
|
||||
if (hasQuietReadyRules(agent)) {
|
||||
const ruled = evaluateAgentStateRules(agent, {
|
||||
readScreenLines,
|
||||
readText: () => waitText.toLowerCase()
|
||||
})
|
||||
return ruled !== null && isStrongIdle(ruled) && ruled.requiresQuiet
|
||||
}
|
||||
return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the screen: Codex repaints its 0.150-0.157 header by cell diff (`ESC[5;3Hdir
|
||||
* ESC[5;7Hctory:`), which only a grid reassembles — the line-folded wait text reads `dirctory:`.
|
||||
* Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize
|
||||
* mid-paint) garbles the header, so the text rule keeps every verdict it gives on its own.
|
||||
*/
|
||||
function isCodexScreenHeaderReady(screen: string): boolean {
|
||||
return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen))
|
||||
}
|
||||
|
||||
function readScreen(readScreenLines: () => readonly string[] | null): string | null {
|
||||
return readScreenLines()?.join('\n').toLowerCase() ?? null
|
||||
}
|
||||
|
||||
function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean {
|
||||
if (readyIndex === null) {
|
||||
return false
|
||||
}
|
||||
const blockedSignal = findTerminalWaitBlockedSignal(normalized)
|
||||
return blockedSignal === null || blockedSignal.index <= readyIndex
|
||||
function isStrongIdle(
|
||||
verdict: AgentStateVerdict
|
||||
): verdict is Extract<AgentStateVerdict, { state: 'idle' }> {
|
||||
return verdict.state === 'idle' && verdict.strength === 'strong'
|
||||
}
|
||||
|
||||
export function isMuseReadyPromptPreview(preview: string): boolean {
|
||||
const normalized = preview.toLowerCase()
|
||||
return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized))
|
||||
return isUnblockedAfter(normalized, findMuseReadyPromptIndex(normalized))
|
||||
}
|
||||
|
||||
export function detectTerminalWaitBlockedReason(
|
||||
@@ -185,44 +137,11 @@ export function findActionableTerminalWaitBlockedSignal(
|
||||
}
|
||||
|
||||
// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits.
|
||||
// Why Muse beside the rule-file anchors: Muse has not moved to agent-state-rules/ yet.
|
||||
function findDismissedStartupModalIndex(normalized: string): number | null {
|
||||
const indexes = [
|
||||
findCodexReadyPromptIndex(normalized),
|
||||
findCodexHeaderIndex(normalized),
|
||||
findAntigravityReadyPromptIndex(normalized),
|
||||
findCursorActivePromptIndex(normalized),
|
||||
findMuseReadyPromptIndex(normalized)
|
||||
].filter((index): index is number => index !== null)
|
||||
return indexes.length > 0 ? Math.max(...indexes) : null
|
||||
}
|
||||
|
||||
function findKnownReadyPromptIndex(normalized: string): number | null {
|
||||
const indexes = [
|
||||
findCodexReadyPromptIndex(normalized),
|
||||
findAntigravityReadyPromptIndex(normalized),
|
||||
findCursorReadyPromptIndex(normalized)
|
||||
].filter((index): index is number => index !== null)
|
||||
return indexes.length > 0 ? Math.max(...indexes) : null
|
||||
}
|
||||
|
||||
// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt.
|
||||
function findCursorActivePromptIndex(normalized: string): number | null {
|
||||
const headerIndex = normalized.lastIndexOf('cursor agent')
|
||||
if (headerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
return normalized.includes('→', headerIndex) ? headerIndex : null
|
||||
}
|
||||
|
||||
// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle).
|
||||
const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/
|
||||
|
||||
function findCursorReadyPromptIndex(normalized: string): number | null {
|
||||
const activeIndex = findCursorActivePromptIndex(normalized)
|
||||
if (activeIndex === null) {
|
||||
return null
|
||||
}
|
||||
return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex
|
||||
const live = findPromptAnchorIndexes(normalized).live
|
||||
const muse = findMuseReadyPromptIndex(normalized)
|
||||
return live === null || muse === null ? (live ?? muse) : Math.max(live, muse)
|
||||
}
|
||||
|
||||
// Why: Muse titles its OSC with the bare cwd and never updates it, so only the body can
|
||||
@@ -237,114 +156,3 @@ function findMuseReadyPromptIndex(normalized: string): number | null {
|
||||
? headerIndex
|
||||
: null
|
||||
}
|
||||
|
||||
function findCodexReadyPromptIndex(normalized: string): number | null {
|
||||
const headerIndex = normalized.lastIndexOf('openai codex')
|
||||
if (headerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const readySegment = normalized.slice(headerIndex)
|
||||
// Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory.
|
||||
return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null
|
||||
}
|
||||
|
||||
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =
|
||||
/update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
|
||||
|
||||
// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the
|
||||
// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review,
|
||||
// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list.
|
||||
const LIVE_PROMPT_TAIL_LINES = 12
|
||||
|
||||
function findTerminalWaitBlockedSignal(
|
||||
fullTail: string
|
||||
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
|
||||
const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES)
|
||||
const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart)
|
||||
// Why: one combined negative scan avoids a dozen searches when no prompt can match.
|
||||
if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
const signal = findBlockedSignalInLiveWindow(normalized)
|
||||
// Why: callers compare this index against ready-header indexes found over the full tail.
|
||||
return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart }
|
||||
}
|
||||
|
||||
function findBlockedSignalInLiveWindow(
|
||||
normalized: string
|
||||
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
|
||||
const candidates = findStartupDialogBlockedSignals(normalized)
|
||||
const trustIndex = Math.max(
|
||||
normalized.lastIndexOf('do you trust'),
|
||||
normalized.lastIndexOf('trust this'),
|
||||
normalized.lastIndexOf('trusted workspace')
|
||||
)
|
||||
const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex)
|
||||
if (
|
||||
trustIndex !== -1 &&
|
||||
(trustSegment.includes('workspace') ||
|
||||
trustSegment.includes('folder') ||
|
||||
trustSegment.includes('directory') ||
|
||||
trustSegment.includes('repo'))
|
||||
) {
|
||||
// Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog.
|
||||
candidates.push({ reason: 'agent-trust-workspace', index: trustIndex })
|
||||
}
|
||||
const interactivePromptIndex = Math.max(
|
||||
normalized.lastIndexOf('press enter to confirm'),
|
||||
normalized.lastIndexOf('press enter to continue'),
|
||||
normalized.lastIndexOf('press enter to view'),
|
||||
normalized.lastIndexOf('press enter to insert'),
|
||||
normalized.lastIndexOf('press t to trust')
|
||||
)
|
||||
const interactivePromptContext =
|
||||
interactivePromptIndex === -1
|
||||
? ''
|
||||
: normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200)
|
||||
// Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is
|
||||
// that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies
|
||||
// on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane.
|
||||
const hasInteractiveDialogContext =
|
||||
interactivePromptContext.includes('codex') ||
|
||||
interactivePromptContext.includes('permission') ||
|
||||
interactivePromptContext.includes('sandbox') ||
|
||||
interactivePromptContext.includes('trust') ||
|
||||
interactivePromptContext.includes('hook')
|
||||
if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) {
|
||||
const contextStart = Math.max(0, interactivePromptIndex - 600)
|
||||
const hasSpecificPromptInContext = candidates.some(
|
||||
(candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex
|
||||
)
|
||||
if (!hasSpecificPromptInContext) {
|
||||
candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex })
|
||||
}
|
||||
}
|
||||
const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized)
|
||||
if (cursorApprovalIndex !== null) {
|
||||
candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex })
|
||||
}
|
||||
const permissionPromptIndex = Math.max(
|
||||
normalized.lastIndexOf('permission required'),
|
||||
normalized.lastIndexOf('requires permission')
|
||||
)
|
||||
if (permissionPromptIndex !== -1) {
|
||||
const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500)
|
||||
const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) =>
|
||||
permissionSegment.includes(choice)
|
||||
).length
|
||||
if (decisionCount >= 2) {
|
||||
// Why neutral: an approval dialog with named choices identifies no agent; older hosts publish
|
||||
// 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member --
|
||||
// remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no
|
||||
// consumer switches exhaustively on it.
|
||||
// Why alias rather than drop the old spelling: preserve the existing remote receipt value for
|
||||
// mixed-version clients -- an older host still publishes codex-* on this path.
|
||||
candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex })
|
||||
}
|
||||
}
|
||||
return candidates.length > 0
|
||||
? candidates.reduce((latest, candidate) =>
|
||||
candidate.index > latest.index ? candidate : latest
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
|
||||
import { buildTailLines } from './terminal-tail-state'
|
||||
import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index'
|
||||
import {
|
||||
findActionableTerminalWaitBlockedSignal,
|
||||
TERMINAL_WAIT_BLOCKED_SENTINEL_RE
|
||||
} from './terminal-wait-detection'
|
||||
import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection'
|
||||
import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer'
|
||||
|
||||
export function buildTerminalWaitText(
|
||||
lines: string[],
|
||||
|
||||
@@ -4,7 +4,10 @@ import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-tit
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal'
|
||||
import { isKnownReadyPromptBody } from './terminal-wait-detection'
|
||||
import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness'
|
||||
import {
|
||||
evaluateAgentStateRules,
|
||||
readsTrustedScreen
|
||||
} from './agent-state-rules/agent-state-rules-engine'
|
||||
import {
|
||||
evaluateTuiIdle,
|
||||
hasFreshDoneFirstPartyStatus,
|
||||
@@ -32,7 +35,7 @@ function input(overrides: Partial<TuiIdleEvaluationInput> = {}): TuiIdleEvaluati
|
||||
readTailBlockedReason: () => null,
|
||||
readPositiveBodyEvidence: () => false,
|
||||
readQuietReadyBodyEvidence: () => true,
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
agent: 'muse',
|
||||
firstPartyStatus: null,
|
||||
quiescenceMs: QUIESCENCE_MS,
|
||||
@@ -209,17 +212,18 @@ describe('rest signal agrees with the lanes that can settle a wait', () => {
|
||||
)
|
||||
const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS)
|
||||
// Why a screen-ruled `none` is sound: its screen shuts the quiet lane whenever one is readable.
|
||||
const rule = getScreenReadyRule(agent)
|
||||
if (rule !== null) {
|
||||
if (readsTrustedScreen(agent)) {
|
||||
const refused = ['> not an idle composer']
|
||||
expect(rule(refused)).toBe(false)
|
||||
const ruled = (screen: readonly string[] | null) =>
|
||||
evaluateAgentStateRules(agent, { readScreenLines: () => screen })
|
||||
expect(ruled(refused)?.state).toBe('hold')
|
||||
const verdict = (screen: readonly string[] | null) =>
|
||||
evaluateTuiIdle(
|
||||
input({
|
||||
agent,
|
||||
record: record({ lastOscTitle: null }),
|
||||
readQuietReadyBodyEvidence: () => false,
|
||||
readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, () => screen) !== null
|
||||
readAgentRuleVerdict: () => ruled(screen)
|
||||
})
|
||||
)
|
||||
expect(verdict(refused)).toEqual({ kind: 'pending', quietForeground: 'closed' })
|
||||
@@ -270,7 +274,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => {
|
||||
rendererTitle: undefined,
|
||||
readPositiveBodyEvidence: () => false,
|
||||
readQuietReadyBodyEvidence: () => false,
|
||||
readScreenDecidesReadiness: () => false,
|
||||
readAgentRuleVerdict: () => null,
|
||||
readTailBlockedReason: () => null,
|
||||
agent: 'dsh' as const,
|
||||
firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() },
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
isKnownReadyPromptBody,
|
||||
isQuietReadyScreenBody
|
||||
} from './terminal-wait-detection'
|
||||
import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness'
|
||||
import {
|
||||
evaluateAgentStateRules,
|
||||
hasQuietReadyRules,
|
||||
idleTitleRequiresQuiet,
|
||||
readsTrustedScreen,
|
||||
type AgentStateVerdict
|
||||
} from './agent-state-rules/agent-state-rules-engine'
|
||||
|
||||
/**
|
||||
* Ranking the evidence that a `tui-idle` wait may settle on.
|
||||
@@ -30,9 +36,9 @@ import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent
|
||||
* 0. BLOCKED — the tail shows a prompt waiting on the user.
|
||||
* 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own
|
||||
* title, or a known ready-prompt body.
|
||||
* 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, and the screen-ruled
|
||||
* agents (Antigravity, Cline, Prime Agent) can paint their idle composer mid-turn, so their
|
||||
* ready-screen body is believed only once quiet.
|
||||
* 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules
|
||||
* (agent-state-rules/) read a ready screen or text they also paint mid-turn (Codex's
|
||||
* header and composer), so that body is believed only once quiet.
|
||||
* 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/
|
||||
* waiting, or a working title. The agent's own account of itself outranks anything
|
||||
* inferred.
|
||||
@@ -116,20 +122,16 @@ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus):
|
||||
return isFreshNonDoneAgentStatus(status ?? undefined)
|
||||
}
|
||||
|
||||
/** Agents that paint their own explicit rest title (Claude's `✳`). Gemini's `◇` would qualify
|
||||
* but is not yet held to it. */
|
||||
const NATIVE_EXPLICIT_IDLE_TITLE_AGENTS: ReadonlySet<TuiAgent> = new Set(['claude'])
|
||||
|
||||
/**
|
||||
* Whether a name-only title from `agent` must be corroborated by a quiet stream.
|
||||
*
|
||||
* Only for agents that announce rest with an explicit title of their own: the hook-driven
|
||||
* `Codex ready` / `Devin ready`, or Claude's `✳`. For them the bare name is not a rest signal —
|
||||
* a shell auto-title (`claude`, `claude ~/repo`) names the agent from the moment it starts, over
|
||||
* a start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo, agy and OpenCode emit
|
||||
* their NAME and nothing more at rest, so holding them to it leaves no settle signal at all:
|
||||
* a real idle Grok pane repaints its banner about four times a second forever, so the stream
|
||||
* never quiesces and the wait runs to timeout.
|
||||
* An agent's own idle-title rule (agent-state-rules/) answers. Without one, only agents whose
|
||||
* hooks drive an explicit `<Agent> ready` title are held to it: for them the bare name is not a
|
||||
* rest signal, since a shell auto-title names the agent from the moment it starts, over a
|
||||
* start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo and agy emit their NAME and
|
||||
* nothing more at rest, so holding them to it leaves no settle signal at all: a real idle Grok
|
||||
* pane repaints its banner about four times a second forever, so the stream never quiesces and
|
||||
* the wait runs to timeout.
|
||||
*/
|
||||
export function nameOnlyIdleNeedsCorroboration(
|
||||
agent: TuiAgent | null | undefined,
|
||||
@@ -138,10 +140,11 @@ export function nameOnlyIdleNeedsCorroboration(
|
||||
// Why the title fallback: an adopted pane carries no launch metadata, but its
|
||||
// name-only title is exactly the thing that names the agent.
|
||||
const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null)
|
||||
if (resolved === null) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
resolved !== null &&
|
||||
(NATIVE_EXPLICIT_IDLE_TITLE_AGENTS.has(resolved) ||
|
||||
getSyntheticAgentTerminalTitle(resolved, 'done') !== null)
|
||||
idleTitleRequiresQuiet(resolved) ?? getSyntheticAgentTerminalTitle(resolved, 'done') !== null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,10 +168,12 @@ export function hasSustainedTitleIdle(
|
||||
// no local output clock, so for an agent that WILL announce rest explicitly there is no
|
||||
// corroboration available at all. Settling here let a busy Codex/Devin satisfy the wait
|
||||
// from a name-only title (#6011); hold out for tier 1/2 or the caller's timeout instead.
|
||||
if (record.lastOutputAt === null) {
|
||||
return false
|
||||
}
|
||||
return Date.now() - record.lastOutputAt >= quiescenceMs
|
||||
return hasQuietOutput(record, quiescenceMs)
|
||||
}
|
||||
|
||||
/** Why a missing clock is not quiet: an adopted or restored pane cannot measure it. */
|
||||
function hasQuietOutput(record: TuiIdleEvidenceRecord, quiescenceMs: number): boolean {
|
||||
return record.lastOutputAt !== null && Date.now() - record.lastOutputAt >= quiescenceMs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,10 +206,10 @@ export type TuiIdleEvaluationInput = {
|
||||
* (~11us and a multi-KB string on a full tail); the title check below usually answers
|
||||
* first, and then none of that has to happen at all. */
|
||||
readPositiveBodyEvidence: () => boolean
|
||||
/** Tier 1b body evidence: a Muse, Codex or screen-ruled ready screen. Thunk, as above. */
|
||||
/** Tier 1b body evidence: a Muse ready screen, or a rule-file one held to quiet. Thunk, as above. */
|
||||
readQuietReadyBodyEvidence: () => boolean
|
||||
/** Whether the agent's live screen already ruled on readiness, which shuts the weak lanes. */
|
||||
readScreenDecidesReadiness: () => boolean
|
||||
/** The agent's own rules' answer; any answer shuts the lanes that cannot see its screen. */
|
||||
readAgentRuleVerdict: () => AgentStateVerdict | null
|
||||
agent: TuiAgent | null | undefined
|
||||
firstPartyStatus: FirstPartyAgentStatus
|
||||
quiescenceMs: number
|
||||
@@ -226,8 +231,6 @@ const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' }
|
||||
const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' }
|
||||
const WORKING: TuiIdleVerdict = { kind: 'working' }
|
||||
|
||||
const QUIET_READY_SCREEN_AGENTS: ReadonlySet<TuiAgent> = new Set(['muse', 'codex'])
|
||||
|
||||
/**
|
||||
* Tier 1b: a ready screen in the body, believed only once the stream has gone quiet.
|
||||
*
|
||||
@@ -235,9 +238,9 @@ const QUIET_READY_SCREEN_AGENTS: ReadonlySet<TuiAgent> = new Set(['muse', 'codex
|
||||
* the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the
|
||||
* sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence
|
||||
* demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's
|
||||
* positive-evidence-plus-quiet shape. Scoped to those agents, the screen-ruled ones, and
|
||||
* agent-unknown panes (which read only Muse's screen): another agent's scrollback quoting them
|
||||
* must not settle its wait.
|
||||
* positive-evidence-plus-quiet shape. Scoped to Muse, agents with quiet ready rules, and
|
||||
* agent-unknown panes (which read only Muse's screen here): another agent's scrollback quoting
|
||||
* them must not settle its wait.
|
||||
*/
|
||||
export function hasQuietReadyScreen(
|
||||
record: TuiIdleEvidenceRecord,
|
||||
@@ -245,12 +248,12 @@ export function hasQuietReadyScreen(
|
||||
readBodyEvidence: () => boolean,
|
||||
quiescenceMs: number
|
||||
): boolean {
|
||||
if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent) && !getScreenReadyRule(agent)) {
|
||||
if (agent && agent !== 'muse' && !hasQuietReadyRules(agent)) {
|
||||
return false
|
||||
}
|
||||
// Why: same rule as the tier-3 lane — without an output clock there is no
|
||||
// corroboration available, so hold out instead of settling.
|
||||
if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) {
|
||||
if (!hasQuietOutput(record, quiescenceMs)) {
|
||||
return false
|
||||
}
|
||||
// Why last: a streaming pane never pays for the screen projection.
|
||||
@@ -308,9 +311,13 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
|
||||
if (input.record.lastAgentStatus === 'working') {
|
||||
return WORKING
|
||||
}
|
||||
// Why: a name-only title and a quiet process cannot see the picker or prompt the screen refused.
|
||||
if (input.readScreenDecidesReadiness()) {
|
||||
return { kind: 'pending', quietForeground: 'closed' }
|
||||
// Why here: a name-only title and a quiet process cannot see the picker or prompt the screen
|
||||
// refused, and an agent's own idle-title rule replaces the sustained-title lane below.
|
||||
const ruled = input.readAgentRuleVerdict()
|
||||
if (ruled !== null) {
|
||||
return isSettledWeakIdle(ruled, input.record, input.quiescenceMs)
|
||||
? READY_WEAK
|
||||
: { kind: 'pending', quietForeground: 'closed' }
|
||||
}
|
||||
if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs, input.launchReadiness)) {
|
||||
return READY_WEAK
|
||||
@@ -322,6 +329,18 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
|
||||
}
|
||||
}
|
||||
|
||||
function isSettledWeakIdle(
|
||||
verdict: AgentStateVerdict,
|
||||
record: TuiIdleEvidenceRecord,
|
||||
quiescenceMs: number
|
||||
): boolean {
|
||||
return (
|
||||
verdict.state === 'idle' &&
|
||||
verdict.strength === 'weak' &&
|
||||
(!verdict.requiresQuiet || hasQuietOutput(record, quiescenceMs))
|
||||
)
|
||||
}
|
||||
|
||||
export function isTuiIdleReadyVerdict(verdict: TuiIdleVerdict): boolean {
|
||||
return verdict.kind === 'ready-strong' || verdict.kind === 'ready-weak'
|
||||
}
|
||||
@@ -334,20 +353,37 @@ export type TuiIdleEvidenceSource = {
|
||||
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
|
||||
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
|
||||
readScreenLines(ptyId: string | null | undefined): readonly string[] | null
|
||||
/** The painted rows on the PTY's own grid, which only screen-ruled agents read. Absent, they
|
||||
* have no trustworthy screen. */
|
||||
/** The painted rows on the PTY's own grid, which only agents whose rules read the trusted
|
||||
* screen use. Absent, they have no trustworthy screen. */
|
||||
readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null
|
||||
}
|
||||
|
||||
// Why per agent table: every other agent keeps the screen it read before screen rules existed.
|
||||
// Why per agent: every other agent keeps the live screen its rules were recorded against.
|
||||
// Why read once: several lanes consult the rules, and one evaluation sees one screen.
|
||||
function screenReader(
|
||||
source: TuiIdleEvidenceSource,
|
||||
agent: TuiAgent | null,
|
||||
ptyId: string | null | undefined
|
||||
): () => readonly string[] | null {
|
||||
return getScreenReadyRule(agent)
|
||||
let lines: readonly string[] | null | undefined
|
||||
const read = readsTrustedScreen(agent)
|
||||
? () => source.readScreenRuledLines?.(ptyId) ?? null
|
||||
: () => source.readScreenLines(ptyId)
|
||||
return () => (lines === undefined ? (lines = read()) : lines)
|
||||
}
|
||||
|
||||
function readAgentRuleVerdict(
|
||||
agent: TuiAgent | null,
|
||||
record: TuiIdleEvidenceRecord,
|
||||
readScreenLines: () => readonly string[] | null,
|
||||
waitText: () => string
|
||||
): AgentStateVerdict | null {
|
||||
return evaluateAgentStateRules(agent, {
|
||||
readScreenLines,
|
||||
readText: () => waitText().toLowerCase(),
|
||||
readTitleStatus: () => record.lastAgentStatus,
|
||||
hasOutputClock: record.lastOutputAt !== null
|
||||
})
|
||||
}
|
||||
|
||||
function lazyWaitText(readWaitText: () => string): () => string {
|
||||
@@ -370,7 +406,7 @@ export function leafTuiIdleEvidence(
|
||||
readPositiveBodyEvidence: () =>
|
||||
isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null),
|
||||
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
|
||||
readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null,
|
||||
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText),
|
||||
agent,
|
||||
firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId),
|
||||
quiescenceMs: source.quiescenceMs
|
||||
@@ -392,7 +428,7 @@ export function ptyTuiIdleEvidence(
|
||||
(agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
|
||||
isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null),
|
||||
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
|
||||
readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null,
|
||||
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText),
|
||||
agent,
|
||||
firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId),
|
||||
quiescenceMs: source.quiescenceMs
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
OrcadManagedStopResult
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
|
||||
import type { KnownRuntimeEnvironment, OrcadDeploymentLink } from '../../shared/runtime-environments'
|
||||
import type {
|
||||
KnownRuntimeEnvironment,
|
||||
OrcadDeploymentLink
|
||||
} from '../../shared/runtime-environments'
|
||||
import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery'
|
||||
import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock'
|
||||
import { readOrcadActivationTransaction } from './orcad-activation-transaction-store'
|
||||
|
||||
@@ -72,106 +72,114 @@ export function updateManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs & { force?: boolean }
|
||||
): Promise<OrcadManagedDeployResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const census = await collectManagedTerminalCensus(
|
||||
userDataPath,
|
||||
environment,
|
||||
context.activationRecord
|
||||
)
|
||||
const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, {
|
||||
signal: args.signal
|
||||
})
|
||||
const result = await deployOrcad({
|
||||
...managedOrcadSlot(context, deployment.remotePort, args.signal),
|
||||
localOrcadDir,
|
||||
target: context.serverTarget,
|
||||
census,
|
||||
force: args.force
|
||||
})
|
||||
if (result.outcome === 'installed-not-activated') {
|
||||
const deferral = {
|
||||
outcome: 'deferred' as const,
|
||||
candidateVersion: result.fullVersion,
|
||||
code: result.code,
|
||||
reason: result.reason,
|
||||
forceable: isForceableOrcadDeferral(result.code)
|
||||
return withManagedOrcadLifecycle(
|
||||
userDataPath,
|
||||
args.selector,
|
||||
async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const census = await collectManagedTerminalCensus(
|
||||
userDataPath,
|
||||
environment,
|
||||
context.activationRecord
|
||||
)
|
||||
const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, {
|
||||
signal: args.signal
|
||||
})
|
||||
const result = await deployOrcad({
|
||||
...managedOrcadSlot(context, deployment.remotePort, args.signal),
|
||||
localOrcadDir,
|
||||
target: context.serverTarget,
|
||||
census,
|
||||
force: args.force
|
||||
})
|
||||
if (result.outcome === 'installed-not-activated') {
|
||||
const deferral = {
|
||||
outcome: 'deferred' as const,
|
||||
candidateVersion: result.fullVersion,
|
||||
code: result.code,
|
||||
reason: result.reason,
|
||||
forceable: isForceableOrcadDeferral(result.code)
|
||||
}
|
||||
recordManagedOrcadUpdateDeferral(environment.id, deferral)
|
||||
return deferral
|
||||
}
|
||||
clearManagedOrcadUpdateDeferral(environment.id)
|
||||
const readiness = await probeManagedOrcadReadiness(
|
||||
context,
|
||||
localOrcadDir,
|
||||
result.fullVersion,
|
||||
args.signal
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: result.outcome === 'already-active' ? 'already-current' : 'updated',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.fullVersion
|
||||
}
|
||||
recordManagedOrcadUpdateDeferral(environment.id, deferral)
|
||||
return deferral
|
||||
}
|
||||
clearManagedOrcadUpdateDeferral(environment.id)
|
||||
const readiness = await probeManagedOrcadReadiness(
|
||||
context,
|
||||
localOrcadDir,
|
||||
result.fullVersion,
|
||||
args.signal
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: result.outcome === 'already-active' ? 'already-current' : 'updated',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.fullVersion
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function rollbackManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs
|
||||
): Promise<OrcadManagedRollbackResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const record = context.activationRecord
|
||||
const target = record.previous
|
||||
if (!target) {
|
||||
return withManagedOrcadLifecycle(
|
||||
userDataPath,
|
||||
args.selector,
|
||||
async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const record = context.activationRecord
|
||||
const target = record.previous
|
||||
if (!target) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code: 'orcad_rollback_no_target',
|
||||
reason: 'This server has no previous version to roll back to.'
|
||||
}
|
||||
}
|
||||
const census = await collectManagedTerminalCensus(userDataPath, environment, record)
|
||||
// Why idle only: this client cannot read the older build's daemon protocol, so it cannot show
|
||||
// that build would reach terminals that are still running.
|
||||
if (census.liveSessions !== 0) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code:
|
||||
census.liveSessions === null
|
||||
? 'orcad_rollback_census_unavailable'
|
||||
: 'orcad_rollback_terminals_running',
|
||||
reason:
|
||||
census.liveSessions === null
|
||||
? 'The server did not answer how many terminals it runs. Retry when it answers.'
|
||||
: 'Close the terminals running on this server before rolling it back.'
|
||||
}
|
||||
}
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
const targetDir = managedOrcadInstallDir(context, target)
|
||||
const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir)
|
||||
const result = await rollbackOrcad({
|
||||
...slot,
|
||||
record,
|
||||
census,
|
||||
targetBuildHash,
|
||||
targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL
|
||||
})
|
||||
if (result.outcome !== 'rolled-back') {
|
||||
return result
|
||||
}
|
||||
const readiness = await probeActiveOrcadReadiness(
|
||||
{ ...slot, remoteInstallDir: targetDir },
|
||||
{ buildHash: targetBuildHash, fullVersion: result.target }
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code: 'orcad_rollback_no_target',
|
||||
reason: 'This server has no previous version to roll back to.'
|
||||
outcome: 'rolled-back',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.target,
|
||||
discarded: result.discarded
|
||||
}
|
||||
}
|
||||
const census = await collectManagedTerminalCensus(userDataPath, environment, record)
|
||||
// Why idle only: this client cannot read the older build's daemon protocol, so it cannot show
|
||||
// that build would reach terminals that are still running.
|
||||
if (census.liveSessions !== 0) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code:
|
||||
census.liveSessions === null
|
||||
? 'orcad_rollback_census_unavailable'
|
||||
: 'orcad_rollback_terminals_running',
|
||||
reason:
|
||||
census.liveSessions === null
|
||||
? 'The server did not answer how many terminals it runs. Retry when it answers.'
|
||||
: 'Close the terminals running on this server before rolling it back.'
|
||||
}
|
||||
}
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
const targetDir = managedOrcadInstallDir(context, target)
|
||||
const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir)
|
||||
const result = await rollbackOrcad({
|
||||
...slot,
|
||||
record,
|
||||
census,
|
||||
targetBuildHash,
|
||||
targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL
|
||||
})
|
||||
if (result.outcome !== 'rolled-back') {
|
||||
return result
|
||||
}
|
||||
const readiness = await probeActiveOrcadReadiness(
|
||||
{ ...slot, remoteInstallDir: targetDir },
|
||||
{ buildHash: targetBuildHash, fullVersion: result.target }
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: 'rolled-back',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.target,
|
||||
discarded: result.discarded
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Finishes or undoes an interrupted activation, rollback or decommission on the host. */
|
||||
@@ -179,25 +187,29 @@ export function recoverManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs
|
||||
): Promise<OrcadManagedRecoveryResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const result = await recoverInterruptedOrcadActivation(
|
||||
managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
)
|
||||
if (result.outcome !== 'recovered') {
|
||||
return result
|
||||
return withManagedOrcadLifecycle(
|
||||
userDataPath,
|
||||
args.selector,
|
||||
async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const result = await recoverInterruptedOrcadActivation(
|
||||
managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
)
|
||||
if (result.outcome !== 'recovered') {
|
||||
return result
|
||||
}
|
||||
const updated = result.readiness
|
||||
? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort)
|
||||
: environment
|
||||
if (result.activeVersion) {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
}
|
||||
return {
|
||||
outcome: 'recovered',
|
||||
resolution: result.resolution,
|
||||
activeVersion: result.activeVersion,
|
||||
environment: redactRuntimeEnvironment(updated)
|
||||
}
|
||||
}
|
||||
const updated = result.readiness
|
||||
? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort)
|
||||
: environment
|
||||
if (result.activeVersion) {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
}
|
||||
return {
|
||||
outcome: 'recovered',
|
||||
resolution: result.resolution,
|
||||
activeVersion: result.activeVersion,
|
||||
environment: redactRuntimeEnvironment(updated)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
* Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure,
|
||||
* including an older host without the method, reads as an unverifiable census, never as zero.
|
||||
*/
|
||||
import {
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
|
||||
ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
type KnownRuntimeEnvironment
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments'
|
||||
import {
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification'
|
||||
|
||||
|
||||
@@ -35,14 +35,6 @@ function shouldFocusEmptyEditorFromSurfaceClick(
|
||||
return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input')
|
||||
}
|
||||
|
||||
function shouldReturnFocusToEditor(event: React.MouseEvent<HTMLDivElement>): boolean {
|
||||
if (event.button !== 0) {
|
||||
return false
|
||||
}
|
||||
const target = event.target
|
||||
return target instanceof Element && Boolean(target.closest('.ProseMirror'))
|
||||
}
|
||||
|
||||
type RichMarkdownEditorSurfaceProps = {
|
||||
editor: Editor | null
|
||||
editorFontZoomLevel: number
|
||||
@@ -217,11 +209,6 @@ export function RichMarkdownEditorSurface({
|
||||
// Image layout must not anchor-scroll over the restored tab position.
|
||||
className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]"
|
||||
onMouseDown={(event) => {
|
||||
if (shouldReturnFocusToEditor(event)) {
|
||||
// Keep the find bar open while handing keyboard focus back to the document.
|
||||
searchState.searchInputRef.current?.blur()
|
||||
editor?.commands.focus()
|
||||
}
|
||||
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea
|
||||
// Pin the last pre-v41 implementation: this contract specifically exercises status-only readers.
|
||||
const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619'
|
||||
|
||||
// Why 120 s: a cold release checkout materializes in the test body, and its 45 s extraction deadline alone exceeds the 30 s default.
|
||||
test('pre-v41 code opens, acknowledges and writes a current-schema database, then current code reopens it', async () => {
|
||||
const checkout = await materializeReleaseCheckout(PRE_V41)
|
||||
const baseline = await importReleaseCheckoutModule(
|
||||
@@ -67,4 +68,4 @@ test('pre-v41 code opens, acknowledges and writes a current-schema database, the
|
||||
db?.close()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}, 120_000)
|
||||
|
||||
Reference in New Issue
Block a user